Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions docs/superpowers/plans/2026-08-30-claude-trust-dialog-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Claude trust dialog: highlight-aware accept

Fixes #705. Package half: claude-code-headless PR (branch `fix/trust-dialog-highlight-aware-accept`).

## Problem

Claude Code 2.1.251 changed the folder-trust dialog. The old dialog was a numbered
list with "Yes, I trust this folder" first and pre-highlighted (`❯ 1. Yes, I trust
this folder`), so a bare Enter accepted. The new dialog is unnumbered, lists
**"No, exit" first, and pre-highlights it**:

```
❯ No, exit
Yes, I trust this folder

Enter to confirm · Esc to cancel
```

Every accept path we own writes a bare `'\r'` on the explicit assumption that Yes
is highlighted (`TrustDialogParser.TRUST_DIALOG_ACCEPT_KEYS`, the
`claude.trust-dialog` condition action, `TrustDialogModal`, and the legacy
`trust_dialog` event's `accept`, whose `reject` also writes a now-meaningless
`'2\r'`). Result recorded in debug bundle `2026-08-30T23-51-06-471-9bd68e14`:
clicking "trust this folder" confirms *No, exit*, the CLI exits ~300ms later,
trust never persists, and the dialog returns on every start. Downstream, every
send fails with "Cannot deliver prompt: … is not a live agent session".

## Design: act on what is on screen, never on an assumed layout

The keystrokes stop being static data. Accept becomes a **custom condition
action** resolved by a headless driver (same architecture as AskUserQuestion's
`resolve` slot): observe the live screen, move the highlight to the target if
needed, verify each step, then confirm — and **fail closed** (no blind Enter)
whenever the highlight cannot be proven, because a wrong Enter is session-fatal.

### claude-code-headless

1. `parsers/TrustDialogParser.ts` — extract the real on-screen option rows:
order, label (tolerating the legacy `N. ` numbering prefix), and which row
carries the `❯` highlight pointer. `TrustDialogState.options` becomes
`{ key, label, highlighted }[]` in screen order (keys stay positional for
wire compat). Delete `TRUST_DIALOG_ACCEPT_KEYS` — a constant accept keystroke
is the defect.
2. New `conditions/trustDialogDriver.ts` — `driveTrustDialog(intent, ctx)`
mirroring the AUQ driver: re-detect from `ctx.snapshotPlain()`; if the target
("Yes, I trust this folder") is highlighted, write `'\r'`; else write one
arrow key toward it, poll until the parser proves the highlight moved, then
`'\r'`; finally poll until the dialog is gone. Structured failures
(`option-not-found` / `timeout` / `aborted`) conforming to the existing
`DriveResult` union; decline writes `'\x1b'` (the dialog states "Esc to
cancel"; upstream maps cancel to exit).
3. `conditions/trustDialog.ts` — accept action becomes
`{ kind: 'custom', id: 'accept', name: 'claude.trust-dialog.accept' }`;
decline stays pty `'\x1b'`; module gains `resolve` routing that name to the
driver.
4. `ClaudeCodeHeadless.ts` — legacy `trust_dialog` event callbacks route through
the same driver (accept) and `'\x1b'` (reject, replacing `'2\r'`).

Tests are built from the recorded screens: the 2.1.251 dialog captured in the
incident bundle (path swapped for a neutral one) and the legacy numbered layout
already present in `ScreenParser.composer.test.ts`. Driver tests pin write
*order* against scripted screen sequences: never `'\r'` while "No, exit" is
highlighted; arrow-then-Enter on the new layout; Enter-only on the old; no
writes at all when no highlight is provable.

### agent-code

1. Bump the `packages/claude-code-headless` gitlink; resync `package-lock.json`
(file: dep embeds the package tree).
2. `TrustDialogModal.tsx` — buttons stop synthesizing raw bytes; props become
`onAccept`/`onDecline` and `views.tsx` dispatches the condition actions
(custom accept → `session:resolveCondition` → headless driver; pty Esc for
decline) through the existing outlet dispatch.
3. `providerConditions.ts` — `ClaudeTrustDialogState.options` gains the optional
`highlighted` field to match the parser.
4. Renderer test pinning that accept dispatches the custom action and never a
raw `'\r'`.

## Out of scope

- #706 (never-owned sends to restored sessions — composer-send wake) is a
separate branch/PR.
- Permission/resume prompts still use static keystrokes; no evidence they
regressed on 2.1.251 (their layouts still parse), but they inherit the same
risk class. Noted in #705; not changed here.

## Verification

- Package: `npx tsc --noEmit` + vitest suite.
- Parent: `npx tsc -p tsconfig.node.json --noEmit` and `-p tsconfig.web.json`
(build/vitest do not typecheck), targeted renderer/conditions suites.
17 changes: 13 additions & 4 deletions src/providers/claude/renderer/TrustDialogModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,25 @@ import {
DialogTitle,
} from '@renderer/components/ui/dialog'

// WHY the modal takes intent callbacks instead of an onSend(bytes) writer:
// this component used to write a bare '\r' for accept, assuming Claude Code
// pre-highlights "Yes, I trust this folder". Claude Code 2.1.251 pre-highlights
// "No, exit", so that Enter confirmed the exit option and killed the session
// (#705). The correct keystrokes depend on the live screen, which only the
// headless side can observe — so the modal expresses INTENT and views.tsx
// dispatches the condition actions (custom accept resolved by the headless
// trust-dialog driver; Esc for decline). No raw bytes originate here anymore.
type Props = {
state: { workspace?: string } | null
onSend: (data: string) => Promise<void>
onAccept: () => Promise<void>
onDecline: () => Promise<void>
}

export function TrustDialogModal({ state, onSend }: Props) {
export function TrustDialogModal({ state, onAccept, onDecline }: Props) {
if (!state) return null

const accept = () => { void onSend('\r') }
const decline = () => { void onSend('\x1b') }
const accept = () => { void onAccept() }
const decline = () => { void onDecline() }

return (
<Dialog open onOpenChange={nextOpen => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// claudeTrustView — the trust modal must dispatch condition ACTIONS, never raw
// keystrokes.
//
// The regression this fences (#705): the modal used to send a bare '\r' for
// accept, and Claude Code 2.1.251 pre-highlights "No, exit" — so the app's own
// Trust button confirmed the exit option and terminated the CLI. Accept must
// therefore leave the renderer as the custom `claude.trust-dialog.accept`
// action (resolved headless-side against the live screen); any pty payload
// containing '\r' from this view is the defect coming back.

import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import type { ConditionAction } from '@shared/conditions-core/contract'
import { claudeTrustView } from './views'

function mountTrustView() {
const dispatched: ConditionAction[] = []
const Component = claudeTrustView.Component
render(
<Component
state={{ visible: true, workspace: '/tmp/fresh-project' }}
// The wire action list is deliberately unused by this view (it dispatches
// its own literals — see views.tsx for why), so an empty list here also
// pins that: a view that started reading `actions` would keep passing
// only if it still never turns them into raw Enter bytes.
actions={[]}
dispatch={async action => {
dispatched.push(action)
}}
interactionActive={false}
/>,
)
return dispatched
}

describe('claudeTrustView action dispatch', () => {
it('accept dispatches the headless-resolved custom action, never Enter', () => {
const dispatched = mountTrustView()
fireEvent.click(screen.getByRole('button', { name: 'trust this folder' }))

expect(dispatched).toEqual([
{
kind: 'custom',
id: 'accept',
label: 'Yes, I trust this folder',
name: 'claude.trust-dialog.accept',
},
])
// Stated in both directions: no pty write, and in particular no '\r'.
expect(
dispatched.some(action => action.kind === 'pty' && action.data.includes('\r')),
).toBe(false)
})

it('cancel dispatches Esc — the only keystroke whose meaning survives a re-layout', () => {
const dispatched = mountTrustView()
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))

expect(dispatched).toEqual([
{ kind: 'pty', id: 'decline', label: 'No, exit', data: '\x1b' },
])
})
})
24 changes: 23 additions & 1 deletion src/providers/claude/renderer/conditions/views.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ type ClaudeStateByKind = {

const raw = (data: string) => ({ kind: 'pty' as const, id: 'raw', label: '', data })

// The trust-dialog wire actions, mirrored verbatim from claude-code-headless's
// conditions/trustDialog.ts (the ids/labels/resolver-name are the contract;
// neither side changes without the other).
//
// WHY accept is a CUSTOM action and not a keystroke: Claude Code 2.1.251
// pre-highlights "No, exit", so the raw '\r' this view used to dispatch
// confirmed the exit option and terminated the CLI (#705). Which keystrokes
// accept requires depends on where the highlight currently sits — only the
// headless side can observe that, so accept routes through
// session:resolveCondition to the trust-dialog driver, which walks the
// highlight onto "Yes, I trust this folder" with per-press verification before
// confirming. Esc's meaning does not depend on the highlight, so decline stays
// a plain pty action.
const trustAccept = {
kind: 'custom' as const,
id: 'accept',
label: 'Yes, I trust this folder',
name: 'claude.trust-dialog.accept',
}
const trustDecline = { kind: 'pty' as const, id: 'decline', label: 'No, exit', data: '\x1b' }

// claude.trust-dialog — only renders when visible, mirroring the old outlet's
// `trust?.visible ? { workspace } : null`.
export const claudeTrustView = defineView<'claude.trust-dialog', ClaudeTrustDialogState>({
Expand All @@ -53,7 +74,8 @@ export const claudeTrustView = defineView<'claude.trust-dialog', ClaudeTrustDial
Component: ({ state, dispatch }) => (
<TrustDialogModal
state={state?.visible ? { workspace: state.workspace } : null}
onSend={(data) => dispatch(raw(data))}
onAccept={() => dispatch(trustAccept)}
onDecline={() => dispatch(trustDecline)}
/>
),
})
Expand Down
7 changes: 6 additions & 1 deletion src/shared/types/providerConditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export type { ConditionPtyAction, ConditionCustomAction, ConditionAction }

export type ClaudeTrustDialogState = {
visible: boolean
options?: Array<{ key: string; label: string }>
// `highlighted` marks the row carrying the TUI's `❯` pointer — the row a
// bare Enter would confirm. Optional because snapshots recorded before the
// 2.1.251 trust fix (claude-code-headless#51) predate the field; the
// renderer never synthesizes keystrokes from options either way (accept is
// resolved headless-side from the live screen — see views.tsx).
options?: Array<{ key: string; label: string; highlighted?: boolean }>
workspace?: string
}

Expand Down