From 32f2a6e6f073cf08efa067172b73e0e93871302c Mon Sep 17 00:00:00 2001 From: chiga0 Date: Fri, 11 Sep 2026 17:27:11 +0800 Subject: [PATCH 01/51] fix(cli): draw OpenTUI tool confirmations inline as ink does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ink renders a tool confirmation inline in the transcript, as a sibling directly below the row of the tool call that is waiting, with no border, no title row and no navigation hint; the awaiting call is marked by an arrow appended to its own row. This renderer instead boxed the same content at full width, gave it a title, added a separate row announcing that the call was awaiting approval, and kept a hint about navigating a list. Approving a command therefore read as answering a modal rather than as continuing the transcript, and the box pushed the question and its options several columns past where ink puts them. The border, the title and the hint are gone. The body, the question and the outcome list now sit at the columns ink uses, the awaiting arrow rides the tool card's own row instead of taking one, and while a call is parked the spinner swaps to ink's waiting phrase and drops the elapsed-time, token and cancel suffix — there is no in-flight request to cancel and no tokens to count. That waiting row is rendered only for a tool confirmation: the MCP startup approval, the shell gate and the action confirmations all arrive while idle, where ink's indicator renders nothing, and the captured frames agree. The row budgets that reserved space for the removed chrome shrink with it. Those bounds turn out to be inert at the viewport heights the harness uses, so the change was verified against ink's own frames at a hundred columns rather than against the arithmetic: the confirmation's row sequence now matches ink line for line. What still differs is where the block sits vertically — this renderer anchors it to the bottom of the viewport while ink flows it straight after the tool row — which is a separate known divergence and is not addressed here. --- .../cli/src/ui/opentui/dialogs-confirm.tsx | 60 ++++++++++++------- packages/cli/src/ui/opentui/messages.test.tsx | 22 +++---- packages/cli/src/ui/opentui/messages.tsx | 33 +++++----- .../cli/src/ui/opentui/opentui-app-shell.tsx | 19 ++++-- .../cli/src/ui/opentui/opentui-footer.tsx | 33 ++++++---- .../src/ui/opentui/transcript-view.test.tsx | 22 +++---- .../cli/src/ui/opentui/transcript-view.tsx | 20 ++++--- 7 files changed, 122 insertions(+), 87 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 919c8f45b20..913509b26ee 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -85,11 +85,13 @@ export interface PendingToolConfirmation { const MAX_BODY_ROWS = 20; /** - * Rows reserved above/below an EXPANDED body: dialog chrome (frame, title, - * options, footer) plus the transcript region that keeps its place above the - * dialog. The expanded tail window is budgeted as terminal height minus this - * reserve, so the end of the content — where the options still are — stays on - * screen (ink reaches the same visible outcome through terminal scrollback). + * Rows reserved around an EXPANDED body: the inline confirmation's chrome + * (padding, question, outcome list, waiting row) plus the transcript region + * that keeps its place above it. The expanded tail window is budgeted as + * terminal height minus this reserve, so the end of the content — where the + * options still are — stays on screen (ink reaches the same visible outcome + * through terminal scrollback). Deliberately generous: erring low hides a few + * payload rows, erring high pushes the options off screen. */ const EXPANDED_BODY_RESERVE_ROWS = 20; @@ -514,6 +516,21 @@ export interface OpenTuiToolConfirmationProps { onSettled: () => void; } +/** + * ink renders a tool confirmation inline in the transcript, as a sibling + * directly below the pending tool's own row — no border, no title row and no + * navigation hint. The two-column margin is the transcript's own and the + * one-column padding is ink's outer box, which together put the question and + * the options at column 3; the body adds two more columns of its own. + */ +function InlineConfirmation(props: { children?: ReactNode }) { + return ( + + {props.children} + + ); +} + /** * Renders one awaiting tool call and settles it through * `confirmationDetails.onConfirm`. ask_user_question gets its own flow; every @@ -534,7 +551,7 @@ export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { [details, onSettled], ); - // Esc declines, matching the "No (esc)" option and the footer hint. + // Esc declines, matching the "No (esc)" option label. useKeyboard((key) => { if (toOriginalKey(key).name === 'escape') { settle(ToolConfirmationOutcome.Cancel); @@ -565,24 +582,23 @@ export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { const prompt = buildConfirmationPrompt(details, config.isTrustedFolder()); return ( - - - - {sanitizeTerminalText(details.title)} - - - - + + + + + {sanitizeTerminalText(prompt.question)} - settle(outcome)} - /> - - + settle(outcome)} + /> + ); } diff --git a/packages/cli/src/ui/opentui/messages.test.tsx b/packages/cli/src/ui/opentui/messages.test.tsx index 3cd6e2636bb..2d2ae88da5c 100644 --- a/packages/cli/src/ui/opentui/messages.test.tsx +++ b/packages/cli/src/ui/opentui/messages.test.tsx @@ -168,25 +168,25 @@ describe('long-content caps (ink MaxSizedBox parity)', () => { expect(maxHistoryItemRows(50)).toBe(200); }); - it('budgets a pending card below the confirmation dialog footprint', () => { + it('budgets a pending card below the inline confirmation footprint', () => { // At 80 rows the ink-parity cap is 320 — 4x past the viewport. The - // pending budget is bounded by the collapsed dialog footprint, and by - // the payload the dialog renders expanded: a hook-forced confirmation + // pending budget is bounded by the collapsed confirmation footprint, and + // by the payload it renders expanded: a hook-forced confirmation // duplicates the card's description in its body, so a wide payload - // shrinks the card or ctrl-s expansion pushes the dialog off screen + // shrinks the card or ctrl-s expansion pushes the options off screen // (mem0 e2e regression). expect(maxHistoryItemRows(80)).toBe(320); - // No payload: the collapsed-dialog bound (80 - 46) is the tight one. - expect(pendingCardMaxRows(80, 0, 110)).toBe(34); - expect(pendingCardMaxRows(100, 0, 110)).toBe(51); - // A ~3.9k-char payload wraps to ~37 dialog rows at 110 columns; the - // expanded-dialog bound leaves (80 - 26 - 37) * 0.7 = 11 card rows. - expect(pendingCardMaxRows(80, 3900, 110)).toBe(11); + // No payload: the collapsed-confirmation bound (80 - 43) is the tight one. + expect(pendingCardMaxRows(80, 0, 110)).toBe(37); + expect(pendingCardMaxRows(100, 0, 110)).toBe(57); + // A ~3.9k-char payload wraps to ~37 confirmation rows at 110 columns; the + // expanded bound leaves (80 - 18 - 37) * 0.7 = 17 card rows. + expect(pendingCardMaxRows(80, 3900, 110)).toBe(17); }); it('falls back to the settled cap on short terminals', () => { expect(pendingCardMaxRows(24, 3900, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); - expect(pendingCardMaxRows(46, 0, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); + expect(pendingCardMaxRows(48, 0, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); }); it('keeps everything when the content fits', () => { diff --git a/packages/cli/src/ui/opentui/messages.tsx b/packages/cli/src/ui/opentui/messages.tsx index 81ae5cb6b01..d7c37c025b2 100644 --- a/packages/cli/src/ui/opentui/messages.tsx +++ b/packages/cli/src/ui/opentui/messages.tsx @@ -222,26 +222,27 @@ export function tailWindowPhysical( export const TOOL_CARD_DESCRIPTION_ROWS = 5; /** - * Rows reserved below a pending tool card so the confirmation dialog's - * COLLAPSED body (frame + title/question + 20-row body + outcome list + - * footer ≈ 36 rows) still ends inside the viewport. Excludes any payload - * the dialog only reveals on ctrl-s expansion — that bound lives in - * pendingCardMaxRows. + * Rows reserved below a pending tool card so the inline confirmation's + * COLLAPSED body (padding + body margins + question + 20-row body + + * hidden-tail and ctrl-s labels + outcome list + waiting row ≈ 33 rows) + * still ends inside the viewport. Excludes any payload the confirmation only + * reveals on ctrl-s expansion — that bound lives in pendingCardMaxRows. */ -export const PENDING_CARD_VIEWPORT_RESERVE_ROWS = 46; +export const PENDING_CARD_VIEWPORT_RESERVE_ROWS = 43; /** - * Rows a pending card's expanded confirmation dialog does not own. Above the - * card's description rows: the banner (6), the startup notices a fresh - * session shows (≈ 3), the prompt echo with its turn margin (2), and the - * card's own hidden-tail and awaiting rows (2). In the dialog itself, around - * the body: the frame's border and padding (4), title (1), body margins (2), - * question row (1), outcome list (2), footer hint (1) ≈ 11. The sum (≈ 24, - * padded to 26 against notice timing) is what an 80-row viewport measured: - * at 14 the expanded tail and the outcome list ran off the bottom of the - * screen (mem0 e2e regression). + * Rows a pending card's expanded confirmation does not own. Above the card's + * description rows: the banner (6), the startup notices a fresh session shows + * (≈ 3), the prompt echo with its turn margin (2), and the card's own + * hidden-tail row (1) — the awaiting marker is inline on the card row. In the + * confirmation itself, around the body: the question row (1), the outcome list + * (2), and the waiting row the loading indicator takes (1). The sum (≈ 16) is + * padded to 18 against notice timing; the frame border, title and footer hint + * an earlier revision reserved are gone with the bordered dialog. 80 rows is + * the viewport that bounds this: the expanded tail plus the outcome list must + * end above the bottom edge (mem0 e2e regression). */ -export const DIALOG_EXPANDED_RESERVE_ROWS = 26; +export const DIALOG_EXPANDED_RESERVE_ROWS = 18; /** * Measured at a 110-column terminal the card's flex row gives the diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index 3f4ef055e94..446668a0e67 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -943,12 +943,19 @@ export function OpenTuiApp(props: OpenTuiAppProps) { onSelect={mcpApproval.handleMcpApprovalSelect} /> ) : activeToolCall ? ( - onToolCallSettled?.(activeToolCall.callId)} - /> + + onToolCallSettled?.(activeToolCall.callId)} + /> + {/* ink's Composer marginTop: one blank between the confirmation's + own bottom padding and the waiting row. */} + + + + ) : activeModal ? ( activeModal.kind === 'shell' ? ( - {isNarrow ? phraseText : `${phraseText} ${suffix}`} + {isNarrow || !suffix ? phraseText : `${phraseText} ${suffix}`} - {isNarrow && {suffix}} + {isNarrow && suffix ? {suffix} : null} ); } diff --git a/packages/cli/src/ui/opentui/transcript-view.test.tsx b/packages/cli/src/ui/opentui/transcript-view.test.tsx index 047cf9f676f..93acfba6900 100644 --- a/packages/cli/src/ui/opentui/transcript-view.test.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.test.tsx @@ -86,7 +86,7 @@ describe('OpenTuiTranscriptView', () => { />, ); expect(container.textContent).toContain('SECRET_PAYLOAD'); - expect(container.textContent).toContain('awaiting approval'); + expect(container.textContent).toContain('←'); }); it('keeps a long pending payload approvable and caps it once settled (R5-9)', () => { @@ -112,7 +112,7 @@ describe('OpenTuiTranscriptView', () => { />, ); expect(pending.container.textContent).toContain('TAIL_MARKER'); - expect(pending.container.textContent).toContain('awaiting approval'); + expect(pending.container.textContent).toContain('←'); pending.unmount(); const settled = render( @@ -128,6 +128,7 @@ describe('OpenTuiTranscriptView', () => { />, ); expect(settled.container.textContent).not.toContain('TAIL_MARKER'); + expect(settled.container.textContent).not.toContain('←'); expect(settled.container.textContent).toContain('... last'); }); @@ -154,17 +155,16 @@ describe('OpenTuiTranscriptView', () => { const text = container.textContent ?? ''; expect(text).toContain('... last'); expect(text).not.toContain('PAYLOAD_TAIL'); - expect(text).toContain('awaiting approval'); + expect(text).toContain('←'); }); it('yields pending rows a hook-confirmation dialog needs when expanded (mem0 e2e)', () => { - // The mem0 confirmation duplicates the card's description inside its - // dialog body: once ctrl-s expands it, the whole payload plus dialog - // chrome must fit the viewport, so a ~4k-char payload must shrink the - // card BELOW the collapsed-dialog bound (34 rows ≈ 3523 visible chars - // at 110 columns). A marker placed past the yielded budget pins the - // shrink — that bound alone would still show it and the e2e expansion - // stage would stay red. + // The mem0 confirmation duplicates the card's description inside its own + // body: once ctrl-s expands it, the whole payload plus the confirmation's + // chrome must fit the viewport, so a ~4k-char payload must shrink the card + // BELOW the collapsed bound (37 rows at h=80). A marker placed past the + // yielded budget pins the shrink — that bound alone would still show it + // and the e2e expansion stage would stay red. const description = '{"content":"' + 'a'.repeat(2500) + @@ -185,7 +185,7 @@ describe('OpenTuiTranscriptView', () => { />, ); const text = container.textContent ?? ''; - expect(text).toContain('awaiting approval'); + expect(text).toContain('←'); expect(text).toContain('... last'); expect(text).not.toContain('MID_MARKER'); }); diff --git a/packages/cli/src/ui/opentui/transcript-view.tsx b/packages/cli/src/ui/opentui/transcript-view.tsx index 27f1f935dbd..ed7d19a46a5 100644 --- a/packages/cli/src/ui/opentui/transcript-view.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.tsx @@ -312,13 +312,13 @@ function ToolCard({ // fallback path does (R6-2). const text = toolCardText(description); // The description stays visible while a call awaits approval: an MCP - // confirmation dialog shows only the server and tool names, so the card - // is the only surface carrying the arguments (R5-9) — the settled 5-row - // cap would hide the tail of exactly the payload being approved. The - // pending budget stays viewport- and payload-aware (pendingCardMaxRows): - // the dialog renders in flow below the transcript, and a hook-forced + // confirmation body shows only the server and tool names, so the card is + // the only surface carrying the arguments (R5-9) — the settled 5-row cap + // would hide the tail of exactly the payload being approved. The pending + // budget stays viewport- and payload-aware (pendingCardMaxRows): the + // confirmation renders in flow below the transcript, and a hook-forced // confirmation renders this same payload in its body, so the card must - // yield rows for it or ctrl-s expansion pushes the dialog off screen. + // yield rows for it or ctrl-s expansion pushes the options off screen. const cap = capToolCardDescription( text, name, @@ -348,13 +348,15 @@ function ToolCard({ ) : null} {suffix ? {sanitizeTerminalText(suffix)} : null} + {item.confirm === 'pending' && !item.done ? ( + // ink's TrailingIndicator: a primary-coloured arrow at the end of the + // awaiting call's own row, not a row of its own. + {' ←'} + ) : null} {cap.hiddenRows > 0 && ( {hiddenTailLinesLabel(cap.hiddenRows)} )} - {item.confirm === 'pending' && !item.done ? ( - (awaiting approval) - ) : null} ); From 85ec4251d28f24012db15d9b6b7d347b6cf92346 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Fri, 11 Sep 2026 17:37:51 +0800 Subject: [PATCH 02/51] fix(cli): let a plain-text tool update replace OpenTUI's structured payload A tool card prefers a structured payload over its flattened text and does so unconditionally, which is what lets a shell run paint a styled token grid, a diff render its coloured lines and a todo write render its status list. The history fold that feeds the card only ever added those payloads, though, and never cleared one, so the preference could outlive the payload it was chosen for. A shell command hits exactly that: it streams styled output while it runs and, if the stream then trips binary detection, the accumulated output is replaced by a plain string. The card kept the last grid it saw and showed it forever, including after the call settled, so the user was told nothing about the binary output and never saw the byte counts that followed. The same shape applies to any tool whose display changes kind mid-flight, since a result carries at most one structured payload. Each event carries the whole display, so the structured fields are now derived from the event rather than accumulated onto the card. The three producers of these events all pick the structured form or the flattened form and never emit both for one result, and the trailing flush that could have delivered a late update after completion is cancelled before the call returns, so nothing regains a payload that a later event legitimately dropped. The egress disclosure keeps its existing sticky behaviour: it is an additional notice under the result rather than an alternative rendering of it, and it takes no part in the card's preference. --- .../src/ui/opentui/live-session-model.test.ts | 42 +++++++++++++++++++ .../cli/src/ui/opentui/live-session-model.ts | 14 +++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/opentui/live-session-model.test.ts b/packages/cli/src/ui/opentui/live-session-model.test.ts index 6208c551f15..5544f928098 100644 --- a/packages/cli/src/ui/opentui/live-session-model.test.ts +++ b/packages/cli/src/ui/opentui/live-session-model.test.ts @@ -329,6 +329,48 @@ describe('foldLiveEvent tool-result ansi', () => { totalBytes: 4096, }); }); + + it('drops the grid when a later chunk carries plain text', () => { + // A shell run that streams ANSI and then trips binary detection replaces + // the grid with a plain string, and ToolCardBody prefers the grid + // unconditionally — a stale one would hide the notice for good, including + // in the settled card. + const grid = [ + [ + { + text: 'partial', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ], + ]; + let items = foldLiveEvent([], { + type: 'tool-start', + id: 'tool1', + tool: 'run_shell_command', + title: 'run_shell_command', + }); + items = foldLiveEvent(items, { + type: 'tool-result', + id: 'tool1', + display: '', + ansi: { grid }, + }); + items = foldLiveEvent(items, { + type: 'tool-output', + id: 'tool1', + output: '[Binary output detected. Halting stream...]', + }); + const tool = items[0]; + if (tool.kind !== 'tool') throw new Error('expected tool item'); + expect(tool.ansi).toBeUndefined(); + expect(tool.output).toBe('[Binary output detected. Halting stream...]'); + }); }); describe('foldLiveEvent compaction', () => { diff --git a/packages/cli/src/ui/opentui/live-session-model.ts b/packages/cli/src/ui/opentui/live-session-model.ts index f2633012c99..b59037512fe 100644 --- a/packages/cli/src/ui/opentui/live-session-model.ts +++ b/packages/cli/src/ui/opentui/live-session-model.ts @@ -319,15 +319,21 @@ export function foldLiveEvent( const i = findToolIndex(items, ev.id); if (i >= 0) { const t = items[i] as LiveToolItem; + const structured = ev.type === 'tool-result' ? ev : undefined; // Both events carry the whole display, so the card replaces rather than - // accumulates — appending would paint the streamed snapshot twice. + // accumulates — appending would paint the streamed snapshot twice. The + // structured payloads replace for the same reason, and clearing them + // matters: ToolCardBody prefers them over the text unconditionally, so + // a shell run that streams ANSI and then trips binary detection would + // otherwise freeze on the stale grid and never show the plain-text + // notice that replaced it. const next: LiveToolItem = { ...t, output: ev.type === 'tool-output' ? ev.output : ev.display, + diff: structured?.diff, + todos: structured?.todos, + ansi: structured?.ansi, }; - if (ev.type === 'tool-result' && ev.diff) next.diff = ev.diff; - if (ev.type === 'tool-result' && ev.todos) next.todos = ev.todos; - if (ev.type === 'tool-result' && ev.ansi) next.ansi = ev.ansi; if (ev.type === 'tool-result' && ev.visionBridgeNotice) { next.visionBridgeNotice = ev.visionBridgeNotice; } From cc230a0f4f67b82155e20ba3a7553e3addc0c28f Mon Sep 17 00:00:00 2001 From: chiga0 Date: Fri, 11 Sep 2026 19:36:39 +0800 Subject: [PATCH 03/51] fix(cli): port ink's question dialog to OpenTUI in full The ask_user_question confirmation had been reduced to its literal options: no free-text row, no description under any option, no digit keys, and a full-width box around a dialog that ink draws inline under its own transcript entry. A selection therefore cost a navigation and a confirmation instead of one keypress, and an answer the model had not happened to invent could not be given at all. The dialog is now ported whole from ink's own component: a chip row of question headers with a review-and-submit tab after them, numbered options carrying their descriptions, multi-select checkboxes, and a trailing free-text row that takes a bracketed paste of its own, since the composer that would otherwise consume one is unmounted while a confirmation owns the screen. The chip row's width is derived rather than guessed, by reusing the water-filling helper ink sizes it with and subtracting every cell the row spends outside the header text from the width a tool confirmation actually gets in the transcript. One divergence is deliberate. ink subscribes to Enter twice on the free-text row, once from the dialog's key handler and once from the input widget it mounts, and its key layer has no focus stack to arbitrate between them, so a typed answer advances two tabs and silently skips the question after it. That was observed on a real run rather than inferred: the acceptance leg timed out waiting for a question that was never drawn, and the review tab reported it as unanswered. Every key here goes through one handler, so Enter advances exactly one tab. Fixing it in ink would move the baseline this sweep measures itself against, so the defect and the divergence are both recorded in the design doc. Typing also builds each next value from a mirror written synchronously rather than from the value the registering render captured, because keystrokes out of one pty read can share a React batch and only the last of them would otherwise survive. Removing the mirror fails exactly the test that pins it, with a five-character answer rendering as a single character. The acceptance harness needed a fix of its own before this could be measured. It waited on the byte stream, and a renderer that repaints only the cells that changed never puts a line edited in place into that stream as one contiguous string, so a leg timed out on a screen that was already correct. Waits now poll the reconstruction as well, less often than the stream because reading it flushes the pty and walks the whole buffer. Verified on a real machine against ink over eight checkpoints of a three-question dialog with a multi-select and a twenty-character typed answer, plus the two checkpoints of the single-question scenario. Every dialog row matches on text and on indentation, including the mark appearing on the header chip as soon as its free-text box is checked and the review tab dropping the cancel clause from its hint. The rows that still differ on those frames are the long context-file path wrapping at a different column, the spinner's braille phase, and this renderer's deliberately reworded update notice. Across the whole matrix the single-question pair drops from twelve and eighteen differing rows to four and five, and no other pair moves at all. Not verified: colour, since the reconstruction is text; the vertical position of the block, which bottom anchoring moves by construction; and whether the free-text cursor's background-filled cell survives layout, which a text frame cannot show. --- .../2026-09-10-opentui-parity-defect-sweep.md | 96 +++- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 67 ++- .../src/ui/opentui/dialogs-confirm.test.tsx | 265 ++++++++- .../cli/src/ui/opentui/dialogs-confirm.tsx | 526 ++++++++++++++---- 4 files changed, 820 insertions(+), 134 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 414321e3936..0b144e44748 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,13 +57,16 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Fourteen scenarios cover boot, a +rather than at the same wall-clock moment. Nineteen scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear -and exit, and a long hold that cycles the loading phrases. +and exit, a long hold that cycles the loading phrases, a to-do card, the +question dialog on its own, the same dialog across three questions with a +multi-select and a typed answer, the release of a parked confirmation when the +approval mode changes, and the approval of a gated server at startup. -Two properties of the comparison matter for reading the results. +Three properties of the comparison matter for reading the results. **Vertical anchoring is not comparable.** This renderer anchors the composer to the bottom of the terminal; ink places it under the conversation. Every @@ -81,6 +84,18 @@ reproduced the missing shell-crawler diagnostics on ink, which moved that finding out of the renderer's column; it did not reproduce the missing extension-refresh notice, which stayed in. +**The channel a scenario waits on is not the channel it compares on.** A +checkpoint is reached by waiting for text the script expects to see, and that +wait scanned the byte stream. A renderer that repaints whole lines satisfies +it, because the line arrives contiguously; one that repaints only the cells +that changed never does. A dialog question edited in place from one wording to +another reaches the stream as the handful of cells that differ, so the wait +times out on a screen that is already correct — the leg that failed this way +had drawn the row it was waiting for, and the stream carried a five-cell +fragment of it. Waits now poll the reconstruction as well as the stream, and +the two channels agree. Reading the stream first is not redundant: it is cheap, +and it still sees text that has scrolled out of the reconstructed window. + ## Decision 1 — the confirmation dialog asks ink's per-type question The dialog now builds its option list from the confirmation's own type rather @@ -508,12 +523,67 @@ line in the body carries an extra leading space here, which is the break-rule difference already recorded for the context-file list; matching it would mean reimplementing the wrap the renderer already provides. +## Decision 21 — the question dialog is ported whole, except where ink is wrong + +The dialog that puts a question to the user had been reduced to its literal +options. It drew no free-text row, so an answer had to be one of the choices +the model happened to invent; no description under each choice, so a label had +to carry the whole meaning; and no digit keys, so a choice cost a navigation +and a confirmation instead of one keystroke. Its header and its hint were also +worded differently. The answer itself travelled correctly, which is why a +state-level suite never caught any of it: once a choice was made, both +renderers printed the same settled card word for word. + +Everything ink draws is now drawn here, and the geometry was derived rather +than guessed. The chip row that names each question and marks the answered +ones is capped by a water-filling helper imported from ink's own dialog rather +than reimplemented, fed the width a confirmation actually gets — the terminal +minus the four columns the transcript item spends, minus the two the +confirmation spends inside it — and then reduced by every cell the row spends +outside the header text: the padding, the active-tab marker, the submit chip, +one gap per chip, two columns of prefix per header, and two more for each +answered mark. It is recomputed on every render, because answering changes the +marks. + +One divergence is deliberate. ink answers a typed free-text entry twice for one +keystroke — the dialog's own key handler and the input widget it mounts both +subscribe, and the key layer broadcasts to every subscriber with no notion of +focus — and each answer advances a tab, so the question after the one being +answered is skipped without ever being drawn. This was not reasoned from source +alone. The ink leg of the acceptance run timed out waiting for a question that +never appeared, and its own review tab then listed that question as +unanswered. Here one keystroke advances one tab. Matching ink would reproduce a +defect rather than the experience, and fixing ink would move the reference the +whole sweep is measured against, so the divergence is recorded instead. The +scenario was rearranged to exercise the free-text row on the last question, +where the tab index clamps and both legs arrive at the review tab together. + +The free-text row keeps its value in a mirror written synchronously beside the +state update. Key events can arrive inside one React batch, and a handler that +builds the next value from the one its render captured appends to a snapshot +that is already stale, so a burst keeps only its last character. ink keeps the +same mirror for the same reason. A test dispatches a burst inside a single +batch, and the fix was checked by taking the mirror back out: exactly that one +test fails, and the row shows a single character of the five typed. On a real +machine the hazard did not fire — a twenty-character answer survived intact in +both legs — so the mirror is correctness by construction rather than the fix +for an observed truncation. + +Eight checkpoints were compared. Every dialog row matches ink line for line and +indent for indent, including the mark appearing on a chip as soon as its +free-text box is checked and before anything is committed, the comma-and-space +join of a multi-select answer with the typed entry counted into it, and the +review tab's own hint, which drops the cancel clause exactly where ink drops +it. The residuals are the three recorded elsewhere — where a long path wraps, +which phase the loading spinner happens to be in, and the update notice this +renderer words differently — plus vertical anchoring. + ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for fourteen scenarios at 100×40 and, for the narrow + reconstructed screen, for nineteen scenarios at 100×40 and, for the narrow and resize scenarios, at 60×24. Both legs from one bundle and one set of boot arguments. - **Colour was not verified.** The reconstruction is text. Several rows are @@ -681,14 +751,16 @@ What was verified, and how far the verification reaches: - The composer advertises a queue key it does not bind. Its exit key also arms the two-press window with a non-empty draft and eats a character doing it, where ink declines to arm at all while the buffer holds text. -- The question tool offers only its literal options; ink adds a free-text row so - an answer can be typed instead of picked, and the port records the omission in - a comment rather than closing it. -- The question dialog now differs in frames, not only in source. It drops the - free-text row, drops each option's description, drops the number keys ink - prints beside them, and words its header and its hint differently. The answer - itself travels correctly: once a choice is made, both renderers print the same - settled card, word for word. +- ink answers a typed free-text entry in its question dialog twice for one + keystroke, because the dialog's key handler and the input widget it mounts + both subscribe and the key layer has no focus stack to arbitrate between + them. Each answer advances a tab, so on a multi-question dialog the question + after the one being answered is skipped without ever being drawn, and the + review tab reports it as unanswered. This was observed on a real run, not + inferred. Decision 21 does not follow it, and fixing it in ink is out of + scope for a sweep that measures itself against ink's behaviour as it stands; + it is recorded here so the divergence between the two renderers is not + re-reported as a porting gap. - The tool card does not print the call's arguments inline, and omits the trailing indicator ink puts beside a row that is still pending. The first is not cosmetic — it is why a disabled-tool error reads as an empty card here and diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 5672343ff7c..42151d31a02 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,11 +41,13 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。十四个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。十九个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 -路径、resize、clear 与退出,以及一次会遍历 loading 短语的长按。 +路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 +问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、审批模式变化时被 +拦下的确认框的释放,以及启动时对受门禁服务器的审批。 -比较方式有两个性质决定了结果该怎么读。 +比较方式有三个性质决定了结果该怎么读。 **垂直锚定不可比。** 本渲染器把 composer 锚在终端底部,ink 把它放在会话之下。 因此每个绝对行号在构造上就不同,一个按行号对齐的 diff 会在每个检查点都报告锚 @@ -58,6 +60,15 @@ diff —— 这对块落在哪里不敏感,对块里有什么敏感。 诊断,于是那条发现被移出了渲染器一栏;它没有复现缺失的扩展刷新提示,于是那条 留在渲染器一栏。 +**场景等待所用的通道不是它比较所用的通道。** 到达一个检查点的办法是等待脚本 +预期看到的文本,而这个等待扫的是字节流。整行重绘的渲染器能满足它,因为整行是 +连续到达的;只重绘变化单元格的渲染器永远不能满足。一个就地从一种措辞改成另一 +种的对话框问题,到达字节流时只剩互相差异的那几个单元格,于是等待会在一块其实 +已经正确的屏幕上超时 —— 以这种方式失败的那一腿确实画出了它在等的那一行,而字 +节流里只携带了它的五个单元格片段。现在等待除了扫字节流也会轮询重建结果,两个 +通道一致了。先读字节流并不是冗余:它便宜,而且仍然能看到已经滚出重建窗口的文 +本。 + ## 决定 1 —— 确认框按类型问 ink 的那个问题 对话框现在根据确认自身的类型构造选项列表,而不是一律用同一个形状。执行类确认 @@ -378,11 +389,48 @@ footer 上。 为上下文文件列表记录过的断行规则差异是同一类;要匹配它就意味着重新实现渲染器已经提供 的折行。 +## 决定 21 —— 提问对话框整体移植,只在 ink 自己错的地方不跟随 + +向用户提问的那个对话框此前被削减到只剩它的字面选项。它不画自由文本行,于是一个答案只 +能是模型恰好发明出来的某个选项;不在每个选项下画描述,于是一个标签必须承载全部含义; +也不画数字键,于是一次选择要花一次导航加一次确认,而不是一次按键。它的标题与提示措辞 +也和 ink 不同。答案本身的传递是正确的,这正是状态层测试套件从来抓不到其中任何一条的 +原因:一旦做出选择,两个渲染器就逐字打印同一张已结算的卡片。 + +ink 会画的东西现在这里都画了,而几何是推导出来的,不是猜的。那条命名每个问题并标出已 +答问题的 chip 行,其上限由一个从 ink 自己的对话框引用过来、而非重新实现的水位填充助手 +给出;喂给它的宽度是一个确认框真正拿到的宽度 —— 终端宽度减去 transcript 条目花掉的 +四列,再减去确认框在其内部花掉的两列 —— 然后再扣掉这一行在标题文字之外花掉的每一个 +单元格:内边距、当前 tab 标记、submit chip、每个 chip 之间一道间隙、每个标题两列前 +缀,以及每个已答标记再多两列。它在每次渲染时重算,因为作答会改变那些标记。 + +有一处分叉是有意的。ink 对一次按键把手打的自由文本答案提交两遍 —— 对话框自己的按键处 +理器与它挂载的输入控件都订阅了,而按键层向每个订阅者广播、没有焦点栈来仲裁 —— 每次提 +交都推进一个 tab,于是紧随其后那个问题在从未被画出来的情况下被跳过。这不是仅凭源码推断 +出来的:验收运行的 ink 腿在等一个始终没有出现的问题时超时,而它自己的复核 tab 随后把那 +个问题列为未作答。这里一次按键推进一个 tab。跟随 ink 会复现一个缺陷而不是复现那种体 +验,而修 ink 会移动整轮清扫用来度量的参照物,所以这处分叉被记录下来。场景被重排为在最 +后一个问题上行使自由文本行,那里 tab 下标会被钳住,两腿一起到达复核 tab。 + +自由文本行把它的值保存在一个与状态更新并排、同步写入的镜像里。按键事件可能在一个 +React 批次内到达,而一个用其渲染所捕获的值去构造下一个值的处理器,是在往一个已经过期 +的快照上追加,于是一串连击只保留最后一个字符。ink 出于同样的原因保留同样的镜像。有一 +个测试在单个批次内派发一串连击,而这个修法是通过把镜像取出来验证的:恰好只有那一个测 +试失败,且那一行只显示五个已打字符中的一个。在真机上这个隐患没有触发 —— 一个二十字符 +的答案在两腿上都完整保留 —— 所以这个镜像是构造上的正确性,而不是对某个已观察到的截断 +的修复。 + +比较了八个检查点。每一行对话框内容都与 ink 逐行逐缩进一致,包括标记在某个 chip 的自由 +文本框被勾上时就出现、早于任何东西被提交,包括多选答案那个「逗号加空格」的连接并把手 +打条目计入其中,也包括复核 tab 自己的那条提示 —— 它在 ink 丢掉取消子句的地方同样丢 +掉。残余是别处已记录过的那三条 —— 长路径在哪里折行、loading spinner 恰好停在哪个相 +位、以及本渲染器措辞不同的更新提示 —— 再加上垂直锚定。 + ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖十四个场景, +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖十九个场景, 100×40;窄终端与 resize 场景另加 60×24。两腿出自同一个 bundle 与同一组启动 参数。 - **颜色未验证。** 重建结果是文本。已知有若干行只在所用主题 token 上不同,而 @@ -498,11 +546,12 @@ footer 上。 刷新间隔与颜色选择;本渲染器则硬编码一行目录、会话、分支与模型。 - composer 宣传了一个它并未绑定的排队键。它的退出键还会在草稿非空时武装那个按两次 的窗口,并在这个过程中吃掉一个字符;而 ink 在缓冲区还有文本时根本不会武装。 -- 提问工具只提供它那些字面选项;ink 会额外加一行自由文本,让答案可以被输入而不是 - 被选中,而移植版是把这个缺失记在一条注释里,而不是把它补上。 -- 提问对话框如今是在帧里、而不只是在源码里显出差异。它丢掉了那行自由文本,丢掉了 - 每个选项的描述,丢掉了 ink 印在选项旁边的数字键,并且它的标题与提示措辞也不同。 - 答案本身传递正确:一旦做出选择,两个渲染器打印出的已结算卡片逐字相同。 +- ink 的提问对话框会对一次击键把手打的自由文本答案答两遍:对话框自己的按键处理 + 与它挂载的输入控件都订阅了按键,而按键层没有焦点栈在两者之间仲裁。每次回答都 + 推进一个标签页,所以多问题对话框上,正被回答那题之后的那题会被跳过、根本没被 + 画出来,而复核标签页把它报告为未回答。这是在真机上观察到的,不是推出来的。 + 决定 21 不跟随它,而在以 ink 现状行为度量自己的清扫里去修 ink 超出了范围; + 记在这里,免得两个渲染器间的这处分歧被再次当成移植缺口上报。 - 工具卡片不会把调用的参数行内打印出来,也省略了 ink 放在一条仍在等待的行末尾的指 示符。第一条不只是外观问题 —— 正因如此,一个「工具被禁用」的错误在这里读起来是 一张空卡片,而在那里是一张带着完整参数 JSON 的卡片。一张已结算的卡片还会停在它 diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 95971b9d6f1..438d832d875 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -13,7 +13,7 @@ * can never settle twice. */ -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { act, render } from '@testing-library/react'; // theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI @@ -21,11 +21,13 @@ import { act, render } from '@testing-library/react'; vi.mock('@opentui/core', () => ({ SyntaxStyle: { fromStyles: () => ({}) }, MouseButton: { LEFT: 0 }, + decodePasteBytes: (bytes: Uint8Array) => Buffer.from(bytes).toString('utf8'), })); const mocks = vi.hoisted(() => { const state = { keyboardHandlers: [] as Array<(key: unknown) => void>, + pasteHandlers: [] as Array<(event: unknown) => void>, dimensions: { width: 110, height: 40 }, }; // The components carry the @opentui/react JSX import source; map its @@ -57,12 +59,34 @@ const mocks = vi.hoisted(() => { return { state, buildJsxRuntime }; }); -vi.mock('@opentui/react', () => ({ - useKeyboard: (handler: (key: unknown) => void) => { - mocks.state.keyboardHandlers.push(handler); - }, - useTerminalDimensions: () => mocks.state.dimensions, -})); +vi.mock('@opentui/react', async () => { + const React = await import('react'); + // Mount-stable registration: the wrapper is registered once per consumer and + // always invokes the latest handler closure — the real renderer's semantics — + // so a re-render cannot multiply the deliveries of a single key event. + const useStableHandler = ( + handlers: Array<(event: unknown) => void>, + handler: (event: unknown) => void, + ) => { + const ref = React.useRef(handler); + ref.current = handler; + React.useEffect(() => { + const fn = (event: unknown) => ref.current(event); + handlers.push(fn); + return () => { + const index = handlers.indexOf(fn); + if (index >= 0) handlers.splice(index, 1); + }; + }, [handlers]); + }; + return { + useKeyboard: (handler: (key: unknown) => void) => + useStableHandler(mocks.state.keyboardHandlers, handler), + usePaste: (handler: (event: unknown) => void) => + useStableHandler(mocks.state.pasteHandlers, handler), + useTerminalDimensions: () => mocks.state.dimensions, + }; +}); vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); @@ -269,6 +293,7 @@ describe('OpenTuiToolConfirmation', () => { beforeEach(() => { mocks.state.keyboardHandlers = []; + mocks.state.pasteHandlers = []; mocks.state.dimensions = { width: 110, height: 40 }; }); @@ -363,6 +388,232 @@ describe('OpenTuiToolConfirmation', () => { ); }); + describe('ask_user_question flow', () => { + const twoOptions = [ + { label: 'A', description: 'the first option' }, + { label: 'B', description: 'the second option' }, + ]; + + const multiAskDetails = ( + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, + ): ToolCallConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Three questions', + questions: [ + { + question: 'Pick a deploy target?', + header: 'Deploy', + options: [ + { label: 'staging', description: 'the staging target' }, + { label: 'prod', description: 'the production target' }, + ], + }, + { + question: 'Pick a region?', + header: 'Region', + options: [ + { label: 'eu', description: 'the eu region' }, + { label: 'us', description: 'the us region' }, + ], + }, + { + question: 'Pick channels?', + header: 'Notify', + multiSelect: true, + options: [ + { label: 'mail', description: 'the mail channel' }, + { label: 'chat', description: 'the chat channel' }, + ], + }, + ], + onConfirm, + }); + + function mount(details: ToolCallConfirmationDetails): HTMLElement { + return render( + {}} + />, + ).container; + } + + function typeChars(text: string) { + for (const char of text) press({ name: char, sequence: char }); + } + + /** Every character in one batch, as a burst of keypresses arrives. */ + function typeBatched(text: string) { + act(() => { + for (const char of text) { + for (const handler of mocks.state.keyboardHandlers) { + handler({ name: char, sequence: char }); + } + } + }); + } + + function paste(text: string) { + act(() => { + for (const handler of mocks.state.pasteHandlers) { + handler({ + bytes: new TextEncoder().encode(text), + preventDefault: () => {}, + }); + } + }); + } + + /** ink pauses before swapping tabs so the ✓ on the answered row shows. */ + function settleAdvance() { + act(() => { + vi.advanceTimersByTime(200); + }); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('numbers each option and renders its description and the free-text row', () => { + const text = mount(multiAskDetails(async () => {})).textContent ?? ''; + expect(text).toContain('1. staging'); + expect(text).toContain('the staging target'); + expect(text).toContain('2. prod'); + expect(text).toContain('3. Type something...'); + }); + + it('commits a predefined option straight from its digit', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + press({ name: '2', sequence: '2' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + + it("only moves the cursor onto the free-text row for that row's own digit", () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + expect(onConfirm).not.toHaveBeenCalled(); + typeChars('own answer'); + expect(container.textContent ?? '').toContain('> own answer'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'own answer' } }, + ); + }); + + it('deletes the last typed character on backspace', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('abc'); + press({ name: 'backspace' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'ab' } }, + ); + }); + + it('appends a bracketed paste to the free-text row', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('a pasted answer'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'a pasted answer' } }, + ); + }); + + it('keeps every character of a burst that shares one batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeBatched('burst'); + expect(container.textContent ?? '').toContain('> burst'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'burst' } }, + ); + }); + + it('advances exactly one tab when a typed answer is committed', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + expect(container.textContent ?? '').toContain('Pick a region?'); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('typed'); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + // ink mounts a TextInput whose own Enter subscriber fires alongside the + // dialog's, so one keystroke skips this question entirely. + expect(container.textContent ?? '').toContain('Pick channels?'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('joins the checked options and counts the typed entry on a multi-select', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + expect(container.textContent ?? '').toContain('Pick channels?'); + press({ name: 'space', sequence: ' ' }); + press({ name: 'down' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'down' }); + typeChars('sms'); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: mail, chat, sms'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'mail, chat, sms' } }, + ); + }); + + it('reviews every answer on the Submit tab and cancels from its second row', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + press({ name: 'right' }); + press({ name: 'right' }); + const text = container.textContent ?? ''; + expect(text).toContain('Your answers:'); + expect(text).toContain('Deploy: staging'); + expect(text).toContain('Region: (not answered)'); + press({ name: 'down' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.Cancel, + undefined, + ); + }); + }); + it('renders the ink question line and labeled body for MCP confirmations', () => { const { container } = render( undefined`, - * and ask_user_question has no free-text "Other" option yet. + * and ask_user_question's free-text row advances one tab where ink skips the + * question after it — ink's TextInput subscribes to Enter a second time, so + * matching it would reproduce a defect rather than the experience. */ import { @@ -49,9 +51,10 @@ import type { } from '@qwen-code/qwen-code-core/tools/tools.js'; import { buildHumanReadableRuleLabel } from '@qwen-code/qwen-code-core/permissions/rule-parser.js'; import type { Config } from '@qwen-code/qwen-code-core/config/config.js'; -import { useKeyboard, useTerminalDimensions } from '@opentui/react'; +import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react'; +import { decodePasteBytes, type PasteEvent } from '@opentui/core'; import { C } from './theme.js'; -import { toOriginalKey } from './key-map.js'; +import { Command, matchesCommand, toOriginalKey } from './key-map.js'; import { DialogFrame, DialogSelect, @@ -68,9 +71,16 @@ import { tailWindow, tailWindowPhysical, } from './messages.js'; -import { sanitizeTerminalText } from '../utils/textUtils.js'; +import { + getCachedStringWidth, + sanitizeTerminalText, + truncateToWidth, +} from '../utils/textUtils.js'; +import { isPrintableKeyInput } from './input-prompt-key.js'; +import { normalizePastedText } from './input-prompt-model.js'; import type { ShellConfirmationResolution } from './commands-context.js'; import { McpApprovalChoice } from '../components/mcp/MCPServerApprovalDialog.js'; +import { computeHeaderCap } from '../components/messages/AskUserQuestionDialog.js'; import type { PendingMcpServer } from '../hooks/useMcpApproval.js'; import { t } from '../../i18n/index.js'; @@ -559,24 +569,22 @@ export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { }); if (details.type === 'ask_user_question') { + // ink's ToolConfirmationMessage early-returns this dialog, so neither its + // border nor the payload title is rendered — the question's own header is + // the title row. return ( - - - - {sanitizeTerminalText(details.title)} - - { - if (answers === null) { - settle(ToolConfirmationOutcome.Cancel); - } else { - settle(ToolConfirmationOutcome.ProceedOnce, { answers }); - } - }} - /> - - + + { + if (answers === null) { + settle(ToolConfirmationOutcome.Cancel); + } else { + settle(ToolConfirmationOutcome.ProceedOnce, { answers }); + } + }} + /> + ); } @@ -603,79 +611,247 @@ export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { } /** - * Sequential ask_user_question flow: walks the questions one at a time, - * collects single- or multi-select answers, and hands back an ink-parity - * answers record keyed by question index — or null when the user escapes. + * ask_user_question parity port of ink's AskUserQuestionDialog: one tab per + * question plus a review-and-Submit tab, numbered options carrying their + * descriptions, multi-select checkboxes, and a trailing free-text row. + * + * One deliberate divergence. ink mounts a TextInput on the free-text row, and + * its own Enter subscriber fires alongside the dialog's — both call + * `selectAndAdvance`, so a typed answer on a multi-question dialog skips the + * question after it. Every key here runs through the single handler below, so + * Enter advances exactly one tab. */ function AskUserQuestionFlow(props: { details: Extract; onAnswered: (answers: Record | null) => void; }) { const { details, onAnswered } = props; - const [index, setIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - const [selected, setSelected] = useState>(new Set()); - - const question = details.questions[index]; - const isMulti = question?.multiSelect === true; - - const commitQuestion = useCallback( - (value: string | undefined) => { - if (value === undefined) return; - const nextAnswers = { ...answers, [index]: value }; - setAnswers(nextAnswers); - setSelected(new Set()); - if (index + 1 < details.questions.length) { - setIndex(index + 1); - } else { - const out: Record = {}; - for (const [key, val] of Object.entries(nextAnswers)) { - out[String(key)] = val; - } - onAnswered(out); - } + const questions = details.questions; + const hasMultipleQuestions = questions.length > 1; + // Only a multi-question dialog gets the review tab; a single question + // commits straight from its own. + const totalTabs = hasMultipleQuestions + ? questions.length + 1 + : questions.length; + + const [tab, setTab] = useState(0); + const [selected, setSelected] = useState(0); + const [picked, setPicked] = useState>({}); + const [checked, setChecked] = useState>({}); + const [typed, setTyped] = useState>({}); + const [typedChecked, setTypedChecked] = useState>({}); + // Key events can land in one React batch, where the value captured by the + // render that registered the handler is already stale by the second + // keystroke. The mirror is written synchronously so each event appends to + // what the previous one produced. + const typedRef = useRef>({}); + const advanceTimer = useRef | null>(null); + const { width } = useTerminalDimensions(); + + const isSubmitTab = hasMultipleQuestions && tab === totalTabs - 1; + const question = isSubmitTab ? undefined : questions[tab]; + const isMultiSelect = question?.multiSelect === true; + // The free-text row sits after the predefined options. + const totalOptions = question ? question.options.length + 1 : 2; + const isCustomRow = + question !== undefined && selected === question.options.length; + const typedValue = (idx: number) => typedRef.current[idx] ?? typed[idx] ?? ''; + const customValue = typedValue(tab); + const isCustomAnswer = + question !== undefined && + !isMultiSelect && + picked[tab] !== undefined && + !question.options.some((option) => option.label === picked[tab]); + + const answerFor = (idx: number): string | undefined => { + const current = questions[idx]; + if (!current?.multiSelect) return picked[idx]; + const labels = [...(checked[idx] ?? [])]; + const own = typedValue(idx).trim(); + if (typedChecked[idx] && own) labels.push(own); + return labels.length > 0 ? labels.join(', ') : undefined; + }; + + const selectAndAdvance = (value: string) => { + setPicked((prev) => ({ ...prev, [tab]: value })); + if (!hasMultipleQuestions) { + onAnswered({ [tab]: value }); + return; + } + if (tab >= totalTabs - 1) return; + // ink's pause, so the ✓ on the row just answered is visible before the tab + // swap carries it up into the chip row. + advanceTimer.current = setTimeout(() => { + setTab((prev) => Math.min(prev + 1, totalTabs - 1)); + setSelected(0); + }, 150); + }; + + const submitAll = () => { + const answers: Record = {}; + questions.forEach((_, idx) => { + const answer = answerFor(idx); + if (answer !== undefined) answers[idx] = answer; + }); + onAnswered(answers); + }; + + const multiAnswer = (includeTyped: boolean, value: string) => { + const labels = [...(checked[tab] ?? [])]; + const own = value.trim(); + if (includeTyped && own) labels.push(own); + return labels.length > 0 ? labels.join(', ') : undefined; + }; + + const setCustomValue = (update: (previous: string) => string) => { + const next = update(typedValue(tab)); + typedRef.current = { ...typedRef.current, [tab]: next }; + setTyped((prev) => ({ ...prev, [tab]: next })); + // A multi-select box tracks whether its free-text entry counts, so typing + // into it checks the box and emptying it unchecks it again. + if (isMultiSelect) { + setTypedChecked((prev) => ({ ...prev, [tab]: next.trim().length > 0 })); + } + }; + + const submitCustomRow = () => { + // Re-read rather than use the rendered value: the keystroke that fills the + // row and this Enter can share one batch. + const current = typedValue(tab); + const value = current.trim(); + if (isMultiSelect) { + setTypedChecked((prev) => ({ ...prev, [tab]: value.length > 0 })); + } + if (!value) return; + const answer = isMultiSelect ? multiAnswer(true, current) : value; + if (answer !== undefined) selectAndAdvance(answer); + }; + + useEffect( + () => () => { + if (advanceTimer.current) clearTimeout(advanceTimer.current); }, - [answers, index, details.questions.length, onAnswered], + [], ); - const items = useMemo>>( - () => - (question?.options ?? []).map((option, i) => ({ - key: `${option.label}-${i}`, - value: option.label, - })), - [question], + // ink sizes the chip row against the width a tool confirmation gets in the + // transcript — the terminal minus 4 for the history item, minus 2 more for + // the confirmation's own padding — and reserves every cell the row spends + // outside the header text. Recomputed per render so answering re-fits it. + const answeredHeaders = questions.filter( + (_, idx) => answerFor(idx) !== undefined, + ).length; + const rowOverhead = + 2 + // the dialog's own padding + (isSubmitTab ? 2 : 1) + // "▸ " when Submit is the active tab + getCachedStringWidth(t('Submit')) + + questions.length + // gap={1} between each chip and the Submit chip + 2 * questions.length + // "▸ " or " " before each header + 2 * answeredHeaders; // " ✓" after each answered header + const headerCap = computeHeaderCap( + questions.map((q) => getCachedStringWidth(q.header)), + width - 6 - rowOverhead, ); - const select = useDialogSelect>({ - items, - numbers: false, - // For single-select we commit directly on Enter; for multi-select Enter is - // handled by the keyboard hook below (it submits the accumulated set), so - // onSelect must stay unset in that mode to avoid a double commit. - onSelect: isMulti ? undefined : (value) => commitQuestion(value), - resyncKey: index, - }); - useKeyboard((key) => { - // Escape is owned by OpenTuiToolConfirmation (it settles the whole call). - if (!isMulti) return; const original = toOriginalKey(key); - const current = items[select.activeIndex]; - if (!current) return; - if (original.name === 'space' || original.sequence === ' ') { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(current.value)) next.delete(current.value); - else next.add(current.value); - return next; - }); + + if (isCustomRow) { + // Bare letters belong to the input and ←/→ must not switch tabs while it + // owns the cursor, so only unambiguous shortcuts are honoured here. + if (original.name === 'up' || (original.ctrl && original.name === 'p')) { + setSelected(Math.max(0, selected - 1)); + } else if ( + original.name === 'down' || + (original.ctrl && original.name === 'n') + ) { + setSelected(Math.min(totalOptions - 1, selected + 1)); + } else if (original.name === 'return') { + submitCustomRow(); + } else if (original.name === 'backspace') { + setCustomValue((previous) => previous.slice(0, -1)); + } else if (isPrintableKeyInput(key)) { + setCustomValue((previous) => previous + key.sequence); + } return; } + + if (hasMultipleQuestions && original.name === 'left' && tab > 0) { + setTab(tab - 1); + setSelected(0); + return; + } + if ( + hasMultipleQuestions && + original.name === 'right' && + tab < totalTabs - 1 + ) { + setTab(tab + 1); + setSelected(0); + return; + } + if (matchesCommand(Command.SELECTION_UP, key)) { + setSelected(Math.max(0, selected - 1)); + return; + } + if (matchesCommand(Command.SELECTION_DOWN, key)) { + setSelected(Math.min(totalOptions - 1, selected + 1)); + return; + } + + const numKey = /^[1-9]\d*$/.test(original.sequence) + ? Number(original.sequence) + : NaN; + if (Number.isSafeInteger(numKey) && numKey <= totalOptions) { + const target = numKey - 1; + setSelected(target); + // Single-select commits a predefined option straight from its digit; the + // free-text row's digit only moves the cursor onto it. + const option = !isMultiSelect ? question?.options[target] : undefined; + if (option) selectAndAdvance(option.label); + return; + } + + if (original.name === 'space' && isMultiSelect && question) { + const option = question.options[selected]; + if (option) { + const current = checked[tab] ?? []; + setChecked((prev) => ({ + ...prev, + [tab]: current.includes(option.label) + ? current.filter((label) => label !== option.label) + : [...current, option.label], + })); + } + return; + } + if (original.name === 'return') { - if (selected.size === 0) return; - commitQuestion([...selected].join(', ')); + if (isSubmitTab) { + if (selected === 0) submitAll(); + else onAnswered(null); + return; + } + if (isMultiSelect) { + const answer = multiAnswer(typedChecked[tab] === true, customValue); + if (answer !== undefined) selectAndAdvance(answer); + return; + } + const option = question?.options[selected]; + if (option) selectAndAdvance(option.label); } + // Escape is owned by OpenTuiToolConfirmation (it settles the whole call). + }); + + // Bracketed pastes arrive as one event with no keypress per character, and + // the composer that would otherwise consume them is unmounted while a + // confirmation owns the screen. + usePaste((event: PasteEvent) => { + if (!isCustomRow) return; + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (!text) return; + event.preventDefault(); + setCustomValue((previous) => previous + text); }); // Defensive: an empty question list, or a question with no options, has @@ -683,47 +859,185 @@ function AskUserQuestionFlow(props: { // render would update the parent mid-render) so the waiting call never // hangs. useEffect(() => { - if (details.questions.length === 0 || !question?.options?.length) { + if (questions.length === 0 || question?.options.length === 0) { onAnswered(null); } - }, [details.questions.length, question, onAnswered]); - - if (!question) return null; + }, [questions.length, question, onAnswered]); - return ( - - - {sanitizeTerminalText(question.header)} ({index + 1}/ - {details.questions.length}) + const chipRow = ( + + {questions.map((q, idx) => { + const active = !isSubmitTab && idx === tab; + return ( + + {(active ? '▸ ' : ' ') + + truncateToWidth(sanitizeTerminalText(q.header), headerCap) + + (answerFor(idx) !== undefined ? ' ✓' : '')} + + ); + })} + + {(isSubmitTab ? '▸ ' : ' ') + t('Submit')} - {sanitizeTerminalText(question.question)} - - { - const checked = isMulti && selected.has(item.value); - const marker = isMulti ? (checked ? '[x] ' : '[ ] ') : ''; + + ); + + if (isSubmitTab) { + return ( + <> + {hasMultipleQuestions ? chipRow : null} + + + {t('Your answers:')} + + {questions.map((q, idx) => { + const answer = answerFor(idx); return ( - - {marker + item.value} - + + {sanitizeTerminalText(q.header) + ': '} + {answer ? ( + {sanitizeTerminalText(answer)} + ) : ( + {t('(not answered)')} + )} + ); - }} + })} + + + {t('Ready to submit your answers?')} + + + + {(selected === 0 ? '❯ ' : ' ') + `1. ${t('Submit answers')}`} + + + {(selected === 1 ? '❯ ' : ' ') + `2. ${t('Cancel')}`} + + + + + ); + } + + if (!question) return null; + + const customMark = isMultiSelect ? (typedChecked[tab] ? '[✓] ' : '[ ] ') : ''; + const customEmphasis = isCustomAnswer || typedChecked[tab] === true; + const customLabel = `${question.options.length + 1}. `; + const placeholder = t('Type something...'); + + return ( + <> + {hasMultipleQuestions ? chipRow : null} + + {!hasMultipleQuestions ? ( + + + {sanitizeTerminalText(question.header)} + + + ) : null} + {sanitizeTerminalText(question.question)} + + + {question.options.map((option, idx) => { + const isSelected = selected === idx; + const isChecked = + isMultiSelect && (checked[tab] ?? []).includes(option.label); + const isAnswered = !isMultiSelect && picked[tab] === option.label; + const highlighted = isSelected || isAnswered || isChecked; + return ( + + + {(isSelected ? '❯ ' : ' ') + + (isMultiSelect ? (isChecked ? '[✓] ' : '[ ] ') : '') + + `${idx + 1}. ${sanitizeTerminalText(option.label)}` + + (isAnswered ? ' ✓' : '')} + + {option.description ? ( + + + {sanitizeTerminalText(option.description)} + + + ) : null} + + ); + })} + {isCustomRow ? ( + + + {'❯ ' + customMark + customLabel} + + {'> '} + {customValue ? ( + <> + {sanitizeTerminalText(customValue)} + {/* ink's software cursor: a background-filled cell at the + insert position, which a text frame cannot show. */} + + + ) : ( + <> + {placeholder.slice(0, 1)} + {placeholder.slice(1)} + + )} + + ) : ( + + {' ' + + customMark + + customLabel + + (customValue ? sanitizeTerminalText(customValue) : placeholder) + + (isCustomAnswer ? ' ✓' : '')} + + )} - + ); } From 074229cf364a106fb5d572f5b26829308ac337e5 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Fri, 11 Sep 2026 19:37:17 +0800 Subject: [PATCH 04/51] fix(cli): keep a keystroke burst intact in OpenTUI's auth inputs The shared line-input helper behind the endpoint and API-key steps built each next value from the value the registering render had captured. Keystrokes read out of one pty buffer can be delivered inside a single React batch, where no render separates them, so every character in the burst appended to the same stale snapshot and the input ended up holding only the last one. A pasted string was safe because a bracketed paste arrives as one event carrying the whole text, and ordinary typing at human speed was safe because each keystroke flushed a render first, which is why the existing tests, one act per character, never saw it. The helper now keeps a mirror of the value that is written synchronously on every change and re-synced on render, so each event appends to what the previous one produced while a value set from anywhere else is still picked up. Both steps reach it through plain state setters that store exactly what they are given, so the mirror converges with the parent on the next render and the fix stays inside the helper, leaving ink's own wizard untouched. Pinned by dispatching a whole burst inside one act and asserting the input holds all of it and carries it into the next step. Taking the mirror out fails that test alone, with a twelve-character key rendering as its final character. This is correctness by construction: no truncation was observed on a real machine, and no acceptance scenario drives the authentication wizard, so the fix has unit coverage only. --- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 21 +++++++++++++++++++ packages/cli/src/ui/opentui/dialogs-auth.tsx | 19 +++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index b5833791aad..99a29c546aa 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -149,6 +149,16 @@ async function typeText(text: string): Promise { } } +/** Every character in one act, as a burst out of a single pty read arrives. */ +async function typeBatched(text: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of text) { + handler(baseKeyEvent({ name: char, sequence: char })); + } + }); +} + async function pressEsc(): Promise { const handler = mocks.state.inputHandlers.at(-1); if (!handler) throw new Error('no raw input handler registered'); @@ -445,6 +455,17 @@ describe('bracketed-paste into dialog inputs (#57)', () => { await press('return'); // baseUrl → apiKey } + it('keeps every character of a burst that shares one batch', async () => { + await runToApiKeyStep(); + await typeBatched('sk-burst-key'); + expect( + screen.getByText((_, element) => element?.textContent === 'sk-burst-key'), + ).toBeTruthy(); + // the burst is what the wizard carries forward, not its last character + await press('return'); // apiKey → models + expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); + }); + it('inserts a paste into the API-key input and prevents default', async () => { await runToApiKeyStep(); const event = await pasteText('sk-pasted-key'); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index 43e63bd22cc..8c1036f5f89 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -20,7 +20,7 @@ * - documentation/TOS links render as plain text (no OSC 8 in dialogs). */ -import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useKeyboard, usePaste, useRenderer } from '@opentui/react'; import type { PasteEvent } from '@opentui/core'; import { decodePasteBytes } from '@opentui/core'; @@ -239,6 +239,17 @@ function useLineInputKeys( onChange: (next: string) => void, onSubmit: () => void, ) { + // Key events can land in one React batch, where the value captured by the + // render that registered the handler is already stale by the second + // keystroke. The mirror is written synchronously so each event appends to + // what the previous one produced, and re-synced on render so a value set + // from anywhere else is not lost. + const latest = useRef(value); + latest.current = value; + const change = (next: string) => { + latest.current = next; + onChange(next); + }; useKeyboard((key) => { const o = toOriginalKey(key); if (o.name === 'return' || o.name === 'enter') { @@ -246,11 +257,11 @@ function useLineInputKeys( return; } if (o.name === 'backspace' || o.name === 'delete') { - onChange(value.slice(0, -1)); + change(latest.current.slice(0, -1)); return; } if (isPrintableKeyInput(key)) { - onChange(value + key.sequence); + change(latest.current + key.sequence); } }); // Bracketed pastes arrive as one PasteEvent with no keypress per character @@ -262,7 +273,7 @@ function useLineInputKeys( const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - onChange(value + text); + change(latest.current + text); }); } From ce46b456634b5e5a0d97f8cd0f7d80ad0b25e832 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Sat, 12 Sep 2026 15:19:02 +0800 Subject: [PATCH 05/51] fix(cli): draw ink's inline tool arguments row in OpenTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `ui.showToolCallArgs` on, ink writes a call's raw arguments on a dim row under the card header, capped to two wrapped rows with the remainder collapsed into `… +N chars (ctrl+o)` and printed in full once ctrl+O puts the session in its detail state. The setting had no reader here, so the row existed nowhere and a call's payload stayed invisible outside its one-line summary. The policy is not reimplemented. Serialisation, the deduplication against a description that already is the payload, the character budget scaled by the header's own inner width, the surrogate-safe walk and the prompt-injection sanitisation all come from the helper ink uses, which is split so that both renderers read one source of truth for the row. Investigation corrected the premise before a line was written: the live path already carried the arguments, on the event that opens the card, and that is the same value ink's setting reads. Only two carriers were missing — the steering card raised by an `@`-mentioned file, and a resumed session's transcript — and the row still drew nowhere because nothing read the field off the item. A first attempt added a fourth source in the scheduler loop and was reverted once the tests showed it emitting the same row twice per call; what it exposed was that the pre-existing carrier had no test at all, so a fold that dropped it would have passed. That carrier is pinned now, along with the two that were filled in. The arrow that marks an awaiting call moved onto ink's gate with it. It was drawn on every pending card, while ink draws it on the first one — this renderer shows only that call's dialog, so every other arrow marked a call the user could not act on. Frame-verified against ink on a real machine while the call is pending, after it settles, and in the untruncated state, at 100 columns. Two divergences are recorded rather than closed: the row costs this renderer one more physical row, which a control arm raising the same header with the row switched off reproduces unchanged, so it belongs to the wrap rule and not to this row; and ink's per-group height reservation has no counterpart where every card is handed the same per-item ceiling. --- .../2026-09-10-opentui-parity-defect-sweep.md | 160 ++++++++++++++++-- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 119 +++++++++++-- .../ui/components/messages/ToolMessage.tsx | 47 +++-- .../cli/src/ui/opentui/event-adapter.test.ts | 15 +- .../cli/src/ui/opentui/live-session.test.ts | 31 ++++ packages/cli/src/ui/opentui/live-session.ts | 3 + .../cli/src/ui/opentui/start-opentui-ui.tsx | 3 +- .../src/ui/opentui/transcript-adapter.test.ts | 40 ++++- .../cli/src/ui/opentui/transcript-adapter.ts | 5 + .../src/ui/opentui/transcript-view.test.tsx | 129 +++++++++++++- .../cli/src/ui/opentui/transcript-view.tsx | 45 ++++- 11 files changed, 550 insertions(+), 47 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 0b144e44748..6c909b67884 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,14 +57,16 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Nineteen scenarios cover boot, a +rather than at the same wall-clock moment. Twenty-one scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear and exit, a long hold that cycles the loading phrases, a to-do card, the question dialog on its own, the same dialog across three questions with a multi-select and a typed answer, the release of a parked confirmation when the -approval mode changes, and the approval of a gated server at startup. +approval mode changes, the approval of a gated server at startup, a tool call +whose arguments are long enough to be capped, and a control arm that repeats +that call with the arguments row switched off. Three properties of the comparison matter for reading the results. @@ -578,12 +580,120 @@ it. The residuals are the three recorded elsewhere — where a long path wraps, which phase the loading spinner happens to be in, and the update notice this renderer words differently — plus vertical anchoring. +## Decision 22 — the confirmation is drawn where the conversation is + +A tool confirmation arrived as a modal: a bordered box at full width, a title +row naming the tool, its own row announcing that the call was waiting, and a +hint about navigating a list. ink draws none of that chrome. Its confirmation is +a sibling in the transcript, directly below the row of the call that is waiting, +with no border, no title row and no navigation hint. Approving a command +therefore read here as answering a dialog rather than as continuing the +conversation, and the box pushed the question and its options several columns +past where ink puts them. + +The chrome is gone, and the body, the question and the outcome list sit at the +columns ink uses. Two consequences were checked rather than assumed. The row +budgets that had reserved space for the removed chrome shrink with it, and those +bounds turn out to be inert at the viewport heights the harness uses, so the +change was verified against ink's frames at a hundred columns instead of against +the arithmetic: the confirmation's row sequence now matches ink line for line. +And while a call is parked, the loading indicator swaps to ink's waiting phrase +and drops its elapsed-time, token and cancel suffixes — there is no in-flight +request to cancel and no tokens to count. That waiting row renders for a tool +confirmation only: the server-startup approval, the trust gate and the action +confirmations all arrive while the session is idle, where ink's own indicator +renders nothing, and the captured frames agree on both halves. + +## Decision 23 — a card's rendering preference is re-derived per event + +The tool card prefers a structured payload over the flattened text beside it, +which is what lets a shell run paint a styled token grid, a diff render its +coloured lines and a todo write render its status list. The fold that feeds the +card only ever added those payloads and never cleared one, so the preference +could outlive the payload it was chosen for. + +A shell command reaches exactly that state. It streams styled output while it +runs, and if the stream then trips binary detection the accumulated output is +replaced by a plain string. The card kept showing the last grid it had seen, +after the call settled as well, so the user learned nothing about the binary +output and never saw the byte counts that followed. Any tool whose display +changes kind mid-flight has the same shape, since a result carries at most one +structured payload. + +Each event carries the whole display, so the structured fields are now derived +from the event instead of accumulated onto the card. The three producers of +these events each pick the structured form or the flattened form and never emit +both for one result, so re-deriving loses nothing; and the trailing flush that +could have delivered a late update after completion is cancelled before the call +returns, so no path regains a payload a later event legitimately dropped. The +egress disclosure keeps its existing sticky behaviour — it is an additional +notice under the result rather than an alternative rendering of it, and it takes +no part in the preference. + +## Decision 24 — the same mirror guards the authentication inputs + +The endpoint and API-key steps share one line-input helper, and it built each +next value from the value the render that registered it had captured. Keystrokes +read out of one pty buffer can be delivered inside a single React batch, where +no render separates them, so every character in the burst appended to the same +stale snapshot and the field kept only the last one. A pasted string was safe, +because a bracketed paste arrives as one event carrying the whole text, and +typing at human speed was safe, because each keystroke flushed a render first — +which is why the existing tests, one act per character, never saw it. + +The helper now keeps a mirror of its value, written synchronously on every +change and re-synced on render, so each event appends to what the previous one +produced while a value set from anywhere else is still picked up. Both steps +reach it through plain state setters that store exactly what they are given, so +the mirror converges with the parent on the next render and the fix stays inside +the helper, leaving ink's own wizard untouched. Taking the mirror out fails the +burst test alone, with a twelve-character key arriving as its final character. +As with the dialog row above, no truncation was observed on a machine and no +scenario drives the authentication wizard, so this is correctness by +construction with unit coverage only. + +## Decision 25 — the arguments row, and one arrow rather than a row per card + +With `ui.showToolCallArgs` on, ink writes the call's raw arguments on a dim row +under the card header, capped to two wrapped rows with the remainder collapsed +into `… +N chars (ctrl+o)`, and prints them in full once the session is in the +detail state that key toggles. Here nothing read the setting and no such row +existed. The policy is not reimplemented: serialisation, the deduplication +against a description that already is the payload, the character budget scaled by +the header's own inner width, the surrogate-safe walk and the prompt-injection +sanitisation all come from the helper ink uses, which was split so that both +renderers read one source of truth. The row is placed between the header and the +body, as ink orders them. + +Investigation corrected the premise before a line was written. The claim was that +no path carried a call's arguments; in fact the live path always had, in the +event that opens the card, which is the same value ink's setting reads. Only two +carriers were missing — the steering card raised by an `@`-mentioned file, and a +resumed session's transcript — and the row still rendered nowhere, because +nothing read the field off the item. A first attempt at the carriers added a +fourth source in the scheduler loop and was reverted once the tests showed it +emitting the same row twice per call; what it exposed is that the pre-existing +carrier had no test at all, so a fold that dropped it would have passed. That +carrier is pinned now, along with the two that were filled in. + +On a machine, both legs draw the row, and the collapsed remainder is the same +number of characters on both. The row costs this renderer more physical rows +than ink's, which is the wrap-rule divergence recorded below and not this row's +own. + +The arrow that marks an awaiting call was drawn on every pending card. ink draws +it on one: its gate is the first call awaiting approval, and this renderer shows +only that call's dialog, so every other arrow marked a call the user could not +act on. The flag now comes from the first pending id, and a test asserts that a +second pending call gets none — checked by removing the gate, which fails that +test with both arrows on screen. + ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for nineteen scenarios at 100×40 and, for the narrow + reconstructed screen, for twenty-one scenarios at 100×40 and, for the narrow and resize scenarios, at 60×24. Both legs from one bundle and one set of boot arguments. - **Colour was not verified.** The reconstruction is text. Several rows are @@ -622,23 +732,28 @@ What was verified, and how far the verification reaches: ink, which is a product decision inherited from the restore work rather than a defect introduced by it. The same overlay model has a second symptom: a dialog here does not reflow the conversation, so rows that ink pushes out of - the viewport when a tall dialog opens stay visible underneath it here. The - tool confirmation is drawn as a bordered box below the conversation here and - inline within it in ink; matching that means relocating the confirmation into - the transcript and rerouting focus while the composer stays mounted, which is - a change to the approval path rather than to its appearance. + the viewport when a tall dialog opens stay visible underneath it here. - **One divergence is intentional.** The update check reports a skipped check with its reason here, while ink reports a failed automatic update. Both renderers share the emission path; ink's subscriber is registered after the background task emits, so ink loses the soft warning and shows the later hard failure instead. Removing a legitimate warning to match a subscription race is not what aligning to ink means, so the warning stays. -- **One cosmetic divergence is recorded, not fixed.** When the context-file - list contains a path longer than the terminal width, ink moves that path to - a line of its own and then breaks it; this renderer breaks it in place. Both - produce three rows with the same hanging indent and the same text. Matching - the break points would mean reimplementing the wrap algorithm the renderer - already provides. +- **One cosmetic divergence is recorded, not fixed.** The two renderers break a + long row at different columns. ink's default wrap is a word wrap that hard + breaks whatever still overflows and trims nothing, so a row fills to the edge + and its continuation starts at the item's own indent; this renderer's text + nodes break on word boundaries, which inside a path include its slashes and + hyphens, so a token too long for the rest of the row moves to the next one and + leaves the row short. The context-file list shows one face of it and a tool + card's header and arguments row another: at a hundred columns the same + arguments collapse to the same number of hidden characters on both legs and + still cost this renderer one more physical row. Matching the break points would + mean reimplementing the wrap algorithm the renderer already provides. A wrapped + header also loses the space between the tool's display name and its + description, which a scenario raising the same header with the arguments row + switched off reproduces unchanged — the control arm that keeps this last part + separate from the row added above. - **One last resort is deliberately not reproduced.** The kept-model announcement reads the runtime snapshot's identifier and falls back to the configured one, which is what ink does. ink then falls back a third time, to @@ -687,6 +802,23 @@ What was verified, and how far the verification reaches: neither is the list of servers that approve-all prints — the scenario declares one server, so the branch that renders that list is never taken. What the branches do is the shared hook's own behaviour, unchanged here. +- **The arguments row is frame-verified for a call that needs approval, at one + width.** The scenario turning the setting on parks a shell call, so its frames + carry the row while the call is pending — with the single arrow that marks it — + once the call settles, and in the untruncated state the toggle key reaches. + Every other scenario leaves the setting at its default, so the row is absent + from the rest of the matrix by construction. Not checked in a frame: a call + whose description already prints the payload its arguments would repeat, which + is the case the shared helper deduplicates, and asserted in a unit test with a + positive control instead; and any width but a hundred columns. +- **ink's per-group height reservation is not ported.** ink subtracts two rows + for every tool that will print an arguments row from the budget each result in + that group is given, because the row is drawn outside the result budget. This + renderer distributes no such budget: every card is handed the same per-item + ceiling, derived from the viewport height, so there is nothing for the arguments + rows to be subtracted from and they only add rows here. The two differ where ink + would have squeezed a long result, and closing that gap means introducing a + distribution this architecture never had. ## Follow-ups diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 42151d31a02..2900b053ddb 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,11 +41,12 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。十九个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。二十一个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、审批模式变化时被 -拦下的确认框的释放,以及启动时对受门禁服务器的审批。 +拦下的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用, +以及把同一次调用在关掉参数行的情况下重跑的对照臂。 比较方式有三个性质决定了结果该怎么读。 @@ -426,11 +427,93 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 掉。残余是别处已记录过的那三条 —— 长路径在哪里折行、loading spinner 恰好停在哪个相 位、以及本渲染器措辞不同的更新提示 —— 再加上垂直锚定。 +## 决定 22 —— 确认框画在会话所在的地方 + +一条工具确认原本以一个模态框到达:一个占满宽度的带框盒子、一行报出工具名的标题、 +一行单独宣告某个调用正在等待、外加一条关于在列表里导航的提示。ink 一点都不画这些 +外框。它的确认是 transcript 里的一个同级节点,直接落在正在等待的那个调用那一行的 +下面,没有边框、没有标题行、也没有导航提示。于是批准一条命令在这里读起来像是在回 +答一个对话框,而不是在继续这段会话,而且那个盒子把问题和它的选项推到了比 ink 所处 +位置靠右好几列的地方。 + +外框已经去掉,正文、问题与结果清单都落在 ink 使用的那些列上。两处后果是被核对过、 +而不是被假设的。那些为已被去掉的外框预留空间的行数预算随之收缩,而这些边界在 harness +所用的视口高度上其实是不起作用的,所以这次改动是拿一百列下的 ink 帧来验证的,而不是 +拿那套算式验证:确认框的行序列现在与 ink 逐行一致。还有一个:当一个调用被停住时, +loading 指示器会换成 ink 那句等待中的措辞,并去掉它的耗时、token 与取消后缀 —— 既然 +没有在飞的请求,就没什么可取消、也没什么 token 可数。这条等待行只为工具确认渲染: +服务器启动审批、目录信任门禁与动作确认全都到达在会话空闲之时,而 ink 自己的指示器在那 +种情形下什么都不画,抓取的帧在两半上都与此一致。 + +仍然不同的是这一块在纵向上的位置,那是下面记录的锚定分叉,按决策排除在外。 + +## 决定 23 —— 卡片的渲染偏好按事件重新导出 + +工具卡片会优先使用它旁边的结构化载荷而不是那段摊平的文本,正是这个偏好让一次 shell +运行画出带样式的 token 网格、让一份 diff 画出带颜色的行、让一次 todo 写入画出状态清单。 +但喂给卡片的历史折叠只会加上这些载荷、从不清掉一个,于是这个偏好可以活得比它所依据的 +那个载荷更久。 + +一条 shell 命令恰好会走到这个状态。它在运行时流式输出带样式的内容,而如果这段流随后触 +发了二进制检测,累积下来的输出就会被一个纯字符串替换掉。卡片会一直显示它见过的最后一 +个网格,调用结算之后也一样,于是用户既不知道出现了二进制输出,也永远看不到随后那些 +字节数。任何显示种类在飞行中途改变的工具有同一个形状,因为一份结果最多只携带一个结构 +化载荷。 + +每个事件都携带完整的显示内容,所以结构化字段现在是从事件里导出的,而不是往卡片上累积。 +产出这些事件的三个生产者各自在结构化形式与摊平形式之间挑一个,绝不会为同一个结果同时 +发出两者,因此重新导出不会丢失任何东西;而那一次可能在完成之后才送到更新的尾部刷新, +会在调用返回之前被取消,所以没有哪条路径能把后来一个事件合法丢掉载荷再拿回来。出口 +披露保留它原有的粘滞行为 —— 它是结果之下一条额外的通知,而不是结果的另一种渲染,也不 +参与卡片的这套偏好。 + +## 决定 24 —— 同一个镜像也守住认证输入 + +端点与 API key 两步共用一个行输入 helper,而它用「注册它的那次渲染所捕获的值」去构造 +下一个值。从同一个 pty 缓冲区里读出的按键可能在单个 React 批次内被派发,中间没有任何 +渲染把它们分开,于是这一串连击里的每个字符都追加到同一份已经过期的快照上,输入框最后 +只留下最后一个字符。粘贴是安全的,因为一次括起粘贴是携带全文的单个事件;以人类速度打字 +也是安全的,因为每次按键都会先冲刷一次渲染 —— 这正是那些一个 act 打一个字符的既有测试 +从未看见它的原因。 + +这个 helper 现在为自己的值保留一份镜像,在每次变更时同步写入、在渲染时重新对齐,于是每 +个事件都追加在上一个事件的产出之后,而从别处设进来的值仍然会被取到。两步都通过普通的 +state setter 到达它,而 setter 存的就是收到的那个值,所以镜像会在下一次渲染与父级收敛, +修复因此留在 helper 内部,没有改动 ink 自己的向导。把镜像拿掉只会让那个连击测试失败, +一个十二字符的 key 到达为它的最后一个字符。与上面那个对话框的行同理:真机上没有观察到 +截断,也没有任何场景走认证向导,所以这是构造上的正确性,只有单元测试覆盖。 + +## 决定 25 —— 参数行,以及一个箭头而不是每张卡片一行 + +当 `ui.showToolCallArgs` 打开时,ink 会把这次调用的原始参数写在卡片 header 下面一行暗色 +文本里,上限两行折行,余下的折叠成 `… +N chars (ctrl+o)`,并在会话进入那个键所切换的详情 +态时完整打印。在这里这个设置没有任何读取点,也根本不存在这样一行。这套策略没有被重新 +实现:序列化、与「描述本身就已经是那份载荷」时的去重、按 header 自身内宽缩放的字符预算、 +规避代理对安全问题的逐列行走,以及针对提示注入的清洗,全部来自 ink 所用的那个 helper, +而它被拆开是为了让两个渲染器读同一个事实来源。这一行的位置在 header 与正文之间,与 ink +的排序一致。 + +调查在写下任何一行之前修正了前提。原先的说法是「没有任何路径携带调用的参数」;实际上 +实时路径一直都带着,就在打开这张卡片的那个事件里,而它携带的正是 ink 这个设置所读的 +那个值。真正缺的只有两个载体 —— 由 `@` 提及文件所引发的转向卡片,以及恢复会话的 +transcript —— 而这一行仍然哪儿都不画,因为没有任何东西去读取条目上的那个字段。第一次 +尝试载体改动时在调度循环里加了第四个来源,等测试显示它每次调用把同一行发了两遍之后被 +回退;它暴露出来的事实是那个既存载体完全没有测试,所以一个把它丢掉的折叠本会通过。 +如今那个既存载体被钉住了,新补的两个也是。 + +在真机上,两腿都画出了这一行,而折叠掉的余数在两腿上是同样的字符数。这一行在本渲染器 +占用的物理行数比 ink 多,那是下面记录的折行规则分叉,不是这一行自己的问题。 + +标记等待调用的那个箭头原本画在每一张待审批卡片上。ink 只画在一个上:它的门禁是第一个 +正在等待审批的调用,而本渲染器也只显示那个调用的对话框,所以其余每一个箭头标记的都是 +用户无法操作的调用。这个标志现在取自第一个待审批的 id,并且有一个测试断言第二个待审批 +的调用拿不到箭头 —— 验证方式是把这道门禁去掉,那会让该测试失败并在屏幕上留下两个箭头。 + ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖十九个场景, +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十一个场景, 100×40;窄终端与 resize 场景另加 60×24。两腿出自同一个 bundle 与同一组启动 参数。 - **颜色未验证。** 重建结果是文本。已知有若干行只在所用主题 token 上不同,而 @@ -456,18 +539,21 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 - **两处结构性分叉被记录并刻意不在本文内修。** banner 在本渲染器里常驻、在 ink 里会滚出视口,这是从恢复工作继承下来的产品决策,而不是它引入的缺陷。同一 套浮层模型还有第二个症状:这里的对话框不会重排会话,所以当 ink 打开一个高对话 - 框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。工具确认在这里画成 - 会话下方的带框盒子、在 ink 里画在会话内联;要对齐就得把确认搬进 transcript,并 - 在 composer 保持挂载的情况下重新路由焦点 —— 那是对审批路径的改动,而不是对其 - 外观的改动。 + 框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。 - **一处分叉是有意的。** 更新检查在这里报告「检查被跳过」及其原因,而 ink 报告 「自动更新失败」。两个渲染器共用发出路径;ink 的订阅者在后台任务发出之后才 注册,所以 ink 丢掉了那条软警告,转而显示之后那条硬失败。为了对齐一个订阅竞 态而删掉一条合法警告,不是「对齐 ink」的意思,所以警告保留。 -- **一处外观分叉被记录,未修。** 当 context 文件列表里含有一条长于终端宽度的 - 路径时,ink 会把该路径挪到独立一行再断开,本渲染器就地断开。两者都产出三行、 - 同样的悬挂缩进与同样的文本。要对齐断点就意味着重新实现渲染器已经提供的换行 - 算法。 +- **一处外观分叉被记录,未修。** 两个渲染器在不同列上断开一行长内容。ink 的默认 + 换行是按词换行、但把仍然放不下的部分强行截断且不做裁剪,所以一行会一直填满到 + 边缘,续行从该条目自己的缩进处开始;本渲染器的 text 节点按词边界断开,而一条路径 + 里的词边界包含其中的斜杠与连字符,所以一个长到放不下当前行的词会被整体挪到下一 + 行、把当前行留短。context 文件列表是它的第一个面,工具卡片的 header 与参数行是 + 第二个面:在一百列下,同样的参数在两腿上折叠成同样数目的隐藏字符,却仍然比 ink + 多耗一个物理行。要对齐断点就意味着重新实现渲染器已经提供的换行算法。被折行的 + header 还会丢掉工具显示名与其描述之间的那个空格,而一个把参数行关掉、却抬起同一 + 个长 header 的场景会原样复现这一点 —— 正是这条对照把最后这一点与上面新增的那一行 + 区分开。 - **一处最后兜底被刻意不复现。**「保留原模型」播报先读运行期快照的标识符,再退到 配置的那一个,这与 ink 一致。ink 还会第三次兜底 —— 当配置的标识符读出来为空时, 退到一个硬编码的默认模型标识符。在这里那不会发生,所以这第三层被省掉,而不是为一 @@ -501,6 +587,17 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 会派生所声明的命令。所以批准与全部批准两个分支都没有帧覆盖,全部批准要打印的那份服务 器列表也没有 —— 场景只声明了一个服务器,所以渲染那份列表的分支从不被走到。这些分支 做什么是那个共享 hook 自己的行为,这里没有改动。 +- **参数行只在一个宽度上、对一个需要审批的调用做过帧验证。** 打开这个设置的那个场景 + 会停住一条 shell 调用,所以它的帧里同时带着:等待中的参数行 —— 以及标记这一条的那 + 个唯一箭头 ——、调用结算之后的样子,和那个切换键到达的不截断状态。其余每一个场景都把 + 该设置留在默认值,所以这一行在矩阵的其余部分按构造就不存在。没有用帧查过的:一个描述 + 本身就已经打印了参数载荷的调用,也就是共享 helper 要去重的那一种 —— 它由一个带正证 + 对照的单元测试断言;以及一百列之外的任何宽度。 +- **ink 那份按组的预留高度没有移植。** ink 会从该组里每一个结果拿到的预算中,为这一个 + 组里每一个将要打印参数行的工具扣掉两行,因为那一行画在结果预算之外。本渲染器不做这种 + 分配:每一张卡片拿到的都是同一个按条目算出的上限,它由视口高度导出,所以参数行在这里 + 没有可扣的预算、只会额外增加行数。两者只在 ink 本会压缩一条长结果的地方不同,而要补上 + 这段差距,就得引入这套架构从来没有过的分配。 ## 后续工作 diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index e3c3f3e88b0..415727a9ecc 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -1157,7 +1157,35 @@ export const TOOL_ARGS_INLINE_MAX_LINES = 2; /** * One-line JSON for the `ui.showToolCallArgs` row, or undefined when there is - * nothing worth adding. + * nothing worth adding. Serializes `args`, then defers to + * {@link formatInlineToolArgsJson} for the row itself. + */ +export function formatInlineToolArgs( + args: Record | undefined, + description: string, + uncapped: boolean, + rowWidth?: number, +): string | undefined { + if (!args || Object.keys(args).length === 0) { + return undefined; + } + + let json: string; + try { + json = JSON.stringify(args); + } catch { + // Circular or otherwise unserializable args — the header line is all we + // can honestly show. + return undefined; + } + + return formatInlineToolArgsJson(json, description, uncapped, rowWidth); +} + +/** + * The `ui.showToolCallArgs` row over an already-serialized `json`, so a renderer + * that carries the call's arguments as text (OpenTUI's `tool-args` event) draws + * the same row as this one rather than keeping a second copy of the policy. * * Skipped when `description` already IS the args JSON: MCP invocations return * `safeJsonStringify(params)` from `getDescription()`, so rendering both would @@ -1179,25 +1207,12 @@ export const TOOL_ARGS_INLINE_MAX_LINES = 2; * component). When given, the row is bounded to `TOOL_ARGS_INLINE_MAX_LINES` * wrapped rows rather than by character count alone — see that constant. */ -export function formatInlineToolArgs( - args: Record | undefined, +export function formatInlineToolArgsJson( + json: string, description: string, uncapped: boolean, rowWidth?: number, ): string | undefined { - if (!args || Object.keys(args).length === 0) { - return undefined; - } - - let json: string; - try { - json = JSON.stringify(args); - } catch { - // Circular or otherwise unserializable args — the header line is all we - // can honestly show. - return undefined; - } - const trimmedDescription = description.trim(); if (trimmedDescription.startsWith('{')) { try { diff --git a/packages/cli/src/ui/opentui/event-adapter.test.ts b/packages/cli/src/ui/opentui/event-adapter.test.ts index b9e3a857865..1dffe1d68a5 100644 --- a/packages/cli/src/ui/opentui/event-adapter.test.ts +++ b/packages/cli/src/ui/opentui/event-adapter.test.ts @@ -56,7 +56,20 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => { type: 'tool_call_request', value: { callId: 'c1', name: 'shell' }, } as unknown as AnyEv); - expect(s[0].type).toBe('tool-start'); + expect(s).toEqual([ + { type: 'tool-start', id: 'c1', tool: 'shell', title: 'shell' }, + ]); + // ink's `ui.showToolCallArgs` row reads the call's raw arguments, so they + // ride the stream right behind the card that opens. + expect( + map({ + type: 'tool_call_request', + value: { callId: 'c2', name: 'shell', args: { command: 'ls -la' } }, + } as unknown as AnyEv), + ).toEqual([ + { type: 'tool-start', id: 'c2', tool: 'shell', title: 'shell' }, + { type: 'tool-args', id: 'c2', args: '{"command":"ls -la"}' }, + ]); expect( map({ type: 'tool_call_response', diff --git a/packages/cli/src/ui/opentui/live-session.test.ts b/packages/cli/src/ui/opentui/live-session.test.ts index 6d07032f954..60dfac99505 100644 --- a/packages/cli/src/ui/opentui/live-session.test.ts +++ b/packages/cli/src/ui/opentui/live-session.test.ts @@ -964,6 +964,37 @@ describe('livePromptEvents', () => { ); }); + it("carries an @-mention card's arguments for ink's args row", async () => { + const sendMessageStream = oneToolBatchStream({ + callId: 't1', + name: 'test_tool', + args: {}, + }); + const config = createFakeConfig(sendMessageStream); + atMocks.result = { + processedQuery: [{ text: 'look @src/a.ts' }], + shouldProceed: true, + toolDisplays: [readDisplay({ args: { file_path: 'src/a.ts' } })], + }; + + const events = (await drain( + livePromptEvents(config, 'start', undefined, { + drainSteering: () => ['look @src/a.ts'], + }), + )) as OpenTuiStreamEvent[]; + + const argsRows = events.filter( + (e) => e.type === 'tool-args' && e.id === 'client-read-1', + ); + expect(argsRows).toEqual([ + { + type: 'tool-args', + id: 'client-read-1', + args: '{"file_path":"src/a.ts"}', + }, + ]); + }); + it('joins drained steering texts with a blank line between messages', async () => { const sendMessageStream = oneToolBatchStream({ callId: 't1', diff --git a/packages/cli/src/ui/opentui/live-session.ts b/packages/cli/src/ui/opentui/live-session.ts index 478d87fbca5..2663cbe71ac 100644 --- a/packages/cli/src/ui/opentui/live-session.ts +++ b/packages/cli/src/ui/opentui/live-session.ts @@ -55,6 +55,7 @@ import type { Part, PartListUnion } from '@google/genai'; import { createEventMapper, extractStructuredResult, + formatToolArgs, renderResultDisplay, toolResultEvent, type OpenTuiStreamEvent, @@ -253,6 +254,8 @@ function atMentionCardEvents( title: display.description, }, ]; + const args = formatToolArgs(display.args); + if (args) events.push({ type: 'tool-args', id: display.callId, args }); const result = toolResultEvent(display.callId, display.resultDisplay); if (result) events.push(result); const failed = display.status === ToolCallStatus.Error; diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx index a8386508523..6bc298d58f7 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -246,10 +246,11 @@ function OpenTuiEntryApp({ availableWidth={Math.max(0, width - 4)} availableTerminalHeight={height} thoughtsExpanded={thoughtsExpanded} + showToolCallArgs={settings.merged.ui?.showToolCallArgs === true} /> ), - [live.items, width, height, thoughtsExpanded], + [live.items, width, height, thoughtsExpanded, settings], ); const handleRenderError = useCallback( diff --git a/packages/cli/src/ui/opentui/transcript-adapter.test.ts b/packages/cli/src/ui/opentui/transcript-adapter.test.ts index b96970b7086..9973c360a78 100644 --- a/packages/cli/src/ui/opentui/transcript-adapter.test.ts +++ b/packages/cli/src/ui/opentui/transcript-adapter.test.ts @@ -7,7 +7,8 @@ /** * Resume replay rules for subtyped user records: U-32 steering * (mid_turn_user_message) replays as a real user row, side-band records - * (goal_runtime, cron) stay out of the transcript. + * (goal_runtime, cron) stay out of the transcript, and a replayed functionCall + * carries its arguments for ink's inline args row. */ import { describe, it, expect } from 'vitest'; @@ -107,3 +108,40 @@ describe('transcriptToEvents subtyped user records', () => { expect(events).toEqual([{ type: 'done' }]); }); }); + +describe('transcriptToEvents resumed tool calls', () => { + const call = (id: string, name: string, args: unknown) => + JSON.stringify({ + type: 'assistant', + message: { role: 'model', parts: [{ functionCall: { id, name, args } }] }, + }); + const result = (id: string) => + JSON.stringify({ type: 'tool_result', toolCallResult: { callId: id } }); + + it("replays the arguments for ink's inline args row", () => { + const events = transcriptToEvents( + [ + call('c1', 'edit', { file_path: 'a.ts' }), + result('c1'), + call('c2', 'list_directory', {}), + result('c2'), + ].join('\n'), + ); + // ink's resume placeholder records `args` for the same row, so a resumed + // session shows what the live one did — and a call without arguments + // carries no row at all. + expect(events).toEqual([ + { type: 'tool-start', id: 'c1', tool: 'edit', title: 'edit' }, + { type: 'tool-args', id: 'c1', args: '{"file_path":"a.ts"}' }, + { type: 'tool-end', id: 'c1', success: true, summary: 'ok' }, + { + type: 'tool-start', + id: 'c2', + tool: 'list_directory', + title: 'list_directory', + }, + { type: 'tool-end', id: 'c2', success: true, summary: 'ok' }, + { type: 'done' }, + ]); + }); +}); diff --git a/packages/cli/src/ui/opentui/transcript-adapter.ts b/packages/cli/src/ui/opentui/transcript-adapter.ts index 5e4c6860c2c..b5c1e85bad5 100644 --- a/packages/cli/src/ui/opentui/transcript-adapter.ts +++ b/packages/cli/src/ui/opentui/transcript-adapter.ts @@ -17,6 +17,7 @@ import { readFileSync } from 'node:fs'; import { extractStructuredResult, + formatToolArgs, renderResultDisplay, type OpenTuiStreamEvent, } from './event-adapter.js'; @@ -173,6 +174,10 @@ export function transcribeSession( tool: name, title: opts.toolTitle?.(name) ?? name, }); + const args = formatToolArgs( + p.functionCall.args as Record | undefined, + ); + if (args) events.push({ type: 'tool-args', id, args }); } else if (p.text) { events.push({ type: 'text', delta: p.text }); } diff --git a/packages/cli/src/ui/opentui/transcript-view.test.tsx b/packages/cli/src/ui/opentui/transcript-view.test.tsx index 93acfba6900..5cc790b9c75 100644 --- a/packages/cli/src/ui/opentui/transcript-view.test.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.test.tsx @@ -9,8 +9,9 @@ * Mount coverage for the transcript view's review-round behaviors: an * awaiting-approval card keeps its (capped) description — the confirmation * dialog does not carry the payload for every type, so an MCP call stays - * approvable with its arguments on screen — and the `!` shell row carries - * ink's `$ ` prefix. + * approvable with its arguments on screen — the `!` shell row carries ink's + * `$ ` prefix, and `ui.showToolCallArgs` adds ink's inline arguments row + * under the card header. */ import { describe, it, expect, vi } from 'vitest'; @@ -226,6 +227,130 @@ describe('OpenTuiTranscriptView', () => { expect(container.textContent).toContain('done shows too'); }); + describe("ink's inline arguments row (ui.showToolCallArgs)", () => { + const argsItem = (overrides: Partial = {}) => + toolItem({ + tool: 'write_file', + title: 'write_file', + args: '{"file_path":"/x","content":"ARGS_ROW_MARKER"}', + confirm: 'approved', + done: true, + ...overrides, + }); + + it('stays off unless the setting is enabled', () => { + const { container } = render( + , + ); + // ink reads the same setting; off is the schema default, so the header + // alone carries the call (the file path is ink's description). + expect(container.textContent).not.toContain('ARGS_ROW_MARKER'); + }); + + it('draws the raw JSON on its own line under the header', () => { + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('{"file_path":"/x","content":"ARGS_ROW_MARKER"}'); + // The row follows the header it belongs to, not the card body. + expect(text.indexOf('ARGS_ROW_MARKER')).toBeGreaterThan( + text.indexOf('write_file'), + ); + }); + + it('skips the row when the header already prints the args (MCP dedup)', () => { + const json = '{"path":"/x","content":"SECRET_PAYLOAD"}'; + const payloadRows = (text: string) => + text.match(/SECRET_PAYLOAD/g)?.length ?? 0; + const deduped = render( + , + ); + // MCP tools describe themselves as their own arguments, so both surfaces + // would print the same payload. + expect(payloadRows(deduped.container.textContent ?? '')).toBe(1); + deduped.unmount(); + + // Positive control: a header that carries the payload without *being* it + // keeps both lines, so the one above is the dedup dropping the row — not + // the row failing to render at all. + const other = render( + , + ); + expect(payloadRows(other.container.textContent ?? '')).toBe(2); + }); + + it('caps the row at two wrapped rows and names ctrl+o as the valve', () => { + // ink bounds the row against the header's inner width (80 columns minus + // the status glyph), so a WriteFile `content` arg cannot bury the + // conversation; the marker advertises what ctrl+O reveals. + const json = '{"file_path":"/x","content":"' + 'z'.repeat(400) + 'TAIL"}'; + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('… +'); + expect(text).toContain('chars (ctrl+o)'); + expect(text).not.toContain('TAIL'); + }); + + it("uncaps the row on ink's ctrl+O full-detail flag", () => { + const json = '{"file_path":"/x","content":"' + 'z'.repeat(400) + 'TAIL"}'; + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('TAIL'); + expect(text).not.toContain('ctrl+o)'); + }); + }); + + it("marks only the first awaiting call with ink's ← indicator", () => { + // ink's TrailingIndicator sits on `toolAwaitingApproval`, the first call in + // confirming status — opentui renders only that card's dialog, so arrows on + // every pending row would point at calls with nothing on screen to answer. + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text.split('←')).toHaveLength(2); + expect(text.indexOf('←')).toBeLessThan(text.indexOf('echo two')); + }); + it('renders the ! shell row with the ink $ prefix', () => { const { container } = render( item.kind === 'tool' && item.confirm === 'pending' && !item.done, + )?.id; return ( {items.map((item) => ( @@ -121,6 +129,8 @@ export function OpenTuiTranscriptView({ terminalHeight={availableTerminalHeight} width={availableWidth} thoughtsExpanded={thoughtsExpanded} + showToolCallArgs={showToolCallArgs} + awaitingApproval={item.id === awaitingId} /> ))} @@ -134,12 +144,16 @@ function TranscriptItem({ terminalHeight, width, thoughtsExpanded, + showToolCallArgs, + awaitingApproval, }: { item: LiveHistoryItem; maxRows: number; terminalHeight: number; width: number; thoughtsExpanded: boolean; + showToolCallArgs: boolean; + awaitingApproval: boolean; }) { switch (item.kind) { case 'user': @@ -155,6 +169,9 @@ function TranscriptItem({ maxRows={maxRows} terminalHeight={terminalHeight} width={width} + fullDetail={thoughtsExpanded} + showToolCallArgs={showToolCallArgs} + awaitingApproval={awaitingApproval} /> ); case 'task': @@ -296,11 +313,17 @@ function ToolCard({ maxRows, terminalHeight, width, + fullDetail, + showToolCallArgs, + awaitingApproval, }: { item: LiveToolItem; maxRows: number; terminalHeight: number; width: number; + fullDetail: boolean; + showToolCallArgs: boolean; + awaitingApproval: boolean; }) { const status = toolStatusMeta(item); const name = toolCardName(item.tool); @@ -328,6 +351,19 @@ function ToolCard({ : TOOL_CARD_DESCRIPTION_ROWS, ); const suffix = toolCardSummarySuffix(item.done, item.summary); + // ink measures the args row against the header's own inner width (the status + // glyph's columns are not available to it), so the wrapped-row cap bounds + // what actually reaches the screen. + const innerWidth = width - STATUS_INDICATOR_WIDTH; + const argsRow = + showToolCallArgs && item.args + ? formatInlineToolArgsJson( + item.args, + description, + fullDetail, + innerWidth > 0 ? innerWidth : undefined, + ) + : undefined; return ( @@ -348,7 +384,7 @@ function ToolCard({ ) : null} {suffix ? {sanitizeTerminalText(suffix)} : null} - {item.confirm === 'pending' && !item.done ? ( + {awaitingApproval ? ( // ink's TrailingIndicator: a primary-coloured arrow at the end of the // awaiting call's own row, not a row of its own. {' ←'} @@ -357,6 +393,13 @@ function ToolCard({ {cap.hiddenRows > 0 && ( {hiddenTailLinesLabel(cap.hiddenRows)} )} + {argsRow ? ( + + + {argsRow} + + + ) : null} ); From 19e2c2592272c22ba485109f515983a10d0d456f Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 09:39:04 +0800 Subject: [PATCH 06/51] docs: correct the row-budget claim the viewport fix overturned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawing the confirmation inline was recorded as making the row budget that reserved space for the removed chrome inert at the heights the harness uses. It is not inert, and the harness never reaches the height where it binds: the reserve was raised upstream after a long confirmation on an 80-row terminal pushed the payload's tail and the outcome list past the bottom edge, and this branch was rebased onto that fix. Both language versions now record the re-derivation and the two arms that bound the value — the scenario is green at the shipped reserve and times out waiting for the tail at zero, but stays green four rows lower, so the arithmetic is what pins it. --- .../2026-09-10-opentui-parity-defect-sweep.md | 36 +++++++++++++++---- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 30 +++++++++++----- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 6c909b67884..90c83c97e8d 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -592,13 +592,35 @@ conversation, and the box pushed the question and its options several columns past where ink puts them. The chrome is gone, and the body, the question and the outcome list sit at the -columns ink uses. Two consequences were checked rather than assumed. The row -budgets that had reserved space for the removed chrome shrink with it, and those -bounds turn out to be inert at the viewport heights the harness uses, so the -change was verified against ink's frames at a hundred columns instead of against -the arithmetic: the confirmation's row sequence now matches ink line for line. -And while a call is parked, the loading indicator swaps to ink's waiting phrase -and drops its elapsed-time, token and cancel suffixes — there is no in-flight +columns ink uses, and the confirmation's row sequence now matches ink's line for +line at a hundred columns. + +One claim made about this change here was wrong, and the merge caught it: the +row budget that had reserved space for the removed chrome was described as inert +at the viewport heights the harness uses. It is not inert, and the harness never +exercised the height where it binds. Upstream raised that same reserve after a +long confirmation on an 80-row terminal pushed the payload's tail and the +outcome list past the bottom edge, so what was being approved could not be read +and the options could not be reached. That fix merged first, and this change was +rebased onto it. + +The reserve was then re-derived rather than copied, from what the inline +confirmation still spends around its body: the banner, the startup notices a +fresh session shows, the prompt echo with its turn margin, the card's own hidden +tail row, the question row, the outcome list, and the waiting row. The border, +title, body margins and footer hint that the earlier version counted are gone +with the chrome, so the number lands below the one it replaces by roughly what +those rows cost. It was then checked both ways. The long-confirmation scenario, +which drives this renderer at 80 rows and 110 columns, is green at the value +that shipped and times out waiting for the payload's tail at zero, so that +scenario does still bound the number. It does not bound it tightly — a value +four rows smaller passes as well — so the threshold sits below the range that +mattered here, and the arithmetic is what pins the shipped value rather than +the scenario. + +One further consequence was checked rather than assumed. While a call is parked, +the loading indicator swaps to ink's waiting phrase and drops its elapsed-time, +token and cancel suffixes — there is no in-flight request to cancel and no tokens to count. That waiting row renders for a tool confirmation only: the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle, where ink's own indicator diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 2900b053ddb..c50f80271e9 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -436,14 +436,28 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 答一个对话框,而不是在继续这段会话,而且那个盒子把问题和它的选项推到了比 ink 所处 位置靠右好几列的地方。 -外框已经去掉,正文、问题与结果清单都落在 ink 使用的那些列上。两处后果是被核对过、 -而不是被假设的。那些为已被去掉的外框预留空间的行数预算随之收缩,而这些边界在 harness -所用的视口高度上其实是不起作用的,所以这次改动是拿一百列下的 ink 帧来验证的,而不是 -拿那套算式验证:确认框的行序列现在与 ink 逐行一致。还有一个:当一个调用被停住时, -loading 指示器会换成 ink 那句等待中的措辞,并去掉它的耗时、token 与取消后缀 —— 既然 -没有在飞的请求,就没什么可取消、也没什么 token 可数。这条等待行只为工具确认渲染: -服务器启动审批、目录信任门禁与动作确认全都到达在会话空闲之时,而 ink 自己的指示器在那 -种情形下什么都不画,抓取的帧在两半上都与此一致。 +外框已经去掉,正文、问题与结果清单都落在 ink 使用的那些列上,确认框的行序列现在在一百 +列终端下与 ink 逐行一致。 + +这里对这一改动下过一个判断是错的,是合并把它抓出来的:那个为已被去掉的外框预留空间的 +行数预算,曾被说成在 harness 所用的视口高度上不起作用。它并不是不起作用,只是 harness +从未跑到它真正收紧的那个高度。上游是在 80 行终端上,一条超长确认把载荷的尾部连同结果 +清单一起推过了屏幕底边,于是用户读不到自己要批准什么、也够不着那些选项,之后抬高了这 +同一个预留量。那个修复先合并了,本改动是换基到它之上继续的。 + +于是这个预留量是被重新推导出来的,而不是照抄:内联之后的确认框在正文上下仍然要花掉的, +是 banner、新会话开屏时的启动公告、带本回合边距的提示回显、卡片自己的隐藏尾行、问题行、 +结果清单,以及那条等待行。早先那一版算进去的边框、标题、正文上下边距和底部提示,都随外 +框一起没了,所以这个数比它替换掉的数小,小的幅度差不多正是那几行花掉的。然后两边都验了 +一遍:跑 80 行 110 列的那条长确认场景,在合入的取值下是绿的,在零值下会等载荷尾部等到 +超时 —— 所以这条场景确实还守着这个数。但它守得不紧:比合入值再小四行的取值同样是绿的, +也就是说阈值落在本轮关心的区间之下,钉住合入值的是那套算式而不是这条场景。 + +还有一处后果是被核对过、而不是被假设的:当一个调用被停住时,loading 指示器会换成 ink +那句等待中的措辞,并去掉它的耗时、token 与取消后缀 —— 既然没有在飞的请求,就没什么可 +取消、也没什么 token 可数。这条等待行只为工具确认渲染:服务器启动审批、目录信任门禁与 +动作确认全都到达在会话空闲之时,而 ink 自己的指示器在这种情形下什么都不画,抓取的帧在 +两半上都与此一致。 仍然不同的是这一块在纵向上的位置,那是下面记录的锚定分叉,按决策排除在外。 From fc28b2d03fdc6c7951926ddfb25055751e9ac8b3 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 10:29:26 +0800 Subject: [PATCH 07/51] docs: retire the two card gaps this change closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up list still recorded the missing inline arguments row and the pending indicator on every card, both of which the last commit on this branch drew. Lead the item with the gap that stays open — a settled card keeps the position it was created at — and say what closed the other two, so a reader does not implement a fix that has landed. --- .../2026-09-10-opentui-parity-defect-sweep.md | 14 +++++++------- ...2026-09-10-opentui-parity-defect-sweep.zh-CN.md | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 90c83c97e8d..16b42a216e2 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -915,10 +915,10 @@ What was verified, and how far the verification reaches: scope for a sweep that measures itself against ink's behaviour as it stands; it is recorded here so the divergence between the two renderers is not re-reported as a porting gap. -- The tool card does not print the call's arguments inline, and omits the - trailing indicator ink puts beside a row that is still pending. The first is - not cosmetic — it is why a disabled-tool error reads as an empty card here and - as a card carrying the full argument JSON there. A settled card also keeps the - position it was created at, where ink commits it to permanent history after - whatever notices arrived meanwhile, so a notice printed during a tool call - lands after the card here and before it there. +- A settled card keeps the position it was created at, where ink commits it to + permanent history after whatever notices arrived meanwhile, so a notice + printed during a tool call lands after the card here and before it there. + This item also recorded an arguments row missing from the card and a pending + indicator drawn on every card rather than the one call awaiting approval; + Decision 25 closed both, and the empty-card reading of a disabled-tool error + went with the first. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index c50f80271e9..02e1ef4f7c9 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -663,8 +663,8 @@ transcript —— 而这一行仍然哪儿都不画,因为没有任何东西 画出来,而复核标签页把它报告为未回答。这是在真机上观察到的,不是推出来的。 决定 21 不跟随它,而在以 ink 现状行为度量自己的清扫里去修 ink 超出了范围; 记在这里,免得两个渲染器间的这处分歧被再次当成移植缺口上报。 -- 工具卡片不会把调用的参数行内打印出来,也省略了 ink 放在一条仍在等待的行末尾的指 - 示符。第一条不只是外观问题 —— 正因如此,一个「工具被禁用」的错误在这里读起来是 - 一张空卡片,而在那里是一张带着完整参数 JSON 的卡片。一张已结算的卡片还会停在它 - 被创建时的位置,而 ink 会把它提交进永久历史、排在期间到达的那些提示之后,所以一 - 条在工具调用期间打印的提示,在这里落在卡片之后,在那里落在卡片之前。 +- 一张已结算的卡片会停在它被创建时的位置,而 ink 会把它提交进永久历史、排在期间到 + 达的那些提示之后,所以一条在工具调用期间打印的提示,在这里落在卡片之后,在那里 + 落在卡片之前。这一条曾经还记着卡片缺少的行内参数行,以及一个画在每张卡片上、而 + 非只画在那一个等待审批的调用上的等待指示符;决定 25 关掉了这两点,「工具被禁用」 + 的错误在这里读成一张空卡片,也随第一条一起消失了。 From 4ef811ed03f6ea3994f7b73f3f2a65e101b0199d Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 12:23:02 +0800 Subject: [PATCH 08/51] fix(cli): keep shell output within ink's history retention ink compacts a shell card's display through the shared history retention policy while handing the model the verbatim output; the OpenTUI shell path skipped the compaction on both the streamed snapshots and the final result, so a long-running command could pin its whole output in the transcript. --- .../cli/src/ui/opentui/shell-mode.test.ts | 49 +++++++++++++++++-- packages/cli/src/ui/opentui/shell-mode.ts | 18 +++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/opentui/shell-mode.test.ts b/packages/cli/src/ui/opentui/shell-mode.test.ts index 3389f6f2cc4..c160792d4e6 100644 --- a/packages/cli/src/ui/opentui/shell-mode.test.ts +++ b/packages/cli/src/ui/opentui/shell-mode.test.ts @@ -12,10 +12,11 @@ import fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - Config, - ShellExecutionResult, - ShellOutputEvent, +import { + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + type Config, + type ShellExecutionResult, + type ShellOutputEvent, } from '@qwen-code/qwen-code-core'; import { executeUserShell } from './shell-mode.js'; import type { OpenTuiStreamEvent } from './event-adapter.js'; @@ -198,6 +199,46 @@ describe('executeUserShell', () => { ); }); + it('compacts an over-long card display but hands the model the whole output', async () => { + // ink's split: the UI history row is compacted, addShellCommandToLlmHistory + // keeps the verbatim text. Un-compacted, a long command would also pin the + // whole output in the transcript for the rest of the session. + const long = `${'a'.repeat(1000)}${'z'.repeat(40_000)}`; + const { events, done, resolveResult } = setup(); + resolveResult(makeResult({ output: long, rawOutput: Buffer.from(long) })); + await done; + const result = events[events.length - 2]; + expect(result?.type).toBe('tool-result'); + if (result?.type !== 'tool-result') throw new Error('no result event'); + expect(result.display.length).toBeLessThanOrEqual( + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + ); + expect(result.display.startsWith('a'.repeat(1000))).toBe(true); + expect(result.display.endsWith('zzzz')).toBe(true); + expect(addHistoryMock).toHaveBeenLastCalledWith( + llmClient, + 'echo hello', + long, + ); + }); + + it('compacts a streamed snapshot beyond what the card may retain', async () => { + const long = 'b'.repeat(MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS + 5000); + const { events, done, emitOutput, resolveResult } = setup(); + emitOutput('x'); + vi.advanceTimersByTime(1001); + emitOutput(long); + const snapshot = events.find((event) => event.type === 'tool-output'); + expect(snapshot?.type).toBe('tool-output'); + if (snapshot?.type !== 'tool-output') throw new Error('no snapshot'); + expect(snapshot.output.length).toBeLessThanOrEqual( + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + ); + expect(snapshot.output.startsWith('x')).toBe(true); + resolveResult(makeResult()); + await done; + }); + it('lands the full output on the card when nothing was streamed', async () => { const { events, done, resolveResult } = setup(); resolveResult( diff --git a/packages/cli/src/ui/opentui/shell-mode.ts b/packages/cli/src/ui/opentui/shell-mode.ts index f278eb857f9..31d95b0f697 100644 --- a/packages/cli/src/ui/opentui/shell-mode.ts +++ b/packages/cli/src/ui/opentui/shell-mode.ts @@ -20,6 +20,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { + compactToolResultDisplayForHistory, isBinary, isSignalTermination, ShellExecutionService, @@ -117,7 +118,11 @@ export async function executeUserShell( cumulative.length > emittedLength ) { emittedLength = cumulative.length; - emit({ type: 'tool-output', id: callId, output: cumulative }); + emit({ + type: 'tool-output', + id: callId, + output: compactToolResultDisplayForHistory(cumulative), + }); lastUpdate = Date.now(); } }; @@ -178,11 +183,16 @@ export async function executeUserShell( : res.output.trim() || '(Command produced no output)'; // The result event replaces whatever streamed onto the card, so it - // carries the whole display — the same string the LLM history write - // below uses. + // carries the whole display. Ink splits the same string two ways here — + // compacted for the UI row, verbatim for the LLM history below — and + // this mirrors it: the card is a display, the model gets the output. const finalOutput = `${prefixText}${mainContent}`; - emit({ type: 'tool-result', id: callId, display: finalOutput }); + emit({ + type: 'tool-result', + id: callId, + display: compactToolResultDisplayForHistory(finalOutput), + }); emit({ type: 'tool-end', id: callId, From a39f6a2991acedd5684c629f6e27b8fab839ba13 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 15:12:01 +0800 Subject: [PATCH 09/51] fix(cli): keep OpenTUI's conversation inside the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app column was laid out at the height of its content, so a conversation longer than the terminal pushed the composer, the waiting row and the footer past the last row, where nothing repaints them — a second turn left the reconstructed frame byte-identical until the checkpoint timed out, and a dialog taller than what was left lost its closing border the same way. ink never sees this because its permanent output goes into the terminal's own scrollback; the library's split-footer equivalent bounds the render tree to the footer band and feeds the region above from captured stdout, so it cannot carry transcript items authored in the tree. Everything that flows — banner, transcript, the two notice rows — now lives in a scroll region bounded by the terminal, pinned to its last row until the user scrolls away from the bottom, while the rows below it are locked against shrinking. Without that lock the layout spreads the region's content height over the column and squeezes the composer's three border rows into one row painted three times. The overflow arm then matches ink row for row, differing only by the scrollbar indicator, and the wheel was measured on that arm: twenty notches move the region back twenty rows with the chrome unmoved, sixty the other way let the anchor take the tail again. Two divergences this document had recorded as deliberate — a persistent banner, and a dialog that did not reflow the conversation — close as a side effect. --- .../2026-09-10-opentui-parity-defect-sweep.md | 131 +++++++-- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 87 +++++- .../src/ui/opentui/opentui-app-shell.test.tsx | 81 +++++- .../cli/src/ui/opentui/opentui-app-shell.tsx | 265 ++++++++++-------- .../cli/src/ui/opentui/start-opentui-ui.tsx | 2 +- 5 files changed, 407 insertions(+), 159 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 16b42a216e2..ea6d6035e5b 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,7 +57,7 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Twenty-one scenarios cover boot, a +rather than at the same wall-clock moment. Twenty-two scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear @@ -65,18 +65,26 @@ and exit, a long hold that cycles the loading phrases, a to-do card, the question dialog on its own, the same dialog across three questions with a multi-select and a typed answer, the release of a parked confirmation when the approval mode changes, the approval of a gated server at startup, a tool call -whose arguments are long enough to be capped, and a control arm that repeats -that call with the arguments row switched off. +whose arguments are long enough to be capped, a control arm that repeats that +call with the arguments row switched off, and an answer long enough to overflow +the terminal. Three properties of the comparison matter for reading the results. -**Vertical anchoring is not comparable.** This renderer anchors the composer to -the bottom of the terminal; ink places it under the conversation. Every -absolute row index therefore differs by construction, and a diff that aligns on -rows reports the anchoring choice on every capture instead of the defect under -test. The comparison strips blank rows and diffs the two resulting sequences -with a longest-common-subsequence pass, which is invariant to where the block -sits and sensitive to what it contains. +**Vertical anchoring is a property of the reconstruction, not of the two +screens.** This renderer paints a window the size of the terminal, with the tail +of the conversation above its input line. ink's live area occupies the same place +in a real terminal, but the harness rebuilds ink's screen from what ink wrote, so +the block lands wherever ink's own repaints put it: under a tall dialog that is +rows 6 to 25 with the rest of the window blank, while this renderer sets the same +dialog flush against the last row. Absolute row indices therefore still do not +line up across the two legs in general, and a diff that aligns on rows would +report the reconstruction instead of the defect under test. The comparison strips +blank rows and diffs the two resulting sequences with a longest-common-subsequence +pass, which is invariant to where the block sits and sensitive to what it +contains. Where the content fills the window exactly the sequences do coincide, +and the overflow arm of Decision 26 is compared row for row rather than as a +sequence. **A divergence needs a control arm before it is attributed.** The OpenTUI leg runs under a different runtime than the ink leg, so any divergence is a @@ -710,20 +718,97 @@ act on. The flag now comes from the first pending id, and a test asserts that a second pending call gets none — checked by removing the gate, which fails that test with both arrows on screen. +## Decision 26 — the conversation gets the viewport, the chrome keeps its rows + +The app laid its own column out at the height of its content, so every row the +conversation needed cost the input line a row of visibility. Reproduced at a +hundred columns by thirty with a single answer of forty-nine lines: the banner +held rows 1 to 6, the answer's opening line landed on row 15 and the fifteen +below it filled the terminal to row 30, while the composer, the loading +indicator and the footer were laid out past the last row and never painted. A +second turn then proved the consequence rather than describing it — the script +typed into the frame and nothing in it moved: the capture taken after that +typing is byte-identical to the two before it, and the turn's marker never +reached the byte stream, so the scenario's checkpoint timed out. The same +mechanism clipped a dialog: at a hundred by forty the commands help page is +thirty rows tall, and the frame that held it ended on its own footer hint with +the closing border below the terminal. + +ink is immune to this without solving it. Its permanent output goes into the +terminal's own scrollback and the terminal scrolls it; the live area holds only +the current tail. The library has the equivalent — a screen mode that splits the +terminal into a scrollback region and a fixed footer — and the reason it was set +aside is in that mode's own geometry: the render tree is bounded to the footer +band and the region above it is fed by captured stdout, so adopting it would +mean rebuilding every transcript item outside of React. The accepted +trade-offs of the migration itself had already recorded dropping that mode in +favour of a single scroll region; the region was simply never connected. This +closes a hole in the documented design rather than inventing a scheme. + +Four properties were needed, and none of them reads off the library's naming. +The root column is bounded by the terminal height, since a flex child's height +is otherwise its content's. Everything that flows — banner, transcript, the two +notice rows — moved into a scroll region that grows and shrinks with what is +left. That region carries a sticky bottom anchor, so it shows the tail and +re-engages after each layout until the user scrolls away from the bottom; the +anchor needs both of the library's two props, one of which is inert on its own, +and that is also why an explicit scroll to the bottom on every update was +rejected — it measures identically in a still frame and fights the wheel the +moment the user touches it. And the rows below the region, every dialog along +with the composer and the footer, are locked against shrinking: without that +lock the layout distributes the region's content height across the whole column +and squeezes the composer's three border rows into one row painted three times, +because a text row cannot shrink below its own content and overwrites its +neighbour instead of clipping. Each property was established on a synthetic +fixture at the same size before it reached the app, and the shell tests now pin +all four — verified by removing them one at a time, which fails one test each. + +On a machine the overflow arm is an exact match: all three of its frames agree +with ink's row for row, from the answer's last line down to the footer's mode +row, and differ in one character — the scrollbar indicator this renderer +draws at the right edge. It stays on purpose. It is the only cue that anything +above the fold is reachable, and the dialogs already carry one. + +The wheel was then measured on the same arm rather than assumed, since the +decision to let the user scroll away from the anchor rests on it. Twenty injected +notches moved the region back twenty rows, from the twenty-fifth of the answer's +numbered lines back to the fifth, with the composer and the footer not moving a +row; sixty notches the other way let the anchor take the tail again. That is the +behaviour a per-frame scroll call would have destroyed and a keyboard binding has +to match. + +Two divergences this document recorded as deliberate close as a side effect, and +both are confirmed on frames. The banner is no longer persistent; it scrolls out +of the region as the conversation grows, where ink commits it to scrollback. And +a dialog now reflows the conversation: under the thirty-row help page the region +shrank to ten rows, giving up the banner's first two, while the dialog itself +fits entire. A region squeezed to no rows at all by a dialog as tall as the +terminal is that same behaviour and not a new defect, since ink pushes those +rows out of the viewport too. What is not here is scrolling the region with the +keyboard. + ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for twenty-one scenarios at 100×40 and, for the narrow + reconstructed screen, for twenty-two scenarios at 100×40 and, for the narrow and resize scenarios, at 60×24. Both legs from one bundle and one set of boot arguments. - **Colour was not verified.** The reconstruction is text. Several rows are known to differ only in which theme token they use, and a styled capture exists that could settle it but was not read. -- **The composer's one-row gap is source-grounded only.** Under bottom - anchoring it is not separable from the anchoring itself in a frame - comparison. +- **The scrollbar costs the transcript no column.** Measured at a hundred + columns with two answer rows of 95 and 96 characters: this renderer fills 99 + columns before it breaks a row where ink fills 98, so the one-column gap sits + in the same direction as the wrap divergence already on the list rather than + being a column the region takes away — and the row-length distribution over + the whole corpus is identical on both sides of this change. +- **The composer's one-row gap is source-grounded, and frame-grounded only where + the two frames line up.** On the overflow arm of Decision 26 the blank row + above the composer is confirmed row for row against ink, because there the + content fills the window on both legs. Elsewhere it is not separable in a frame + comparison from where the reconstruction puts the block. - **The quit warning's queued segment is unit-tested only.** No scenario queues a message and then arms the warning. Nor does any scenario produce a durable queue at all: ink's badge survives only as a single transient of its @@ -749,12 +834,13 @@ What was verified, and how far the verification reaches: which depends on how items happen to be grouped across renders rather than on any layout rule. Which of the two produces which row was not traced, and matching either would mean hardcoding a write-batching artifact. -- **Two structural divergences are recorded and deliberately not fixed here.** - The banner is persistent in this renderer and scrolls out of the viewport in - ink, which is a product decision inherited from the restore work rather than - a defect introduced by it. The same overlay model has a second symptom: a - dialog here does not reflow the conversation, so rows that ink pushes out of - the viewport when a tall dialog opens stay visible underneath it here. +- **Two structural divergences were recorded here as deliberate and have since + been closed.** The banner was persistent in this renderer while ink scrolls it + out of the viewport, and a dialog did not reflow the conversation, so rows that + ink pushes out when a tall dialog opens stayed visible underneath it here. Both + were inherited from the restore work rather than introduced by it. Decision 26 + removed the overlay model they both came from, and its frames are the evidence + for the two closures. - **One divergence is intentional.** The update check reports a skipped check with its reason here, while ink reports a failed automatic update. Both renderers share the emission path; ink's subscriber is registered after the @@ -844,6 +930,11 @@ What was verified, and how far the verification reaches: ## Follow-ups +- The scroll region answers the mouse wheel and no key. What scrolls ink's + conversation is the terminal's own scrollback, and the alternate screen this + renderer takes over has none, so the page keys that work there reach nothing + here and a row above the fold comes back only by wheel. Binding those keys to + the region's own offset is its own change. - The `@` completion here asks only the file index. ink also completes sessions, MCP resources and extensions, and draws a category bar to switch between them. That is a feature gap rather than a parity defect and belongs diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 02e1ef4f7c9..774293b2d74 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,19 +41,24 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。二十一个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。二十二个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、审批模式变化时被 -拦下的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用, -以及把同一次调用在关掉参数行的情况下重跑的对照臂。 +拦下的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用、 +把同一次调用在关掉参数行的情况下重跑的对照臂,以及一份长到溢出终端的回答。 比较方式有三个性质决定了结果该怎么读。 -**垂直锚定不可比。** 本渲染器把 composer 锚在终端底部,ink 把它放在会话之下。 -因此每个绝对行号在构造上就不同,一个按行号对齐的 diff 会在每个检查点都报告锚 -定选择,而不是被测缺陷。比较会先去掉空行,再用最长公共子序列对两个序列做 -diff —— 这对块落在哪里不敏感,对块里有什么敏感。 +**垂直锚定是重建方式的属性,不是两块屏幕的属性。** 本渲染器画的是一个终端大 +小的窗口,会话尾部落在输入行之上。ink 的 live 区在真实终端里占据同一个位置, +但 harness 是依据 ink 写出了什么来重建它的屏幕的,于是那块内容落在哪里取决于 +ink 自己的重绘:在一个高对话框之下,重建结果里对话框占第 6 到 25 行、窗口其余 +部分留空,而本渲染器会把同一个对话框贴着最后一行放。因此绝对行号在两腿之间通常 +仍然对不上,按行号对齐的 diff 会报告重建方式而不是被测缺陷。比较会先去掉空行, +再用最长公共子序列对两个序列做 diff —— 这对块落在哪里不敏感,对块里有什么敏 +感。当内容刚好填满窗口时两个序列确实重合:决定 26 的溢出臂就是逐行比较而不是 +按序列比较的。 **归因之前需要对照臂。** OpenTUI 腿跑在与 ink 腿不同的运行时上,所以任何分叉 都同时是「渲染器干的」和「运行时干的」两个候选。在这个歧义要紧的地方,加了第 @@ -523,17 +528,66 @@ transcript —— 而这一行仍然哪儿都不画,因为没有任何东西 用户无法操作的调用。这个标志现在取自第一个待审批的 id,并且有一个测试断言第二个待审批 的调用拿不到箭头 —— 验证方式是把这道门禁去掉,那会让该测试失败并在屏幕上留下两个箭头。 +## 决定 26 —— 会话拿到视口,chrome 保住自己的行 + +应用按自己内容的高度排布那一列,所以会话每多要一行,输入行就少一行可见性。在一百列 +乘三十行的终端上用一个四十九行的回答复现:banner 占住第 1 到 6 行,回答的起始行落在 +第 15 行、它下面的十五行把终端填到第 30 行,而 composer、loading 指示器与 footer 被 +排到了最后一行之外,完全没有绘制。第二轮对话随后证明了这个后果而不是描述它 —— 脚本往帧 +里打字,帧里没有任何东西移动:打字之后抓的那一帧与它之前的两帧逐字节相同,而那一轮的标记 +从未进入字节流,于是场景的检查点超时。同一套机制也会裁掉对话框:在一百乘四十下命令帮助 +页有三十行高,而承载它的那一帧结束在自己的 footer 提示行上,收口的那道边框掉到了终端之 +下。 + +ink 对此免疫,却并没有解决它。ink 的永久输出进入终端自己的回滚缓冲区,由终端负责滚动; +live 区只保留当前的尾部。库里有等价的东西 —— 把终端切成回滚区域与固定 footer 的屏幕 +模式 —— 而它被搁置的原因就写在这个模式自己的几何计算里:渲染树被界定在 footer 那一条 +带上,上方区域由捕获到的 stdout 喂进去,所以采用它意味着把每一条 transcript 条目重建 +到 React 之外。迁移自身那份「已接受的取舍」早就记录了放弃那个模式、改用单一滚动区; +缺的只是把那个区域接上。这补的是已经写下的设计里的一个洞,不是另起一套方案。 + +需要四个属性,而它们没有一个能从库的命名里读出来。根列被终端高度界定,因为 flex 子元 +素否则就拿自己内容的高度当高度。所有会流动的东西 —— banner、transcript、两行通知 —— +被移进一个随剩余空间伸缩的滚动区。该区域带着一个粘性的底部锚,所以它显示尾部,并在每 +次布局后重新咬合,直到用户从底部滚开;这个锚需要同时给出库的那两个 prop,其中单独一个 +是无效的。这也是为什么「每次更新时显式滚到底部」被否决 —— 它在静止帧里量出来一模一样的 +效果,而用户一碰滚轮它就和滚轮作对。区域之下那些行 —— 每个对话框连同 composer 与 +footer —— 被锁住不被压缩:没有这把锁,布局会把区域的内容高度摊派到整列上,把 composer +的三行边框挤成同一行画三遍,因为一行文本不能收缩得比它自己的内容还小,它宁可覆盖邻居也 +不裁剪。每个属性在进入应用之前都先在同等尺寸的合成夹具上确立,shell 的测试如今把这四个 +都钉住 —— 验证方式是把它们逐个去掉,每去一个就让一个测试失败。 + +在真机上,溢出臂是一个精确匹配:它的三帧全部与 ink 逐行一致,从回答的最后一行一直往下到 +footer 的模式行,差异只有一个字符 —— 本渲染器画在最右列的那格滚动条指示。它是有意留下 +的:它是「折叠线以上还有可达内容」的唯一线索,而对话框本来就带着一个。 + +滚轮随后在同一臂上被测量,而不是被假设,因为「让用户从锚上滚开」这个决定正建立在它之 +上。注入二十格把区域往回推了二十行,从回答那串编号行里的第二十五行退回第五行,而 +composer 与 footer 一行都没有动;反向六十格让锚重新咬住了尾部。那正是「每帧一次滚动调 +用」会毁掉、而键盘绑定必须对上的行为。 + +本文曾记为刻意的两个分叉作为副产物关闭了,两者都有帧为证。banner 不再常驻:它随会话 +增长滚出区域,而 ink 是把它提交进回滚缓冲区。对话框现在也会重排会话:在三十行的帮助页 +之下区域缩到了十行,让出了 banner 的最上两行,而对话框本身完整装得下。一个和终端一样 +高的对话框把区域压到零行是同一个行为,不是新的缺陷,因为 ink 也把那些行推出视口。这里 +没有的是用键盘滚动这个区域。 + ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十一个场景, +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十二个场景, 100×40;窄终端与 resize 场景另加 60×24。两腿出自同一个 bundle 与同一组启动 参数。 - **颜色未验证。** 重建结果是文本。已知有若干行只在所用主题 token 上不同,而 存在一份带样式的抓取可以定这件事,但没有读。 -- **composer 的一行间距仅有源码依据。** 在底部锚定下,它在帧比较里无法与锚定 - 本身分离。 +- **滚动条没有从转录里拿走一列。** 在一百列下用两条分别为 95 与 96 字符的回答行 + 测量:本渲染器折满 99 列才断行,ink 折满 98 列,所以这一列的差与清单上那条既存的 + 换行分歧同向,而不是滚动区域拿走了一列 —— 且整份语料的行长分布在这次改动前后完全 + 一致。 +- **composer 的一行间距以源码为据,只在两帧确实对齐的地方有帧为据。** 在决定 + 26 的溢出臂上,composer 之上那一空行与 ink 逐行吻合,因为那里两腿的内容都刚好 + 填满窗口。在别处,它在帧比较里无法与重建把块放在哪里这件事分离。 - **退出警告的排队段仅有单元测试覆盖。** 没有场景会先排队一条消息再武装警告。也 没有任何场景会产生一个持久的队列:ink 的徽标只在它自己的提交路径里作为一帧瞬态 存在于裸流中,两腿都从未进入任何一个抓取帧。 @@ -550,10 +604,11 @@ transcript —— 而这一行仍然哪儿都不画,因为没有任何东西 因此空行会落在批次边界所在之处,而这取决于条目在多次渲染之间碰巧如何分组,而不 取决于任何布局规则。这两者中哪一个产生了哪一行没有被追查,而对齐其中任何一个都 意味着把写入分批的伪影硬编码下来。 -- **两处结构性分叉被记录并刻意不在本文内修。** banner 在本渲染器里常驻、在 - ink 里会滚出视口,这是从恢复工作继承下来的产品决策,而不是它引入的缺陷。同一 - 套浮层模型还有第二个症状:这里的对话框不会重排会话,所以当 ink 打开一个高对话 - 框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。 +- **两处结构性分叉曾被记录为刻意保留,如今已经关闭。** banner 在本渲染器里 + 常驻、在 ink 里会滚出视口,这是从恢复工作继承下来的产品决策,而不是它引入的 + 缺陷。同一套浮层模型还有第二个症状:对话框不会重排会话,所以当 ink 打开一个高 + 对话框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。决定 26 拆掉了 + 这两条共同来源的那套浮层模型,它的帧就是这两处关闭的证据。 - **一处分叉是有意的。** 更新检查在这里报告「检查被跳过」及其原因,而 ink 报告 「自动更新失败」。两个渲染器共用发出路径;ink 的订阅者在后台任务发出之后才 注册,所以 ink 丢掉了那条软警告,转而显示之后那条硬失败。为了对齐一个订阅竞 @@ -615,6 +670,10 @@ transcript —— 而这一行仍然哪儿都不画,因为没有任何东西 ## 后续工作 +- 滚动区响应鼠标滚轮,却不响应任何键。滚动 ink 那份会话靠的是终端自己的回滚缓冲 + 区,而本渲染器接管的备用屏幕没有这样东西,所以在那边管用的翻页键在这里什么都触及 + 不到,折叠线以上的一行只能靠滚轮回来。把这些键绑到区域自己的滚动偏移上是独立的改 + 动。 - 这里的 `@` 补全只问文件索引。ink 还会补全会话、MCP 资源与扩展,并画一个分类 栏用于切换。那是功能缺口而不是一致性缺陷,应放进独立的改动。 - 回合中队列在这里被计数,但从不被显示。它的长度会到达 footer 徽标,而 composer diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index 1c07c4e91ba..060e28abd8c 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -102,16 +102,34 @@ const mocks = vi.hoisted(() => { ) => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; + // Layout props are what the structural tests read, and the DOM nodes this + // mock maps to would drop them: keep the primitives as a JSON attribute. + const captured = JSON.stringify( + Object.fromEntries( + Object.entries(props ?? {}).filter( + ([, v]) => + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean', + ), + ), + ); if (type === 'box' || type === 'text') { return React.createElement( type === 'box' ? 'div' : 'span', - key === undefined ? null : { key }, + { + ...(key === undefined ? {} : { key }), + 'data-p': captured, + }, children, ); } return React.createElement( type as React.ElementType, - config as Record, + { + ...(config as Record), + 'data-p': captured, + }, children, ); }; @@ -1898,3 +1916,62 @@ describe('OpenTuiApp approval-mode cycling (F-2)', () => { expect(mocks.state.emitAutoModeEntryNotices).toHaveBeenCalledTimes(1); }); }); + +describe('OpenTuiApp transcript scroll region', () => { + const layoutOf = ( + node: Element | null | undefined, + ): Record => + JSON.parse(node?.getAttribute('data-p') ?? '{}') as Record; + + const readLayout = () => { + const region = document.querySelector('scrollbox'); + const column = region?.parentElement; + return { region, column, chrome: column?.lastElementChild }; + }; + + const renderWithTranscript = async () => { + renderApp({ + renderMain: () =>
TRANSCRIPT-ROWS
, + }); + await settle(); + }; + + it('bounds the app column by the terminal and anchors the transcript region to its tail', async () => { + // ink needs no viewport maths: Static writes to the terminal's own + // scrollback, so the dynamic area always sits at the bottom row. Alt-screen + // OpenTUI has to say the same thing in layout. Without the bounded column a + // conversation longer than the terminal pushed the composer, the waiting row + // and the footer past the last row, where nothing repaints them. + await renderWithTranscript(); + const { region, column, chrome } = readLayout(); + expect(region).not.toBeNull(); + expect(layoutOf(column)).toMatchObject({ height: 40 }); + expect(layoutOf(region)).toMatchObject({ + flexGrow: 1, + flexShrink: 1, + minHeight: 0, + stickyScroll: true, + stickyStart: 'bottom', + }); + // The split is the fix: the conversation scrolls, the chrome does not. + expect(region?.textContent).toContain('TRANSCRIPT-ROWS'); + expect(chrome?.textContent).toContain('input-prompt'); + // And the chrome has to hold its own rows. Measured with the shrink unlocked, + // the scroll region took the composer's border rows and painted all three of + // them over one terminal row. + expect(layoutOf(chrome)).toMatchObject({ flexShrink: 0 }); + }); + + it('keeps the dialog out of the scroll region', async () => { + await renderWithTranscript(); + mocks.state.handleResult = { + kind: 'open_dialog', + request: { dialog: 'theme' }, + } satisfies OpenTuiDispatchOutcome; + await submit('/theme'); + const { region, chrome } = readLayout(); + expect(screen.getByText('dialog:theme')).toBeTruthy(); + expect(region?.textContent).not.toContain('dialog:theme'); + expect(chrome?.textContent).toContain('dialog:theme'); + }); +}); diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index 446668a0e67..4d9b6272117 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -920,138 +920,159 @@ export function OpenTuiApp(props: OpenTuiAppProps) { recordForExitEcho onError={(error) => onRenderError?.(error)} > - - - {renderMain ? renderMain() : null} - {!dialog && - !activeModal && - !activeToolCall && - !mcpApproval.isMcpApprovalDialogOpen && - updateNotice ? ( - {updateNotice} - ) : null} - {noticeText ? {noticeText} : null} - {mcpApproval.isMcpApprovalDialogOpen && - mcpApproval.currentMcpApproval ? ( - // ink ranks the gated-server approval above both the shell and the - // tool confirmation, so it takes the slot outright. - - ) : activeToolCall ? ( - - onToolCallSettled?.(activeToolCall.callId)} + + {/* Everything that flows lives here, and it is the alt-screen stand-in + for ink's : the column is bounded by the terminal, so the + region keeps only the tail of the conversation, pinned to its last + row until the user scrolls away from the bottom. */} + + + {renderMain ? renderMain() : null} + {!dialog && + !activeModal && + !activeToolCall && + !mcpApproval.isMcpApprovalDialogOpen && + updateNotice ? ( + {updateNotice} + ) : null} + {noticeText ? {noticeText} : null} + + {/* The chrome below the scroll region must be locked against shrinking: + Yoga otherwise spreads the scroll region's content height over the + column and squeezes the composer's three border rows into one row + painted three times. */} + + {mcpApproval.isMcpApprovalDialogOpen && + mcpApproval.currentMcpApproval ? ( + // ink ranks the gated-server approval above both the shell and the + // tool confirmation, so it takes the slot outright. + - {/* ink's Composer marginTop: one blank between the confirmation's + ) : activeToolCall ? ( + + onToolCallSettled?.(activeToolCall.callId)} + /> + {/* ink's Composer marginTop: one blank between the confirmation's own bottom padding and the waiting row. */} - - + + + + + ) : activeModal ? ( + activeModal.kind === 'shell' ? ( + + closeShellModal(activeModal, resolution) + } + /> + ) : ( + + closeActionModal(activeModal, confirmed) + } + /> + ) + ) : dialog ? ( + // ink's layout wraps every popup in a two-column margin and caps its + // width, so a dialog's border runs from column 2 to column 97 instead + // of spanning the terminal. The confirmations stay outside: their + // body reads the terminal width to estimate line wrapping. + // + // The keys on this branch and the composer's are load-bearing: both + // are a `` in the same slot, so without them React reuses one + // instance and diffs props — and @opentui's margin/width setters + // ignore the `null` its reconciler passes for a removed prop, leaving + // the previous branch's layout stuck on the node. + + setDialog(null)} + notify={notify} + fillInput={fillComposer} + onSelectSetting={handleSelectSetting} + onApprovalModeChanged={adoptApprovalMode} + availableTerminalHeight={props.availableTerminalHeight} + /> - - ) : activeModal ? ( - activeModal.kind === 'shell' ? ( - - closeShellModal(activeModal, resolution) - } - /> ) : ( - - closeActionModal(activeModal, confirmed) - } - /> - ) - ) : dialog ? ( - // ink's layout wraps every popup in a two-column margin and caps its - // width, so a dialog's border runs from column 2 to column 97 instead - // of spanning the terminal. The confirmations stay outside: their - // body reads the terminal width to estimate line wrapping. - // - // The keys on this branch and the composer's are load-bearing: both - // are a `` in the same slot, so without them React reuses one - // instance and diffs props — and @opentui's margin/width setters - // ignore the `null` its reconciler passes for a removed prop, leaving - // the previous branch's layout stuck on the node. - - + + { + void onSubmit(text, imagePaths); + }} + userMessages={userMessages} + config={config} + focus + streaming={streaming} + onInterrupt={onInterrupt} + approvalMode={currentApprovalMode} + queueLength={queueLength} + onPopQueue={onPopQueue} + composerHandle={props.composerHandle} + promptSuggestion={promptSuggestion} + onPromptSuggestionDismiss={onPromptSuggestionDismiss} + onPromptSuggestionAbort={onPromptSuggestionAbort} + shellModeActive={shellModeActive} + onToggleShellMode={toggleShellMode} + onSuggestionsVisibilityChange={onSuggestionsVisibilityChange} + onCycleApprovalMode={cycleApprovalMode} + /> + + )} + {/* An armed quit warning forces the footer to mount: a dialog unmounts + the composer, so nothing intercepts Ctrl+C and the app-level guard + still arms — hiding the footer here would drop the only row telling + the user that a second press exits. With the warning set the footer + returns just that row, so nothing else appears. */} + {exitHint || + (!dialog && !activeModal && !activeToolCall && !showSuggestions) ? ( + setDialog(null)} - notify={notify} - fillInput={fillComposer} - onSelectSetting={handleSelectSetting} - onApprovalModeChanged={adoptApprovalMode} - availableTerminalHeight={props.availableTerminalHeight} - /> - - ) : ( - - - { - void onSubmit(text, imagePaths); - }} - userMessages={userMessages} - config={config} - focus - streaming={streaming} - onInterrupt={onInterrupt} approvalMode={currentApprovalMode} queueLength={queueLength} - onPopQueue={onPopQueue} - composerHandle={props.composerHandle} - promptSuggestion={promptSuggestion} - onPromptSuggestionDismiss={onPromptSuggestionDismiss} - onPromptSuggestionAbort={onPromptSuggestionAbort} + sessionName={host.sessionName} shellModeActive={shellModeActive} - onToggleShellMode={toggleShellMode} - onSuggestionsVisibilityChange={onSuggestionsVisibilityChange} - onCycleApprovalMode={cycleApprovalMode} + exitHint={exitHint} /> - - )} - {/* An armed quit warning forces the footer to mount: a dialog unmounts - the composer, so nothing intercepts Ctrl+C and the app-level guard - still arms — hiding the footer here would drop the only row telling - the user that a second press exits. With the warning set the footer - returns just that row, so nothing else appears. */} - {exitHint || - (!dialog && !activeModal && !activeToolCall && !showSuggestions) ? ( - - ) : null} + ) : null} + ); diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx index 6bc298d58f7..adf813dc7b0 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -238,7 +238,7 @@ function OpenTuiEntryApp({ // --- seams handed to the shell --------------------------------------------- const renderMain = useCallback( () => ( - + {/* The transcript box carries two columns of margin on each side, so its content budget is 4 short of the terminal width. */} Date: Mon, 14 Sep 2026 15:24:20 +0800 Subject: [PATCH 10/51] docs: record the shell-output compaction this branch already ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up list still described the shell card as keeping its whole output on screen, although the compaction landed on this branch, and it claimed ink discards shell progress — ink renders the streamed output as a pending item, which is what the compacted snapshot beside it is for. Both languages now carry the decision instead, with the two unit arms that pin it and the mutation showing they fail without the compaction. --- .../2026-09-10-opentui-parity-defect-sweep.md | 26 ++++++++++++++----- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 18 ++++++++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index ea6d6035e5b..f40e69a12ef 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -787,6 +787,26 @@ terminal is that same behaviour and not a new defect, since ink pushes those rows out of the viewport too. What is not here is scrolling the region with the keyboard. +## Decision 27 — a long shell card is compacted for the screen, verbatim for the model + +ink writes a shell command's output out twice. The transcript row goes through +the shared history-retention compaction; the string handed to the model stays +verbatim. This renderer put the raw accumulation on the card at both of its +write sites — the throttled streaming snapshot and the final result — so one +long-running command pinned its entire output in the transcript for the rest of +the session. On this branch that stopped being a cosmetic difference: Decision +26 bounds the region by the terminal, so rows that ink would have compacted now +push everything earlier above the fold. + +Both sites call the same helper ink's history fold uses, so the retention limit +has one owner rather than a copy that can drift. No scenario in the harness +produces output that long, so this is pinned by unit tests rather than by a +frame: each of the two write sites asserts the card display stays within the +retained budget while the history write still receives the whole string. +Removing the compaction fails exactly those two tests and no other, with +over-length displays of forty-one thousand and thirty-seven thousand characters +named in the failing assertions. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -954,12 +974,6 @@ What was verified, and how far the verification reaches: The diagnostics are not lost, but they are unreachable here because this renderer never binds the library's console toggle. Whether to expose that console is an open question. -- The shell card keeps the whole output where ink shortens it. Both renderers - write the same raw string to the model's history, but ink compacts the copy - it puts on screen once that copy passes a retention limit. No scenario here - produces output that long, so the gap is reasoned rather than observed, and - closing it also raises whether the intermediate streaming snapshots should - exist at all — ink discards shell progress instead of showing it. - The help dialog's reserved-row constant does not describe the rows actually observed on screen, and the window height this renderer shows below a 42-row terminal is bounded rather than matched. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 774293b2d74..c8545794aa5 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -572,6 +572,20 @@ composer 与 footer 一行都没有动;反向六十格让锚重新咬住了尾 高的对话框把区域压到零行是同一个行为,不是新的缺陷,因为 ink 也把那些行推出视口。这里 没有的是用键盘滚动这个区域。 +## 决定 27 —— 长的 shell 卡片为屏幕做压缩,为模型保留原文 + +ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过共享的历史保留压缩;交给 +模型的那一份保持原文。本渲染器在它的两个写入点上都把裸的累加结果放上卡片 —— 节流 +后的流式快照与最终结果事件 —— 于是一条长跑命令会把它的完整输出在整个会话剩下的时 +间里钉在转录中。在本分支上这不再只是观感问题:决定 26 用终端高度界定了区域,因此那 +些 ink 会压缩的行会把更早的内容推到折叠线以上。 + +两个写入点调用的都是 ink 的历史折叠所用的同一个 helper,所以保留上限只有一个所有者, +而不是一份会各自漂移的副本。工装里没有任何场景产出那么长的输出,因此这件事由单元测 +试钉住,而不是由某一帧:两个写入点各自断言卡片显示保持在保留额度之内,而写入历史的 +那一份仍然拿到完整字符串。移除压缩恰好让这两个测试失败、其余测试不受影响,失败的断 +言里点名了四万一千与三万七千这两个超长的显示长度。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -686,10 +700,6 @@ composer 与 footer 一行都没有动;反向六十格让锚重新咬住了尾 搜索二进制在测试机上只是一个 shell 别名,所以派生查找会失败。诊断并没有丢失, 但在这里不可达,因为本渲染器从未绑定该库的控制台唤出键。是否要暴露那个控制台是 一个待定问题。 -- shell 卡片保留完整输出,而 ink 会把它缩短。两个渲染器写给模型历史的是同一个 - 裸字符串,但 ink 在放到屏幕上的那一份超过保留上限时会做压缩。这里没有任何场景 - 会产出那么长的输出,所以这个差距是推导出来的而不是实测到的;补上它还会牵出中 - 间那些流式快照是否应该存在的问题 —— ink 是直接丢弃 shell 进度而不显示的。 - help 对话框的保留行数常量并没有描述屏幕上实际观察到的行,且在低于 42 行的终 端上本渲染器显示的窗口高度是被限制的,而不是被对齐的。 - loading 指示器没有子代理 token 汇总,也没有 tokens-per-second 段,两者 ink From 7bee45ae9d209928dff9379f5cb46802eff7174d Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 16:20:22 +0800 Subject: [PATCH 11/51] docs: attribute the startup update-check notice to the runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control arm — ink under the runtime the OpenTUI leg uses — reproduces the notice on ink, so its absence from ink's frames is not a renderer choice. The explanation this document recorded instead (a subscription race, with ink showing a later hard failure) is retracted: no arm showed a hard failure. --- .../2026-09-10-opentui-parity-defect-sweep.md | 17 +++++++++++------ ...6-09-10-opentui-parity-defect-sweep.zh-CN.md | 12 ++++++++---- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index f40e69a12ef..01a7ac1c853 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -861,12 +861,17 @@ What was verified, and how far the verification reaches: were inherited from the restore work rather than introduced by it. Decision 26 removed the overlay model they both came from, and its frames are the evidence for the two closures. -- **One divergence is intentional.** The update check reports a skipped check - with its reason here, while ink reports a failed automatic update. Both - renderers share the emission path; ink's subscriber is registered after the - background task emits, so ink loses the soft warning and shows the later - hard failure instead. Removing a legitimate warning to match a subscription - race is not what aligning to ink means, so the warning stays. +- **The startup update-check notice belongs to the runtime, and what this + document said about it before was wrong.** The notice appeared on every + OpenTUI leg's frames and on no ink leg's — a divergence that belongs to either + the renderer or the runtime until a control arm says which. One ran: ink under + the same runtime as the OpenTUI leg reproduces the notice, so it leaves the + renderer's column. The same run falsifies the explanation recorded here + originally, that ink loses the soft warning to a subscription race and shows a + later hard update failure instead; no arm of any run shows a hard failure at + all. Why the check's own outcome differs between the two runtimes is not + something this sweep chased. The harness now suppresses the update check on + both legs, so the notice contributes no rows to the frame comparison. - **One cosmetic divergence is recorded, not fixed.** The two renderers break a long row at different columns. ink's default wrap is a word wrap that hard breaks whatever still overflows and trims nothing, so a row fills to the edge diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index c8545794aa5..98a6936ccce 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -623,10 +623,14 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 缺陷。同一套浮层模型还有第二个症状:对话框不会重排会话,所以当 ink 打开一个高 对话框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。决定 26 拆掉了 这两条共同来源的那套浮层模型,它的帧就是这两处关闭的证据。 -- **一处分叉是有意的。** 更新检查在这里报告「检查被跳过」及其原因,而 ink 报告 - 「自动更新失败」。两个渲染器共用发出路径;ink 的订阅者在后台任务发出之后才 - 注册,所以 ink 丢掉了那条软警告,转而显示之后那条硬失败。为了对齐一个订阅竞 - 态而删掉一条合法警告,不是「对齐 ink」的意思,所以警告保留。 +- **启动时的更新检查通知属于运行时,而本文档之前关于它的说法是错的。** 这条通知 + 出现在每一条 OpenTUI 腿的帧上,却从未出现在任何 ink 腿上 —— 在对照臂说明它归属 + 哪一侧之前,这种分叉既可能来自渲染器、也可能来自运行时。对照臂跑了:ink 在与 + OpenTUI 腿同一个运行时下复现了这条通知,所以它从渲染器那一栏移出。同一次运行也 + 推翻了此处原先记录的解释 —— 即 ink 把一条软警告输给了订阅竞态、转而显示之后那条 + 硬更新失败;任何一臂的任何一次运行都没有出现过硬失败。这个检查自己的结果为何在 + 两个运行时之间不同,不是这轮排查追的东西。工装现在于两条腿上都抑制更新检查,所以 + 这条通知不再向帧对比贡献任何行。 - **一处外观分叉被记录,未修。** 两个渲染器在不同列上断开一行长内容。ink 的默认 换行是按词换行、但把仍然放不下的部分强行截断且不做裁剪,所以一行会一直填满到 边缘,续行从该条目自己的缩进处开始;本渲染器的 text 节点按词边界断开,而一条路径 From 277f06dc1bcd5005dc42bdb3467fd42cb492c8d0 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 17:19:26 +0800 Subject: [PATCH 12/51] fix(cli): freeze the waiting row's spinner while a confirmation is open ink's responding spinner animates only while a turn is responding and draws one static frame while a call is parked on a confirmation, so its waiting row emits no bytes at all until the user answers. This renderer's spinner owned an unconditional interval, so the row kept repainting: measured on a real machine at about twelve bytes a second against ink's zero, holding a different frame at every one-second sample. The acceptance harness decides a leg has settled by watching its output go quiet, so every parked leg of a confirmation scenario also burned the full sixty-second idle timeout. Port the gate, and move the frame it freezes on out of ink's literal into the spinner constants both renderers already read from one place. --- .../2026-09-10-opentui-parity-defect-sweep.md | 33 +++++++++++++++++++ ...09-10-opentui-parity-defect-sweep.zh-CN.md | 24 ++++++++++++++ .../src/ui/components/LoadingIndicator.tsx | 3 +- packages/cli/src/ui/constants.ts | 7 ++++ .../src/ui/opentui/opentui-footer.test.tsx | 30 +++++++++++++++++ .../cli/src/ui/opentui/opentui-footer.tsx | 23 +++++++++---- 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 01a7ac1c853..596963d0467 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -807,6 +807,39 @@ Removing the compaction fails exactly those two tests and no other, with over-length displays of forty-one thousand and thirty-seven thousand characters named in the failing assertions. +## Decision 28 — the waiting row freezes while the dialog is open + +While a call was parked on a confirmation, this renderer kept repainting the +waiting row and ink's did not. Measured rather than inferred: a probe replaying +the question-dialog scenario sampled the reconstructed screen once a second for +fifteen seconds with the dialog open, and the ink leg emitted zero bytes on +every sample while this one emitted about twelve, its row holding a different +frame each time. Twelve bytes a second is that renderer's own cadence — the +shared spinner interval is eighty milliseconds, and a repaint that changes one +cell costs roughly a byte. A leg with no dialog open emits nothing at all on +either renderer, so this was not the whole tree redrawing. + +The gate lives in ink's `RespondingSpinner`: it animates in the responding state +and otherwise draws the `nonRespondingDisplay` string it is handed, and +`LoadingIndicator` hands it one static frame while the state is waiting for +confirmation — no timer runs at all in that state. This renderer's spinner owned +an unconditional interval, so the row asked for attention while the user's +answer was the only thing that could stop it. The cost is not only visual: the +acceptance harness decides a leg has settled by watching its output go quiet, so +every parked leg of a confirmation scenario burned the full sixty-second idle +timeout (measured at 60,088 ms and 39,798 bytes on one leg of one scenario). +Checkpoints are declared by the scenario rather than timed, so no verdict was +invalidated — each parked leg simply paid the wait, and the frame comparison +counted every one of those rows as the spinner-frame difference this document +already records rather than as content. + +The gate is ported, and the frame it freezes on is shared rather than copied: it +joins the spinner constants both renderers already read from one place, which ink +had spelled out as a literal until now. Two tests cover the pair — the frame +advances while a turn is in flight, and holds that one frame while a call is +parked. Each is killed by its own mutation and by nothing else: removing the gate +fails the parked test, removing the interval fails the in-flight one. + ## Coverage boundary What was verified, and how far the verification reaches: diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 98a6936ccce..f649967df22 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -586,6 +586,30 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 那一份仍然拿到完整字符串。移除压缩恰好让这两个测试失败、其余测试不受影响,失败的断 言里点名了四万一千与三万七千这两个超长的显示长度。 +## 决定 28 —— 对话框开着时,等待行静止不动 + +当一个调用被确认框挂住时,本渲染器仍在持续重画那行等待提示,而 ink 不会。这是量出 +来的,不是推断的:一个探针复现了提问对话框场景,在对话框打开的情况下每秒重建一次屏 +幕、连续采十五秒;ink 腿每一次采样都是零字节,而本渲染器腿约十二字节,且每一次采样 +上那个格子都不是同一帧。十二字节每秒正是该渲染器自己的节奏 —— 共享的转圈间隔是八十 +毫秒,而只改动一个格子的重绘大约就是一个字节。两条渲染器在没有打开对话框时都一字节 +都不发,所以这不是整棵树在重画。 + +门禁在 ink 的 `RespondingSpinner` 里:它只在响应态动画,其余状态下画出自己被传入的 +`nonRespondingDisplay` 字符串,而 `LoadingIndicator` 在等待确认状态下传给它的就是某 +一个静止帧 —— 那个状态下根本没有任何定时器在跑。本渲染器的转圈组件持有一个无条件 +的 interval,于是在「只有用户的回答才能让它停下来」的时候这一行还在索取注意力。代 +价不只是观感:验收工装靠观察输出变静默来判定一条腿已经落定,于是确认类场景里每一条 +被挂住的腿都会吃满六十秒的空闲超时(某场景某一条腿实测 60,088 毫秒、39,798 字节)。 +检查点由场景声明而非按定时采样,所以没有任何判定因此失效 —— 只是每条被挂住的腿都付 +了这段等待,而帧对比把这些行里的每一行都算成本文档已经记录的那类 spinner 帧差异, +而不是内容差异。 + +门禁被移植过来了,而它停下时画的那一帧是共享的、不是抄的:它进了两个渲染器本来就从 +同一处读取的转圈常量,而 ink 此前把它写成了字面量。两条测试覆盖这一对 —— 回合在飞行 +中时帧会推进,调用被挂住时帧停在那一格。各自被自己的变异杀掉、且只杀掉自己:移除门禁 +只让「挂住」那条失败,移除 interval 只让「飞行中」那条失败。 + ## 覆盖边界 验证了什么,以及验证到哪一层: diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index cbdbec6f5c3..84243693d79 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -11,6 +11,7 @@ import { theme } from '../semantic-colors.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { RespondingSpinner } from './RespondingSpinner.js'; +import { WAITING_SPINNER_FRAME } from '../constants.js'; import { formatDuration, formatTokenCount } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useAnimationFrame } from '../hooks/useAnimationFrame.js'; @@ -129,7 +130,7 @@ export const LoadingIndicator: React.FC = ({ diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 5a617f3ab3e..f7fbfe2360e 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -67,3 +67,10 @@ export const SPINNER_FRAMES = [ ]; export const SPINNER_INTERVAL_MS = 80; + +/** + * The frame drawn while a call is parked on a confirmation: ink's + * `LoadingIndicator` passes it as `nonRespondingDisplay`, so both renderers stop + * animating and show this one instead. Shared so the value cannot drift. + */ +export const WAITING_SPINNER_FRAME = '⠏'; diff --git a/packages/cli/src/ui/opentui/opentui-footer.test.tsx b/packages/cli/src/ui/opentui/opentui-footer.test.tsx index 5baa3a6cfa2..c1799752ce0 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.test.tsx @@ -92,9 +92,19 @@ vi.mock('../../i18n/index.js', async (importOriginal) => { import { ApprovalMode } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core'; +import { + SPINNER_FRAMES, + SPINNER_INTERVAL_MS, + WAITING_SPINNER_FRAME, +} from '../constants.js'; import { WITTY_LOADING_PHRASES } from '../hooks/usePhraseCycler.js'; import { OpenTuiFooter, OpenTuiLoadingIndicator } from './opentui-footer.js'; +/** The indicator's first text cell is the spinner's own 2-column box. */ +function spinnerCell(container: HTMLElement): string { + return (container.querySelector('span')?.textContent ?? '').trim(); +} + function fakeConfig(overrides: Partial = {}): Config { return { getTargetDir: () => '/home/user/projects/qwen-code', @@ -122,6 +132,26 @@ describe('OpenTuiLoadingIndicator', () => { expect(container.textContent).toContain('(0s'); }); + it('advances the spinner frame while the turn is in flight', () => { + const { container } = render(); + expect(spinnerCell(container)).toBe(SPINNER_FRAMES[0]); + act(() => { + vi.advanceTimersByTime(SPINNER_INTERVAL_MS * 3); + }); + expect(spinnerCell(container)).toBe(SPINNER_FRAMES[3]); + }); + + it("holds ink's static frame once a call is parked on a confirmation", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('Waiting for user confirmation...'); + act(() => { + vi.advanceTimersByTime(SPINNER_INTERVAL_MS * 20); + }); + expect(spinnerCell(container)).toBe(WAITING_SPINNER_FRAME); + }); + it('ticks the elapsed counter once per second', () => { const { container } = render(); act(() => { diff --git a/packages/cli/src/ui/opentui/opentui-footer.tsx b/packages/cli/src/ui/opentui/opentui-footer.tsx index 86b27cbe676..9e2b85deb6c 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.tsx @@ -26,7 +26,11 @@ import { type Config, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; -import { SPINNER_FRAMES, SPINNER_INTERVAL_MS } from '../constants.js'; +import { + SPINNER_FRAMES, + SPINNER_INTERVAL_MS, + WAITING_SPINNER_FRAME, +} from '../constants.js'; import { usePhraseCycler } from '../hooks/usePhraseCycler.js'; import { useGitBranchName } from '../hooks/useGitBranchName.js'; import { useTimer } from '../hooks/useTimer.js'; @@ -45,17 +49,24 @@ import { C } from './theme.js'; /** * Owns its frame timer so the high-frequency tick re-renders ONLY this 1-cell - * component, not the whole transcript tree. + * component, not the whole transcript tree. ink's `RespondingSpinner` animates + * while responding and draws one static frame while a call is parked on a + * confirmation, so that row emits no bytes at all until the user answers. */ -function Spinner() { +function Spinner({ waiting }: { waiting: boolean }) { const [frame, setFrame] = useState(0); useEffect(() => { + if (waiting) return; const spin = setInterval(() => setFrame((f) => f + 1), SPINNER_INTERVAL_MS); return () => clearInterval(spin); - }, []); + }, [waiting]); return ( - {SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} + + {waiting + ? WAITING_SPINNER_FRAME + : SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} + ); } @@ -127,7 +138,7 @@ export function OpenTuiLoadingIndicator({ return ( - + {isNarrow || !suffix ? phraseText : `${phraseText} ${suffix}`} From 8184e3377123fed0e5937f324348f12b44598cc1 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 17:45:07 +0800 Subject: [PATCH 13/51] fix(cli): cancel a pending tab swap when a second answer lands inside the pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering a question schedules the swap after ink's 150ms pause. A second answer inside that window overwrote the timer handle without clearing it, so both timers fired and the question between them was skipped without ever being drawn — the same failure the ported dialog exists to remove, reached by another route. The pause was carried over from ink, which keeps no handle to its timer and so cannot cancel it either. Also restates the sweep's remaining residuals against the matrix re-run on the frozen waiting row: the parked spinner frame and the startup update notice are no longer open items, leaving the long-path wrap break and vertical anchoring. --- .../2026-09-10-opentui-parity-defect-sweep.md | 30 +++++++++++++++++-- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 20 +++++++++++-- .../src/ui/opentui/dialogs-confirm.test.tsx | 17 +++++++++++ .../cli/src/ui/opentui/dialogs-confirm.tsx | 6 +++- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 596963d0467..33dd8a0a66e 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -579,14 +579,29 @@ machine the hazard did not fire — a twenty-character answer survived intact in both legs — so the mirror is correctness by construction rather than the fix for an observed truncation. +Answering a question schedules the tab swap after ink's pause rather than doing +it inline, and a second answer landing inside that pause used to overwrite the +scheduled swap's handle without clearing it. Both timers then fired, each +advancing one tab, so the question between them was skipped without ever being +drawn — the same failure this decision exists to remove, reached by a different +route. ink cannot cancel that swap either: it calls `setTimeout` without keeping +the handle, so it carries the orphan as well. The ref this renderer already holds +to clear the timer on unmount makes the cancel one line. A test answers one +question twice inside the pause and asserts the next question still arrives; it +fails with the clear taken back out. Two Enters within 150 milliseconds is a fast +typist rather than a stress case, though, and no leg of any run has been observed +skipping a question this way — the cancel is correctness by construction. + Eight checkpoints were compared. Every dialog row matches ink line for line and indent for indent, including the mark appearing on a chip as soon as its free-text box is checked and before anything is committed, the comma-and-space join of a multi-select answer with the typed entry counted into it, and the review tab's own hint, which drops the cancel clause exactly where ink drops -it. The residuals are the three recorded elsewhere — where a long path wraps, -which phase the loading spinner happens to be in, and the update notice this -renderer words differently — plus vertical anchoring. +it. Two residuals stand: where a long path wraps, and vertical anchoring. A third +recorded here originally — which frame the waiting row shows — is closed by +Decision 28, and a fourth, the startup update notice, was falsified: that notice +comes from the runtime rather than this renderer, and the harness suppresses it on +both legs. ## Decision 22 — the confirmation is drawn where the conversation is @@ -840,6 +855,15 @@ advances while a turn is in flight, and holds that one frame while a call is parked. Each is killed by its own mutation and by nothing else: removing the gate fails the parked test, removing the interval fails the in-flight one. +Re-running the full matrix afterwards confirms it on the machine rather than in a +test: eleven checkpoints taken while a dialog is parked each shed one row on both +sides, from four divergent rows to three, and the rows left there are the +long-path wrap break recorded under Decision 21. The overall divergent-row count +goes from 221 ink-only and 265 opentui-only to 210 and 254. The number of +checkpoints matching byte for byte stays at 22 of 69 — that wrap row is what keeps +these from joining it, so the freeze closed a row everywhere and flipped no +verdict. + ## Coverage boundary What was verified, and how far the verification reaches: diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index f649967df22..a949e3f8f32 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -426,11 +426,21 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 的答案在两腿上都完整保留 —— 所以这个镜像是构造上的正确性,而不是对某个已观察到的截断 的修复。 +回答一个问题不是就地换 tab,而是在 ink 的那次停顿之后才调度换 tab;而一次落在该停顿 +内的第二个答案,过去会覆写已调度的那次换 tab 的句柄却不清掉它。于是两个定时器都会触 +发,各自前进一个 tab,夹在它们之间的那道题就从未被画出来地被跳过了 —— 正是本决定要消 +除的那个失效,只是从另一条路径到达。ink 也取消不了那次换 tab:它调用 `setTimeout` 时 +根本没有保留句柄,所以它同样带着这个孤儿定时器。本渲染器为了在卸载时清掉定时器已经持 +有的那个 ref,让取消只花一行。有一个测试在停顿之内把同一道题答两次,并断言下一题仍然 +出现;把那次清除取掉,它就失败。不过 150 毫秒内两次 Enter 是打字快,而不是压力用例, +而且任何一腿都没有被观察到因此跳过一道题 —— 这次取消同样是构造上的正确性。 + 比较了八个检查点。每一行对话框内容都与 ink 逐行逐缩进一致,包括标记在某个 chip 的自由 文本框被勾上时就出现、早于任何东西被提交,包括多选答案那个「逗号加空格」的连接并把手 打条目计入其中,也包括复核 tab 自己的那条提示 —— 它在 ink 丢掉取消子句的地方同样丢 -掉。残余是别处已记录过的那三条 —— 长路径在哪里折行、loading spinner 恰好停在哪个相 -位、以及本渲染器措辞不同的更新提示 —— 再加上垂直锚定。 +掉。剩下两处分叉:长路径在哪里折行,以及垂直锚定。这里原先记录的第三条 —— 等待行停在 +哪一帧 —— 已由决定 28 关闭;第四条,启动时的更新提示,已被证伪:那条提示来自运行时而 +非本渲染器,而且工装在两条腿上都把它压掉了。 ## 决定 22 —— 确认框画在会话所在的地方 @@ -610,6 +620,12 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 中时帧会推进,调用被挂住时帧停在那一格。各自被自己的变异杀掉、且只杀掉自己:移除门禁 只让「挂住」那条失败,移除 interval 只让「飞行中」那条失败。 +之后重跑全量矩阵,在真机上而不是在测试里确认了它:十一个在对话框挂住时取到的检查点, +每一处两侧各减掉一行,从四行分叉变成三行,那里剩下的行是决定 21 记录过的长路径折行断 +点。总分叉行数从 221 行 ink 独有、265 行 opentui 独有,降到 210 与 254。逐字节一致的检 +查点数目仍停在 69 个里的 22 个 —— 正是那行折行使它们没能并进去,所以这次冻结在每一处 +关掉了一行,却没有翻转任何一条判定。 + ## 覆盖边界 验证了什么,以及验证到哪一层: diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 438d832d875..bb830159949 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -573,6 +573,23 @@ describe('OpenTuiToolConfirmation', () => { expect(onConfirm).not.toHaveBeenCalled(); }); + it('advances one tab when a second answer lands inside the pause', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + act(() => { + vi.advanceTimersByTime(50); + }); + press({ name: 'down' }); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + // Both keystrokes answered the first question, so only the pause scheduled + // by the second one may swap tabs. + expect(container.textContent ?? '').toContain('Pick a region?'); + expect(container.textContent ?? '').toContain('Deploy ✓'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + it('joins the checked options and counts the typed entry on a multi-select', () => { const onConfirm = vi.fn(async () => {}); const container = mount(multiAskDetails(onConfirm)); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 5cdcf91f8d3..29acec0835d 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -680,7 +680,11 @@ function AskUserQuestionFlow(props: { } if (tab >= totalTabs - 1) return; // ink's pause, so the ✓ on the row just answered is visible before the tab - // swap carries it up into the chip row. + // swap carries it up into the chip row. An answer landing inside that pause + // supersedes the swap scheduled by the previous keystroke: left running, + // both timers fire and the question between them is skipped without ever + // being drawn. + if (advanceTimer.current) clearTimeout(advanceTimer.current); advanceTimer.current = setTimeout(() => { setTab((prev) => Math.min(prev + 1, totalTabs - 1)); setSelected(0); From e11b9c443aae2bea23df13cf05623824aa3496e7 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 18:36:21 +0800 Subject: [PATCH 14/51] fix(cli): drop the footer while a gated server holds the dialog slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ink's layout swaps the whole Composer — footer included — for the dialog mount whenever it counts any dialog visible, and the startup server approval is one of them. This renderer gave the approval the Composer's slot while leaving the footer on a gate of its own that knew about the shell confirmation, the action modal, the tool confirmation and the completion list, but not about the new occupant — so two rows ink does not draw stayed painted under the box. The line by line read of that frame is what turned it up; the frame now differs only by the two residuals already recorded for it. --- docs/design/2026-09-10-opentui-parity-defect-sweep.md | 7 +++++++ .../2026-09-10-opentui-parity-defect-sweep.zh-CN.md | 5 +++++ .../cli/src/ui/opentui/opentui-app-shell.test.tsx | 11 +++++++++++ packages/cli/src/ui/opentui/opentui-app-shell.tsx | 6 +++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 33dd8a0a66e..b3d8765563d 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -516,6 +516,13 @@ above both the shell confirmation and the tool confirmation, so it takes the slo outright here too, and the update notice stays suppressed while it owns the screen exactly as the other popups keep it suppressed. +Reading that frame line by line found a defect the port had introduced. ink's +layout drops the whole Composer — its footer included — for any dialog it counts +as visible, and this approval is one of them; here the dialog took the Composer's +slot while the footer kept its own separate gate, unaware of the new occupant, so +two rows ink does not draw stayed painted underneath the box. One term on that +gate, and the checkpoint now differs only by the two residuals below. + The geometry was measured against ink rather than assumed, and the first attempt was wrong in two ways: it drew the box at the terminal's left edge spanning the full width, and it printed no row numbers. ink insets this box one column further diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index a949e3f8f32..0d0ea0e5b90 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -383,6 +383,11 @@ config、返回普通数据外加一个处理器的 hook 里。它与 provider 在这里也直接占据那个槽位,而更新提示在它占屏期间保持被抑制,与其他弹层抑制它的做法完 全一致。 +逐行读那一帧时,发现了这次移植自己引入的一个缺陷。ink 的布局在任何它计为可见的对话框面 +前都会把整个 Composer —— 连它的 footer 一起 —— 卸掉,而这个审批就在其中;这里对话框占 +据了 Composer 的槽位,footer 却仍走自己那道独立的门禁、不知道槽位换了主人,于是 ink 不 +画的两行继续印在盒子下面。给那道门禁补上一个条件,这个检查点现在只剩下面两处残余。 + 几何是照着 ink 量出来的,不是假定的,而第一版有两处错:它把盒子画在终端左边缘、占满整 宽,并且不印行号。ink 把这个盒子比对话框区域已经定位好的那些弹层再往里缩一列,因为它 在那个区域内部又加了一道自己的边距,这使它比共享的弹层宽度窄一列;而它的单选行是带编 diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index 060e28abd8c..e4550286dcc 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -384,6 +384,17 @@ describe('OpenTuiApp shell wiring', () => { expect(screen.getByText('footer')).toBeTruthy(); }); + it('hides the footer while the gated-server approval dialog is open', async () => { + // ink's layout swaps the whole Composer — footer included — for the dialog + // mount while any dialog is visible, and the approval is one of them. Here + // the footer is a sibling with its own gate, so it needs the same term. + mocks.state.mcpQueue.push({ name: 'acceptance-server' }); + renderApp(); + await settle(); + expect(screen.getByText('mcp-approval')).toBeTruthy(); + expect(screen.queryByText('footer')).toBeNull(); + }); + it('keeps an armed quit warning mounted while a dialog hides the footer', async () => { // A dialog unmounts the composer, so nothing intercepts Ctrl+C and the // app-level guard still arms. The warning has to survive the footer's diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index 4d9b6272117..deba9852c0d 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -1061,7 +1061,11 @@ export function OpenTuiApp(props: OpenTuiAppProps) { the user that a second press exits. With the warning set the footer returns just that row, so nothing else appears. */} {exitHint || - (!dialog && !activeModal && !activeToolCall && !showSuggestions) ? ( + (!dialog && + !activeModal && + !activeToolCall && + !mcpApproval.isMcpApprovalDialogOpen && + !showSuggestions) ? ( Date: Mon, 14 Sep 2026 20:09:22 +0800 Subject: [PATCH 15/51] fix(cli): hold ink's pending glyph on a call queued behind another approval A turn that returns two calls needing approval parks the first in the scheduler's 'scheduled' status until the second's answer arrives. ink reads that status and keeps its pending glyph on the card; this renderer went straight to the executing one, drawing work in progress on a call waiting for a person. The status was already on the batch update this renderer consumes, so this reads it there rather than touching the scheduler, and emits it only when it changes. Found by a new acceptance scenario that captures the screen between the two approvals, and confirmed on the machine with a single-variable comparison: the row pairs off and the checkpoint drops from four divergent rows to three. --- .../2026-09-10-opentui-parity-defect-sweep.md | 66 +++++++++++++++++-- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 48 ++++++++++++-- packages/cli/src/ui/opentui/event-adapter.ts | 5 ++ .../src/ui/opentui/live-session-model.test.ts | 26 ++++++++ .../cli/src/ui/opentui/live-session-model.ts | 12 ++++ .../cli/src/ui/opentui/live-session.test.ts | 62 +++++++++++++++++ packages/cli/src/ui/opentui/live-session.ts | 14 ++++ packages/cli/src/ui/opentui/messages.test.tsx | 21 ++++++ packages/cli/src/ui/opentui/messages.tsx | 7 ++ 9 files changed, 250 insertions(+), 11 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index b3d8765563d..66ddc7ba187 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,7 +57,7 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Twenty-two scenarios cover boot, a +rather than at the same wall-clock moment. Twenty-five scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear @@ -66,8 +66,10 @@ question dialog on its own, the same dialog across three questions with a multi-select and a typed answer, the release of a parked confirmation when the approval mode changes, the approval of a gated server at startup, a tool call whose arguments are long enough to be capped, a control arm that repeats that -call with the arguments row switched off, and an answer long enough to overflow -the terminal. +call with the arguments row switched off, an answer long enough to overflow +the terminal, a scroll through that overflow and back, two answer rows wide +enough to show where the wrap happens, and a turn that parks two calls on +approval at once so the first waits behind the second's dialog. Three properties of the comparison matter for reading the results. @@ -871,14 +873,59 @@ checkpoints matching byte for byte stays at 22 of 69 — that wrap row is what k these from joining it, so the freeze closed a row everywhere and flipped no verdict. +## Decision 29 — a call approved behind another approval waits with ink's pending glyph + +One model turn can return two calls that both need approval. Approving the first +does not start it: the batch still holds the second one's approval, so the +scheduler parks the first in `scheduled` until that answer arrives. ink's card +for it reads that status and holds `o` across the whole stretch; this renderer +switched the row to `⊷` the moment the answer arrived — the glyph for work in +progress, on a call that was waiting for a person. + +ink's status mapping hands `validating` and `executing` the Executing display +status and `scheduled` the Pending one, and its indicator draws Pending as +`TOOL_STATUS.PENDING` in the success green where Executing draws the toggling +spinner's `⊷`. Both renderers read the same scheduler, so the status this needed +was already in this renderer's hands — the batch update it subscribes to carries +every call with its own `status`. Only the read was missing, and no change to the +shared scheduler is involved. + +What made the row worth stopping on is that `TOOL_STATUS.PENDING` had no +producer on this side at all. The comment above the status table names all six +glyphs the shared constants define, and five of them were ever emitted here, so +the row was not drawing the right state at the wrong frame. It was drawing a +state this architecture had no name for. + +The scenario that surfaced it is new to the matrix: two shell calls in one turn +under the default approval mode, the screen captured between the two approvals. +Compared on the machine with one variable — the read site reverted, the rest of +the bundle and the scenario untouched — ink's row reads +`o Shell touch acceptance-two-a` where this renderer's reads `⊷ …`, and that pair +is one of the checkpoint's four divergent rows. With the read in place both rows +read `o` and the checkpoint is down to three, which are the long-path wrap break +recorded under Decision 21. + +The event is edge-triggered against a per-call set: the update that first finds +a call queued emits, a repeat of that status emits nothing, and the move to +`executing` emits the clear — so a call that never left `awaiting_approval` +carries neither event nor state. Three tests carry the three layers of the path, +and four mutations fail them: deleting the glyph branch fails the render test +alone, deleting the read fails the scheduler test alone, weakening the fold +fails the model test alone, and dropping the dedupe guard fails the scheduler +test by printing a clear for every call in every batch. + +The same scenario also puts a second divergence on the record, in Follow-ups: +this renderer appends the open dialog after both waiting cards where ink sets it +between them. + ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for twenty-two scenarios at 100×40 and, for the narrow - and resize scenarios, at 60×24. Both legs from one bundle and one set of - boot arguments. + reconstructed screen, for twenty-five scenarios at 100×40 and, for the narrow + and resize scenarios, at 60×24 — seventy-two checkpoints in all. Both legs + from one bundle and one set of boot arguments. - **Colour was not verified.** The reconstruction is text. Several rows are known to differ only in which theme token they use, and a styled capture exists that could settle it but was not read. @@ -1096,3 +1143,10 @@ What was verified, and how far the verification reaches: indicator drawn on every card rather than the one call awaiting approval; Decision 25 closed both, and the empty-card reading of a disabled-tool error went with the first. +- With two calls awaiting approval at once, the confirmation sits in a different + place. ink puts it directly below the row of the call it belongs to, so the + screen reads card, confirmation, card; here the conversation keeps both cards + in their order and the confirmation follows them. The difference is that one + row moving, on top of the three each side already loses to the wrap break at + the same checkpoint. Decision 22 fixed what the inline confirmation draws; + this is where it is mounted, which is a separate change. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 0d0ea0e5b90..8ae711a2bff 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,12 +41,14 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。二十二个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。二十五个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、审批模式变化时被 拦下的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用、 -把同一次调用在关掉参数行的情况下重跑的对照臂,以及一份长到溢出终端的回答。 +把同一次调用在关掉参数行的情况下重跑的对照臂、一份长到溢出终端的回答、在溢出部分 +上滚下去再滚回来、两行宽到足以展示折行发生在哪一列的回答,以及一个把两个调用同时 +停在审批上的回合 —— 于是第一个要排在第二个的对话框后面。 比较方式有三个性质决定了结果该怎么读。 @@ -631,13 +633,45 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 查点数目仍停在 69 个里的 22 个 —— 正是那行折行使它们没能并进去,所以这次冻结在每一处 关掉了一行,却没有翻转任何一条判定。 +## 决定 29 —— 排在另一个审批之后的已批准调用,用 ink 的等待字形停着 + +一个模型回合可以返回两个都需要审批的调用。批准第一个并不会让它开始跑:这一批里第二 +个的审批还挂着,于是调度器把第一个按在 `scheduled` 里,直到那个回答到达。ink 的卡片 +会读出这个状态,并在整段等待里保持 `o`;本渲染器却在回答落地的那一刻就把这一行换成 +了 `⊷` —— 那是「正在干活」的字形,可这个调用等的是人。 + +ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态,把 `scheduled` 归 +到 Pending;它的指示器把 Pending 画成成功绿的 `TOOL_STATUS.PENDING`,把 Executing 画 +成会切换的转圈 `⊷`。两个渲染器读的是同一个调度器,所以这件事要的状态本来就在本渲染器 +手里 —— 它订阅的那批更新里,每个调用都带着自己的 `status`。缺的只是那一次读取,不涉及 +对共享调度器的任何改动。 + +让这一行值得停下来的原因是:`TOOL_STATUS.PENDING` 在这一侧根本没有任何生产者。状态表 +上方那段注释列出了共享常量定义的六个字形,而这里被产出过的只有五个 —— 所以那一行不是 +把正确的状态画成了错误的一帧,它画的是一个本架构没有名字的状态。 + +找到它的场景是新加进矩阵的:默认审批模式下一个回合里两个 shell 调用,在两次批准之间 +抓取屏幕。真机上的受控对照只变一个量 —— 回退读取点,bundle 与场景其余部分不动 —— ink +那一行是 `o Shell touch acceptance-two-a`,本渲染器对应位置是 `⊷ …`,而这一对正是该检 +查点四行分叉里的一行。补上读取之后两侧都读 `o`,该检查点降到三行,剩下的就是决定 21 +记录过的长路径折行断点。 + +这个事件是按调用集合触发的:第一次发现某调用进入排队的那次更新发一个事件,同一状态的 +重复更新什么都不发,转入 `executing` 时发一次清除 —— 所以从未离开 `awaiting_approval` +的调用既没有事件也没有状态。三个测试撑起这条路径的三层,四个变异让它们各自失败:删掉 +字形分支只让渲染那条失败,删掉读取只让调度器那条失败,削弱折叠只让模型那条失败,而去 +掉去重守卫会让调度器那条失败 —— 因为它给每一批里的每个调用都打了一次清除。 + +同一个场景还把另一处分歧记进了后续工作:两个调用同时等待审批时,本渲染器把打开的确认 +框接在两张卡片之后,而 ink 把它放在两张卡片之间。 + ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十二个场景, - 100×40;窄终端与 resize 场景另加 60×24。两腿出自同一个 bundle 与同一组启动 - 参数。 +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十五个场景, + 100×40;窄终端与 resize 场景另加 60×24,总计七十二个检查点。两腿出自同一个 + bundle 与同一组启动参数。 - **颜色未验证。** 重建结果是文本。已知有若干行只在所用主题 token 上不同,而 存在一份带样式的抓取可以定这件事,但没有读。 - **滚动条没有从转录里拿走一列。** 在一百列下用两条分别为 95 与 96 字符的回答行 @@ -786,3 +820,7 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 落在卡片之前。这一条曾经还记着卡片缺少的行内参数行,以及一个画在每张卡片上、而 非只画在那一个等待审批的调用上的等待指示符;决定 25 关掉了这两点,「工具被禁用」 的错误在这里读成一张空卡片,也随第一条一起消失了。 +- 两个调用同时等待审批时,确认框在屏幕上的位置不同。ink 把它直接放在所属那行卡片之 + 下,所以画面读起来是卡片、确认框、卡片;这里会话按自己的顺序保留两张卡片,确认框 + 跟在它们后面。差的就是这一行的挪动,同一检查点上两侧本来就已经因为折行断点各失去 + 三行。决定 22 改的是行内确认框画什么;这一条是它挂在哪里,需要另一次改动。 diff --git a/packages/cli/src/ui/opentui/event-adapter.ts b/packages/cli/src/ui/opentui/event-adapter.ts index ac639a759f5..0bb6b4c219e 100644 --- a/packages/cli/src/ui/opentui/event-adapter.ts +++ b/packages/cli/src/ui/opentui/event-adapter.ts @@ -82,6 +82,11 @@ export type OpenTuiStreamEvent = id: string; outcome: 'approved' | 'rejected'; } + /** The tracked call's own scheduler status: `queued` is true while it sits + * in 'scheduled' — approved, but not started because the batch still holds + * another approval. ink reads this status off the same update and draws + * TOOL_STATUS.PENDING for it. */ + | { type: 'tool-queued'; id: string; queued: boolean } /** Structured compression item (/compress command): rendered as the ink * CompressionMessage row (spinner/diamond + token counts) instead of the * flattened text projection. */ diff --git a/packages/cli/src/ui/opentui/live-session-model.test.ts b/packages/cli/src/ui/opentui/live-session-model.test.ts index 5544f928098..f237e21b86a 100644 --- a/packages/cli/src/ui/opentui/live-session-model.test.ts +++ b/packages/cli/src/ui/opentui/live-session-model.test.ts @@ -146,6 +146,32 @@ describe('foldLiveEvent confirm-resolved (outcome parity, R1-18)', () => { }); }); +describe('foldLiveEvent tool-queued (approved but not started)', () => { + it('records the queued status and clears it again', () => { + const queued = foldLiveEvent([{ ...waitingTool(), confirm: 'approved' }], { + type: 'tool-queued', + id: 'tool1', + queued: true, + }); + expect(queued[0]).toMatchObject({ confirm: 'approved', queued: true }); + const running = foldLiveEvent(queued, { + type: 'tool-queued', + id: 'tool1', + queued: false, + }); + expect(running[0]).toMatchObject({ queued: false }); + }); + + it('ignores a status for a call with no card', () => { + const items = foldLiveEvent([assistant('hi')], { + type: 'tool-queued', + id: 'tool1', + queued: true, + }); + expect(items).toHaveLength(1); + }); +}); + describe('foldLiveEvent user (promptId/sentToModel parity)', () => { it('carries promptId and sentToModel onto the user item (R1-16)', () => { const items = foldLiveEvent([assistant('hi')], { diff --git a/packages/cli/src/ui/opentui/live-session-model.ts b/packages/cli/src/ui/opentui/live-session-model.ts index b59037512fe..5f49e015c43 100644 --- a/packages/cli/src/ui/opentui/live-session-model.ts +++ b/packages/cli/src/ui/opentui/live-session-model.ts @@ -30,6 +30,10 @@ export type LiveToolItem = Extract & { * mapToDisplay parity) — takes precedence over the args-based fallback. */ description?: string; confirm?: ToolConfirmState; + /** The scheduler reports the call as 'scheduled' — approved, but not + * started because the batch still holds another approval. ink reads the + * same status and draws its pending glyph instead of the executing one. */ + queued?: boolean; /** Structured FileDiff result: the card renders colored diff lines inline * (ink DiffResultRenderer parity) instead of the flattened output text. */ diff?: { fileDiff: string; fileName: string }; @@ -386,6 +390,14 @@ export function foldLiveEvent( } return items; } + case 'tool-queued': { + const i = findToolIndex(items, ev.id); + if (i >= 0) { + const t = items[i] as LiveToolItem; + items[i] = { ...t, queued: ev.queued }; + } + return items; + } case 'task-start': if (last?.kind === 'assistant' && last.streaming) items[items.length - 1] = { ...last, streaming: false }; diff --git a/packages/cli/src/ui/opentui/live-session.test.ts b/packages/cli/src/ui/opentui/live-session.test.ts index 60dfac99505..df1e608b714 100644 --- a/packages/cli/src/ui/opentui/live-session.test.ts +++ b/packages/cli/src/ui/opentui/live-session.test.ts @@ -96,6 +96,32 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { await this.opts.onToolCallsUpdate?.( waiting('Hook requested confirmation to run'), ); + } else if ( + calls.some( + (c) => + (c.args as { __queuedApproval?: boolean } | undefined) + ?.__queuedApproval, + ) + ) { + // Two-call batch where approving the first does not start it: the + // scheduler holds it in 'scheduled' until the sibling's approval + // lands, and only then moves it to 'executing'. + const awaiting = calls.map((c) => ({ + status: 'awaiting_approval', + request: c, + confirmationDetails: { + type: 'info', + title: 'original', + onConfirm: async () => {}, + }, + })); + const firstMoved = (status: string) => + calls.map((c, i) => + i === 0 ? { status, request: c } : awaiting[i], + ); + await this.opts.onToolCallsUpdate?.(awaiting); + await this.opts.onToolCallsUpdate?.(firstMoved('scheduled')); + await this.opts.onToolCallsUpdate?.(firstMoved('executing')); } else if ( calls.some( (c) => @@ -1892,6 +1918,42 @@ describe('livePromptEvents', () => { ]); }); + it('reports an approved call as queued while its sibling still awaits', async () => { + let requests = 0; + const queuedArgs = { __queuedApproval: true }; + const sendMessageStream = vi.fn(function* (): Generator<{ + type: string; + value?: unknown; + }> { + requests += 1; + if (requests === 1) { + yield { + type: 'tool_call_request', + value: { callId: 'q1', name: 'run_shell_command', args: queuedArgs }, + }; + yield { + type: 'tool_call_request', + value: { callId: 'q2', name: 'run_shell_command', args: queuedArgs }, + }; + return; + } + yield { type: 'finished', value: {} }; + }); + const config = createFakeConfig(sendMessageStream); + + const events = (await drain( + livePromptEvents(config, 'q'), + )) as OpenTuiStreamEvent[]; + + // Approving the first call does not start it while the second still + // awaits: ink shows it as queued, not running, for exactly that span. The + // call that never left awaiting_approval carries no event at all. + expect(events.filter((e) => e.type === 'tool-queued')).toEqual([ + { type: 'tool-queued', id: 'q1', queued: true }, + { type: 'tool-queued', id: 'q1', queued: false }, + ]); + }); + it('records the No/Esc cancellation as a rejected resolution (R1-18)', async () => { const sendMessageStream = vi.fn(function* (): Generator<{ type: string; diff --git a/packages/cli/src/ui/opentui/live-session.ts b/packages/cli/src/ui/opentui/live-session.ts index 2663cbe71ac..17d82c407a3 100644 --- a/packages/cli/src/ui/opentui/live-session.ts +++ b/packages/cli/src/ui/opentui/live-session.ts @@ -864,6 +864,9 @@ export async function* livePromptEvents( // callIds whose real invocation description already went out (one per // call, ink mapToDisplay parity). const descriptionSeen = new Set(); + // Calls last seen in the scheduler's queued state, so a status only goes + // out when it changes. + const queuedSeen = new Set(); const scheduler = new CoreToolScheduler({ config, getPreferredEditor: () => undefined, @@ -935,6 +938,17 @@ export async function* livePromptEvents( confirmationDetails: c.confirmationDetails, }); } + // A call approved while the batch still holds another approval goes + // back to 'scheduled' rather than straight to 'executing'; ink's card + // reads that status and holds its pending glyph until the call runs. + for (const c of calls) { + const callId = c.request.callId; + const queued = c.status === 'scheduled'; + if (queuedSeen.has(callId) === queued) continue; + if (queued) queuedSeen.add(callId); + else queuedSeen.delete(callId); + live.push({ type: 'tool-queued', id: callId, queued }); + } }, onAllToolCallsComplete: async (calls) => { completed = calls as unknown as LooseCompletedCall[]; diff --git a/packages/cli/src/ui/opentui/messages.test.tsx b/packages/cli/src/ui/opentui/messages.test.tsx index 2d2ae88da5c..6cb286cdd60 100644 --- a/packages/cli/src/ui/opentui/messages.test.tsx +++ b/packages/cli/src/ui/opentui/messages.test.tsx @@ -413,6 +413,27 @@ describe('message meta (ink glyph/color parity)', () => { expect(meta.glyph).toBe(TOOL_STATUS.CANCELED); expect(meta.color).not.toBe(C.red); }); + + it("shows an approved call that has not started with ink's pending glyph", () => { + // The scheduler holds an approved call in 'scheduled' while a sibling + // still awaits its own approval; ink draws that as pending, not running. + const item = { + kind: 'tool', + id: 't', + tool: 'run_shell_command', + title: 'Shell touch a', + output: '', + done: false, + confirm: 'approved', + queued: true, + } as unknown as LiveToolItem; + const queued = toolStatusMeta(item); + expect(queued.glyph).toBe(TOOL_STATUS.PENDING); + expect(queued.color).toBe(C.green); + expect(toolStatusMeta({ ...item, queued: false }).glyph).toBe( + TOOL_STATUS.EXECUTING, + ); + }); }); describe('truncateTokenLine (ink wrap="truncate" parity)', () => { diff --git a/packages/cli/src/ui/opentui/messages.tsx b/packages/cli/src/ui/opentui/messages.tsx index d7c37c025b2..74db4e76040 100644 --- a/packages/cli/src/ui/opentui/messages.tsx +++ b/packages/cli/src/ui/opentui/messages.tsx @@ -486,6 +486,13 @@ export function toolStatusMeta(item: LiveToolItem): ToolStatusMeta { }; } if (!item.done) { + if (item.queued) { + return { + glyph: TOOL_STATUS.PENDING, + color: C.green, + strikethrough: false, + }; + } return { glyph: TOOL_STATUS.EXECUTING, color: C.text, From 772a23f6938f0ffda6ee86e21abf8eddbc9b4a1a Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 21:05:13 +0800 Subject: [PATCH 16/51] docs: record the inline-confirmation row reserves as named debt A review pass noted that the two reserves sizing the conversation around an expanded confirmation would rot silently if that geometry ever changed: they are hand-counted from a row inventory, the live scenario separates the shipped value from zero but not from four rows smaller, and the unit tests assert the arithmetic the constants feed rather than the screen. Both languages of the design doc now say so where the follow-ups are kept. --- docs/design/2026-09-10-opentui-parity-defect-sweep.md | 7 +++++++ .../design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 66ddc7ba187..f2f2831b647 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1093,6 +1093,13 @@ What was verified, and how far the verification reaches: - The help dialog's reserved-row constant does not describe the rows actually observed on screen, and the window height this renderer shows below a 42-row terminal is bounded rather than matched. +- The two reserves that size the conversation around an expanded confirmation + are hand-derived from a row inventory. The long-confirmation scenario shows + the shipped value green and zero timing out, but a value four rows smaller + passes there too, so what pins the numbers is the arithmetic in the unit + tests — which asserts the constants' own sums rather than the screen. A change + to the confirmation's geometry will therefore not fail on its own; re-deriving + them belongs with that change. - The loading indicator has no subagent token rollup and no tokens-per-second segment, both of which ink shows. - The theme mode helpers have no production caller, so this renderer always diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 8ae711a2bff..f513cf68504 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -785,6 +785,11 @@ ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态 一个待定问题。 - help 对话框的保留行数常量并没有描述屏幕上实际观察到的行,且在低于 42 行的终 端上本渲染器显示的窗口高度是被限制的,而不是被对齐的。 +- 内联确认框周围为会话预留视口行的那两个常量是按行清单手工推导的。长确认场景 + 显示实际采用的取值为绿、归零时超时,但小四行的取值在那里同样能通过,所以真正 + 钉住这两个数字的是单元测试里的算术 —— 而它断言的是常量自己的加总,不是屏幕。 + 因此确认框的几何将来发生变化时,这两个数字不会自己报错;重新推导它们应当与那 + 次改动一起做。 - loading 指示器没有子代理 token 汇总,也没有 tokens-per-second 段,两者 ink 都有。 - 主题模式 helper 没有生产调用方,所以本渲染器永远画它的暗色调色板。 From 0cd2ce245b5613baa8e9d2ca3c32833241b34de8 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 22:06:47 +0800 Subject: [PATCH 17/51] fix(cli): list mid-turn queued prompts above the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt submitted while a turn was running was counted here but never shown: its length reached the footer badge and the composer could pop it back for editing, but the texts themselves were readable only by draining the queue, which empties it. ink lists them above the composer instead — three at a time, each collapsed to one line, an overflow row and a fading hint — so this ports that component row for row. The queue stays in a ref, because a turn drains it and puts it back within one tick and a ref is what that needs; the five sites that move it now copy it into state besides, which is also why a cleared screen sheds the rows with it. No frame can show this one: both renderers steer a plain-Enter submission at the next sampling boundary, so a queue that outlives the turn needs the deferral ink puts behind Ctrl+Q, a key this renderer does not bind. Coverage is therefore unit-level, and the design doc records that the ported hint now prints that unbound key on screen. --- .../2026-09-10-opentui-parity-defect-sweep.md | 68 +++++++--- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 50 ++++++-- packages/cli/src/ui/opentui/live-turn.test.ts | 42 +++++-- packages/cli/src/ui/opentui/live-turn.ts | 49 +++++--- .../src/ui/opentui/opentui-app-shell.test.tsx | 14 +++ .../cli/src/ui/opentui/opentui-app-shell.tsx | 10 +- .../src/ui/opentui/queued-message.test.tsx | 117 ++++++++++++++++++ .../cli/src/ui/opentui/queued-message.tsx | 83 +++++++++++++ .../src/ui/opentui/start-opentui-ui.test.tsx | 2 +- .../cli/src/ui/opentui/start-opentui-ui.tsx | 2 +- 10 files changed, 379 insertions(+), 58 deletions(-) create mode 100644 packages/cli/src/ui/opentui/queued-message.test.tsx create mode 100644 packages/cli/src/ui/opentui/queued-message.tsx diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index f2f2831b647..2e2d4d0b1e8 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -869,9 +869,9 @@ test: eleven checkpoints taken while a dialog is parked each shed one row on bot sides, from four divergent rows to three, and the rows left there are the long-path wrap break recorded under Decision 21. The overall divergent-row count goes from 221 ink-only and 265 opentui-only to 210 and 254. The number of -checkpoints matching byte for byte stays at 22 of 69 — that wrap row is what keeps -these from joining it, so the freeze closed a row everywhere and flipped no -verdict. +checkpoints matching byte for byte stays at twenty-two, of the sixty-nine that +run scored — that wrap row is what keeps these from joining it, so the freeze +closed a row everywhere and flipped no verdict. ## Decision 29 — a call approved behind another approval waits with ink's pending glyph @@ -918,6 +918,45 @@ The same scenario also puts a second divergence on the record, in Follow-ups: this renderer appends the open dialog after both waiting cards where ink sets it between them. +## Decision 30 — prompts queued mid-turn list as ink's rows above the composer + +A prompt submitted while a turn is running was counted here but never shown. Its +length reached the footer badge, and the composer's up key at the top edge could +pop a text back for editing, but the texts themselves were readable only by +draining them — which empties the queue. ink lists them instead, above the +composer: three at a time, each collapsed to one line, an overflow row when more +are waiting, and a hint naming the keys, shown the first three times the queue +fills. + +The port is ink's component row for row, hint included. What had blocked it was +the read rather than the render. The queue lives in a ref so that a turn can +drain it and put it back within one tick, and a ref is invisible to a render — +so the ref stays the synchronous source, and the five sites that move it each +copy it into state besides: the push, the drain, the restore for texts still +riding when a turn aborts, the pop for editing, and the transcript reset that +backs `/clear`, resume and branch. That last one is why the rows shed with a +cleared screen instead of outliving it. + +This is the one decision here that no frame can evidence. Both renderers _steer_ +a plain-Enter submission made mid-turn: the text is drained at the next sampling +boundary and rides into the following model request, so the rows appear and +vanish inside a single turn. A queue that outlives the turn needs the submission +deferred until it is idle, and in ink that is Ctrl+Q — which this renderer does +not bind. The command is named in the key map's priority list, but the resolvers +that read that list have no production caller, so the keystroke falls through as +text. The rows are reachable here only for the length of a turn, which is also +why no scenario in the matrix produces a durable queue on either leg. + +Coverage is unit-level across three layers: five component tests (nothing when +the queue is empty, one row per prompt with whitespace flattened, the three-row +cap with its overflow row, the hint that stops after the fourth fill, a row cut +to the terminal width minus ink's indent), the hook's assertions on the queued +texts at each of those sites plus the reset, and a shell test that fixes the +placement — rows in the persistent chrome between the loading indicator and the +composer, not in the scroll region. Nine mutations, one at each of the four +component behaviours, four of the five mirror sites and the mount, each fail +their own test and no other. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -940,11 +979,14 @@ What was verified, and how far the verification reaches: above the composer is confirmed row for row against ink, because there the content fills the window on both legs. Elsewhere it is not separable in a frame comparison from where the reconstruction puts the block. -- **The quit warning's queued segment is unit-tested only.** No scenario - queues a message and then arms the warning. Nor does any scenario produce a - durable queue at all: ink's badge survives only as a single transient of its - own submit path in the raw stream, and never reaches a captured frame in - either leg. +- **The quit warning's queued segment, and Decision 30's queue rows, are + unit-tested only.** No scenario queues a message and then arms the warning. + Nor does any scenario produce a durable queue at all: ink's badge survives only + as a single transient of its own submit path in the raw stream, and never + reaches a captured frame in either leg. A queue that outlives the turn needs + the deferral ink puts behind Ctrl+Q; this renderer does not bind that key, and + no scenario presses it on either leg — so the texts, like the badge, exist + only inside one turn. - **The non-shrinkable row prefix and the per-item top margin are verified by re-capture only.** The unit-test runtime stubs the renderer's graphics surface, so it cannot exercise layout; a test there could only echo the prop @@ -1075,12 +1117,6 @@ What was verified, and how far the verification reaches: sessions, MCP resources and extensions, and draws a category bar to switch between them. That is a feature gap rather than a parity defect and belongs in its own change. -- The mid-turn queue is counted here but never shown. Its length reaches the - footer badge and the composer's up key at the top edge pops it back for - editing, but ink also lists the queued texts above the composer — three at a - time, each collapsed to one line, with an overflow row and a hint shown for - the first few times the queue fills. Porting that needs a non-destructive - snapshot of the queue, which today can only be read by draining it. - The shell-crawler diagnostics that print when the search binary is missing are now explained. The renderer library replaces the global console with a capture stream and folds console output into its own in-renderer console, so @@ -1130,7 +1166,9 @@ What was verified, and how far the verification reaches: - The status-line settings are ignored. ink renders up to two lines produced by a user-configured command, on its own refresh interval and with its own colour choice; this renderer hardcodes a directory, session, branch and model row. -- The composer advertises a queue key it does not bind. Its exit key also arms +- The composer advertises a queue key it does not bind. Decision 30 widened + that: the ported hint row prints the key's name on screen, so the dead + affordance is now visible rather than merely documented. Its exit key also arms the two-press window with a non-empty draft and eats a character doing it, where ink declines to arm at all while the buffer holds text. - ink answers a typed free-text entry in its question dialog twice for one diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index f513cf68504..b2a7819f3c3 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -630,8 +630,8 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 之后重跑全量矩阵,在真机上而不是在测试里确认了它:十一个在对话框挂住时取到的检查点, 每一处两侧各减掉一行,从四行分叉变成三行,那里剩下的行是决定 21 记录过的长路径折行断 点。总分叉行数从 221 行 ink 独有、265 行 opentui 独有,降到 210 与 254。逐字节一致的检 -查点数目仍停在 69 个里的 22 个 —— 正是那行折行使它们没能并进去,所以这次冻结在每一处 -关掉了一行,却没有翻转任何一条判定。 +查点数目仍停在那次运行打分的那 69 个里的 22 个 —— 正是那行折行使它们没能并进去,所以 +这次冻结在每一处关掉了一行,却没有翻转任何一条判定。 ## 决定 29 —— 排在另一个审批之后的已批准调用,用 ink 的等待字形停着 @@ -665,6 +665,34 @@ ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态 同一个场景还把另一处分歧记进了后续工作:两个调用同时等待审批时,本渲染器把打开的确认 框接在两张卡片之后,而 ink 把它放在两张卡片之间。 +## 决定 30 —— 回合中排队的提示,像 ink 那样列在 composer 之上 + +一个在回合运行时提交的提示,在这里只被计数、从不被显示。它的长度会到达 footer 徽标, +composer 的上键在顶边也能把一条文本弹回来供编辑,但这些文本本身只能通过排空来读取 +—— 而排空就会清空队列。ink 改为把它们列在 composer 上方:一次三条、每条压成一行, +更多在等待时给一个溢出行,外加一条念出按键名字的提示,只在队列前几次被填满时显示。 + +移植过来的是 ink 的那个组件,逐行照搬,提示也在内。此前挡住的是读取而不是渲染。队列 +住在一个 ref 里,好让一个回合能在一拍之内把它排空再放回去,而 ref 对一次渲染是不可见 +的 —— 于是 ref 仍是那个同步的真相来源,同时移动它的五处各自再往 state 里复制一份: +压入、排空、回合中止时把仍在搭载的文本放回去的恢复、供编辑的弹出,以及为 `/clear`、 +恢复会话与分支兜底的那次转录重置。最后这一处决定了这些行是随清屏一起脱落,而不是活过 +它。 + +这是本文档里唯一一件没有帧能作证的改动。两个渲染器都会把一个回合中按普通回车提交的文 +本 **转向(steer)**:它在下一个采样边界被排空,作为真实的用户内容进入下一次模型请求, +所以这些行在一个回合之内出现又消失。要一个活得比回合更久的队列,就需要把提交推迟到空 +闲,而在 ink 那是 Ctrl+Q —— 本渲染器并没有绑定它:这个命令在 key map 的优先级表里被列 +出,但读那张表的解析器没有任何生产调用方,于是这次按键落成普通文本。这些行在这里只在 +一个回合的长度内可达,这也正是矩阵里没有任何场景在任一腿上产生持久队列的原因。 + +覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三行 +的上限与它的溢出行、第四次填满后不再出现的提示、按终端宽度减掉 ink 缩进截断的一行), +hook 在上述每一处以及那次重置上对被排队文本的断言,以及一条钉住位置的 shell 测试 —— +这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动区域中。九个 +变异分别落在四个组件行为、五个镜像点中的四个、以及挂载处,每个只让属于自己的那条测试 +失败。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -681,9 +709,11 @@ ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态 - **composer 的一行间距以源码为据,只在两帧确实对齐的地方有帧为据。** 在决定 26 的溢出臂上,composer 之上那一空行与 ink 逐行吻合,因为那里两腿的内容都刚好 填满窗口。在别处,它在帧比较里无法与重建把块放在哪里这件事分离。 -- **退出警告的排队段仅有单元测试覆盖。** 没有场景会先排队一条消息再武装警告。也 - 没有任何场景会产生一个持久的队列:ink 的徽标只在它自己的提交路径里作为一帧瞬态 - 存在于裸流中,两腿都从未进入任何一个抓取帧。 +- **退出警告的排队段、以及决定 30 的队列行,仅有单元测试覆盖。** 没有场景会先排队 + 一条消息再武装警告。也没有任何场景会产生一个持久的队列:ink 的徽标只在它自己的提交 + 路径里作为一帧瞬态存在于裸流中,两腿都从未进入任何一个抓取帧。活得比回合更久的队列 + 需要 ink 放在 Ctrl+Q 后面的那次推迟;本渲染器没有绑定这个键,而没有任何场景在任何一 + 条腿上按它 —— 所以这些文本和徽标一样,只存在于一个回合之内。 - **不可压缩的行前缀与按条目上边距仅靠重新抓取验证。** 单元测试运行时会桩掉 渲染器的图形表面,因此无法执行布局;在那里的测试只能把这个 prop 原样回显。 - **纵向间距直到最后一次运行之前都在帧证据之外。** 比对会把每一帧归约为一串非 @@ -773,10 +803,6 @@ ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态 动。 - 这里的 `@` 补全只问文件索引。ink 还会补全会话、MCP 资源与扩展,并画一个分类 栏用于切换。那是功能缺口而不是一致性缺陷,应放进独立的改动。 -- 回合中队列在这里被计数,但从不被显示。它的长度会到达 footer 徽标,而 composer - 的上键在顶边会把队列弹回来供编辑;但 ink 还会在 composer 上方列出那些排队文本 - —— 一次三条、每条压成一行,带一个溢出行,以及在队列前几次被填满时显示的一条提 - 示。移植它需要一份非破坏性的队列快照,而这份队列如今只能通过排空来读取。 - 缺少搜索二进制时打印的 shell crawler 诊断现已有解释。渲染器库把全局 console 替换成一个捕获流,并把 console 输出收进它自己的内置控制台,所以那些警告根本到 不了终端;ink 没有这类拦截,任由它们落进 scrollback。触发条件是环境性的 —— @@ -812,8 +838,10 @@ ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态 内容。 - 状态栏相关设置被忽略。ink 会渲染至多两行由用户配置的命令产出的内容,按它自己的 刷新间隔与颜色选择;本渲染器则硬编码一行目录、会话、分支与模型。 -- composer 宣传了一个它并未绑定的排队键。它的退出键还会在草稿非空时武装那个按两次 - 的窗口,并在这个过程中吃掉一个字符;而 ink 在缓冲区还有文本时根本不会武装。 +- composer 宣传了一个它并未绑定的排队键。决定 30 把这件事放大了:移植来的提示行会 + 把那个键的名字打印到屏幕上,于是这个失效的可供性从「仅有记录」变成「看得见」。它 + 的退出键还会在草稿非空时武装那个按两次的窗口,并在这个过程中吃掉一个字符;而 ink + 在缓冲区还有文本时根本不会武装。 - ink 的提问对话框会对一次击键把手打的自由文本答案答两遍:对话框自己的按键处理 与它挂载的输入控件都订阅了按键,而按键层没有焦点栈在两者之间仲裁。每次回答都 推进一个标签页,所以多问题对话框上,正被回答那题之后的那题会被跳过、根本没被 diff --git a/packages/cli/src/ui/opentui/live-turn.test.ts b/packages/cli/src/ui/opentui/live-turn.test.ts index 657d3a5b338..b234c2e82d8 100644 --- a/packages/cli/src/ui/opentui/live-turn.test.ts +++ b/packages/cli/src/ui/opentui/live-turn.test.ts @@ -259,7 +259,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { result.current.submit('look @notes.md'); }); - expect(result.current.queueLength).toBe(1); + expect(result.current.messageQueue).toEqual(['look @notes.md']); await act(async () => { for (const wake of live.waiters.splice(0)) wake(); @@ -274,7 +274,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { expect(live.turns[1]?.options).toMatchObject({ submittedPrompt: 'look @notes.md', }); - expect(result.current.queueLength).toBe(0); + expect(result.current.messageQueue).toEqual([]); }); it('consumes only its own submission when a replayed turn declines (R2-2)', async () => { @@ -296,7 +296,10 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { result.current.submit('then do the other thing'); }); - expect(result.current.queueLength).toBe(2); + expect(result.current.messageQueue).toEqual([ + 'look @notes.md', + 'then do the other thing', + ]); await act(async () => { for (const wake of live.waiters.splice(0)) wake(); @@ -305,7 +308,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { expect(live.turns[1]?.prompt).toBe('look @notes.md'); expect(live.turns[2]?.prompt).toBe('then do the other thing'); - expect(result.current.queueLength).toBe(0); + expect(result.current.messageQueue).toEqual([]); }); it('restores a dropped steering batch at the front of the queue', () => { @@ -323,7 +326,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { result.current.submit('queued after'); }); - expect(result.current.queueLength).toBe(1); + expect(result.current.messageQueue).toEqual(['queued after']); const { restoreSteering } = live.turns[0]?.options as unknown as { restoreSteering: (texts: readonly string[]) => void; @@ -331,16 +334,41 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { restoreSteering([' steer me ', '', 'then @b.ts']); }); - expect(result.current.queueLength).toBe(3); + expect(result.current.messageQueue).toEqual([ + 'steer me', + 'then @b.ts', + 'queued after', + ]); let popped: string | null = null; act(() => { popped = result.current.popQueue(); }); - expect(result.current.queueLength).toBe(0); + expect(result.current.messageQueue).toEqual([]); expect(popped).toBe('steer me\n\nthen @b.ts\n\nqueued after'); }); + + it('drops the visible queue rows when the transcript resets', () => { + // `/clear` and a session switch rebuild the transcript. A stale row would + // keep advertising a prompt the following turn never runs. + const { result } = renderHook(() => + useOpenTuiLiveTurn({ config: {} as Config }), + ); + + act(() => { + result.current.submit('first prompt'); + }); + act(() => { + result.current.submit('queued after'); + }); + expect(result.current.messageQueue).toEqual(['queued after']); + + act(() => { + result.current.resetTranscript([]); + }); + expect(result.current.messageQueue).toEqual([]); + }); }); describe('useOpenTuiLiveTurn streaming counters', () => { diff --git a/packages/cli/src/ui/opentui/live-turn.ts b/packages/cli/src/ui/opentui/live-turn.ts index 0ddc702f218..87631a2ed20 100644 --- a/packages/cli/src/ui/opentui/live-turn.ts +++ b/packages/cli/src/ui/opentui/live-turn.ts @@ -131,8 +131,8 @@ export interface OpenTuiLiveTurn { isReceivingContent: boolean; /** Scheduler calls parked in awaiting_approval, awaiting a dialog. */ waitingCalls: readonly WaitingCallInfo[]; - /** Number of mid-turn prompts queued (composer queueLength parity). */ - queueLength: number; + /** Mid-turn queued prompts, oldest first (composer badge + Esc pop-back). */ + messageQueue: readonly string[]; /** Pops the whole queue back into the composer (Esc parity). */ popQueue(): string | null; /** @@ -173,7 +173,10 @@ export function useOpenTuiLiveTurn( [], ); const queueRef = useRef([]); - const [queueLength, setQueueLength] = useState(0); + // queueRef stays the synchronous source — a turn drains and restores within + // one tick, before any render. This mirror only exists to be displayed. + const [queue, setQueue] = useState([]); + const syncQueue = useCallback(() => setQueue([...queueRef.current]), []); const abortRef = useRef(null); const streamingRef = useRef(false); @@ -207,24 +210,30 @@ export function useOpenTuiLiveTurn( setItems((prev) => foldLiveEvent(prev, ev)); }, []); - const pushQueue = useCallback((text: string) => { - queueRef.current.push(text); - setQueueLength(queueRef.current.length); - }, []); + const pushQueue = useCallback( + (text: string) => { + queueRef.current.push(text); + syncQueue(); + }, + [syncQueue], + ); const drainQueue = useCallback((): string[] => { const drained = queueRef.current; queueRef.current = []; - setQueueLength(0); + syncQueue(); return drained; - }, []); + }, [syncQueue]); - const restoreQueue = useCallback((texts: readonly string[]) => { - const restored = texts.map((text) => text.trim()).filter(Boolean); - if (restored.length === 0) return; - queueRef.current = [...restored, ...queueRef.current]; - setQueueLength(queueRef.current.length); - }, []); + const restoreQueue = useCallback( + (texts: readonly string[]) => { + const restored = texts.map((text) => text.trim()).filter(Boolean); + if (restored.length === 0) return; + queueRef.current = [...restored, ...queueRef.current]; + syncQueue(); + }, + [syncQueue], + ); const runTurn = useCallback( async ( @@ -315,7 +324,7 @@ export function useOpenTuiLiveTurn( if (rest.length > 0) { const [text, ...remaining] = rest; queueRef.current = remaining; - setQueueLength(remaining.length); + syncQueue(); apply({ type: 'user', text }); // ink keeps provenance for a queued submission, and the raw text // is what the stream layer expands `@path` mentions from. @@ -326,7 +335,7 @@ export function useOpenTuiLiveTurn( } } }, - [config, apply, drainQueue, restoreQueue, setBusy, setReceiving], + [config, apply, drainQueue, restoreQueue, setBusy, setReceiving, syncQueue], ); const submit = useCallback( @@ -387,7 +396,7 @@ export function useOpenTuiLiveTurn( } abort?.abort(); queueRef.current = []; - setQueueLength(0); + syncQueue(); waitingCallsRef.current = []; setWaitingCalls([]); // Synchronous: a submit right after the reset must start a fresh turn, @@ -395,7 +404,7 @@ export function useOpenTuiLiveTurn( setBusy(false); setItems(foldBatch(events)); }, - [setBusy], + [setBusy, syncQueue], ); const settleWaitingCall = useCallback((callId: string) => { @@ -419,7 +428,7 @@ export function useOpenTuiLiveTurn( streamingCharsRef, isReceivingContent, waitingCalls, - queueLength, + messageQueue: queue, popQueue, submit, interrupt, diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index e4550286dcc..db3b67db080 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -1985,4 +1985,18 @@ describe('OpenTuiApp transcript scroll region', () => { expect(region?.textContent).not.toContain('dialog:theme'); expect(chrome?.textContent).toContain('dialog:theme'); }); + + it('shows queued prompts in the chrome, above the composer itself', async () => { + // ink prints them inside the Composer column, so they must share the + // non-scrolling rows rather than scroll away with the conversation. + renderApp({ + renderMain: () =>
TRANSCRIPT-ROWS
, + messageQueue: ['QUEUE_ROW_MARKER'], + }); + await settle(); + const { region, chrome } = readLayout(); + expect(chrome?.textContent).toContain('QUEUE_ROW_MARKER'); + expect(chrome?.textContent).toContain('input-prompt'); + expect(region?.textContent).not.toContain('QUEUE_ROW_MARKER'); + }); }); diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index deba9852c0d..8d2dfb9e4c0 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -92,6 +92,7 @@ import { OpenTuiDialogMount } from './opentui-dialog-mount.js'; import { OpenTuiInputPrompt } from './input-prompt.js'; import { OpenTuiBanner } from './opentui-header.js'; import { OpenTuiFooter, OpenTuiLoadingIndicator } from './opentui-footer.js'; +import { OpenTuiQueuedMessageDisplay } from './queued-message.js'; import { OpenTuiActionConfirmation, OpenTuiMcpApprovalDialog, @@ -177,8 +178,8 @@ export interface OpenTuiAppProps { /** Aborts the in-flight turn (Esc while streaming). */ onInterrupt?: () => void; approvalMode?: ApprovalMode; - /** Mid-turn queued prompts (composer badge + Esc pop-back). */ - queueLength?: number; + /** Mid-turn queued prompts (queue rows + badge + Esc pop-back). */ + messageQueue?: readonly string[]; onPopQueue?: () => string | null; /** * Scheduler calls parked in `awaiting_approval`. The shell renders the @@ -240,7 +241,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { isReceivingContent, onInterrupt, approvalMode, - queueLength, + messageQueue, onPopQueue, waitingToolCalls, onToolCallSettled, @@ -250,6 +251,8 @@ export function OpenTuiApp(props: OpenTuiAppProps) { onPromptSuggestionAbort, } = props; + const queueLength = messageQueue?.length ?? 0; + const [dialog, setDialog] = useState( props.initialDialog ?? null, ); @@ -1032,6 +1035,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { streamingCharsRef={streamingCharsRef} isReceivingContent={isReceivingContent} /> + { void onSubmit(text, imagePaths); diff --git a/packages/cli/src/ui/opentui/queued-message.test.tsx b/packages/cli/src/ui/opentui/queued-message.test.tsx new file mode 100644 index 00000000000..d5e9dbca975 --- /dev/null +++ b/packages/cli/src/ui/opentui/queued-message.test.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment jsdom + +/** + * ink `QueuedMessageDisplay` parity: the queued prompts, their three-row cap, + * the overflow row, the hint that fades after three reveals, and the + * single-line truncation ink gets from `wrap="truncate"`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +const mocks = vi.hoisted(() => { + async function buildJsxRuntime() { + const React = await import('react'); + const jsx = ( + type: unknown, + props: { children?: unknown; key?: React.Key } | null, + key?: React.Key, + ) => { + const config = key === undefined ? props : { ...props, key }; + const children = (config?.children ?? null) as React.ReactNode; + if (type === 'box' || type === 'text') { + return React.createElement( + type === 'box' ? 'div' : 'span', + key === undefined ? null : { key }, + children, + ); + } + return React.createElement( + type as React.ElementType, + config as Record, + children, + ); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; + } + return { + buildJsxRuntime, + state: { dimensions: { width: 100, height: 40 } }, + }; +}); + +// theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI +// native FFI — unavailable in the test runtime. Stub the graphics surface. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +vi.mock('@opentui/react', () => ({ + useTerminalDimensions: () => mocks.state.dimensions, +})); +vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); +vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); + +import { OpenTuiQueuedMessageDisplay } from './queued-message.js'; + +const queueRows = (messageQueue: readonly string[]): ReactElement => ( + +); + +describe('OpenTuiQueuedMessageDisplay', () => { + it('renders nothing while the queue is empty', () => { + const { container } = render(queueRows([])); + expect(container.textContent).toBe(''); + }); + + it('shows each queued prompt on one flattened line', () => { + const { container } = render( + queueRows(['first\n line\ttabbed', 'second']), + ); + const rows = [...container.querySelectorAll('div > span')]; + expect(rows.map((row) => row.textContent)).toEqual([ + 'first line tabbed', + 'second', + 'Ctrl+Q to queue · ↑ to edit queued messages', + ]); + }); + + it('caps the list at three rows and counts what it folds away', () => { + const { container } = render(queueRows(['one', 'two', 'three', 'four'])); + const text = container.textContent ?? ''; + expect(text).toContain('three'); + expect(text).not.toContain('four'); + expect(text).toContain('... (+1 more)'); + }); + + it('drops the hint once it has been shown three times', () => { + // ink counts reveals, not renders: the hint fades once the user has seen + // the queue appear three times. + const { container, rerender } = render(queueRows([])); + const oneReveal = () => { + rerender(queueRows(['queued'])); + const shown = (container.textContent ?? '').includes('Ctrl+Q'); + rerender(queueRows([])); + return shown; + }; + expect([1, 2, 3, 4].map(oneReveal)).toEqual([true, true, true, false]); + }); + + it("truncates a prompt to the row's own width, like ink's wrap=truncate", () => { + mocks.state.dimensions = { width: 22, height: 40 }; + try { + const { container } = render(queueRows(['q'.repeat(80)])); + const row = container.querySelector('div > span'); + expect(row?.textContent).toBe(`${'q'.repeat(19)}…`); + } finally { + mocks.state.dimensions = { width: 100, height: 40 }; + } + }); +}); diff --git a/packages/cli/src/ui/opentui/queued-message.tsx b/packages/cli/src/ui/opentui/queued-message.tsx new file mode 100644 index 00000000000..1437ee09fe8 --- /dev/null +++ b/packages/cli/src/ui/opentui/queued-message.tsx @@ -0,0 +1,83 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI port of ink's `QueuedMessageDisplay`: the prompts the user submitted + * while a turn was in flight, shown between the waiting row and the composer + * until the following turn consumes them. + */ + +import { useRef } from 'react'; +import { useTerminalDimensions } from '@opentui/react'; +import { t } from '../../i18n/index.js'; +import { truncateToWidth } from '../utils/textUtils.js'; +import { C } from './theme.js'; + +const MAX_DISPLAYED_QUEUED_MESSAGES = 3; +const NUM_TIMES_QUEUE_HINT_SHOWN = 3; +/** ink's `wrap="truncate"` on a box that pads two columns and spans the rest. */ +const QUEUE_ROW_INDENT = 2; + +export interface OpenTuiQueuedMessageDisplayProps { + messageQueue: readonly string[]; +} + +export const OpenTuiQueuedMessageDisplay = ({ + messageQueue, +}: OpenTuiQueuedMessageDisplayProps) => { + const { width } = useTerminalDimensions(); + // Counts how many times the edit hint has been shown, so it stops after a + // few. ink keys this on the empty → non-empty transition, and its counter + // resets whenever the composer unmounts — which happens for a dialog here + // too, since the composer branch is swapped out the same way. + const hintSeenCountRef = useRef(0); + const wasEmptyRef = useRef(true); + + if (messageQueue.length === 0) { + wasEmptyRef.current = true; + return null; + } + + if (wasEmptyRef.current) { + hintSeenCountRef.current++; + wasEmptyRef.current = false; + } + + const shouldShowHint = hintSeenCountRef.current <= NUM_TIMES_QUEUE_HINT_SHOWN; + const rowBudget = Math.max(0, width - QUEUE_ROW_INDENT); + + return ( + + {messageQueue + .slice(0, MAX_DISPLAYED_QUEUED_MESSAGES) + .map((message, index) => ( + + + {truncateToWidth(message.replace(/\s+/g, ' '), rowBudget)} + + + ))} + {messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && ( + + + {`... (+${ + messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES + } more)`} + + + )} + {shouldShowHint && ( + + + {t('Ctrl+Q to queue · ↑ to edit queued messages')} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx index ed95e2bd584..b73d6d4fb8b 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx @@ -107,7 +107,7 @@ vi.mock('./live-turn.js', () => ({ streamingCharsRef: { current: 0 }, isReceivingContent: false, waitingCalls: [], - queueLength: 0, + messageQueue: [], popQueue: () => null, submit: () => {}, interrupt: () => {}, diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx index adf813dc7b0..ec4b686b199 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -308,7 +308,7 @@ function OpenTuiEntryApp({ isReceivingContent={live.isReceivingContent} onInterrupt={interrupt} approvalMode={config.getApprovalMode()} - queueLength={live.queueLength} + messageQueue={live.messageQueue} onPopQueue={live.popQueue} waitingToolCalls={live.waitingCalls} onToolCallSettled={live.settleWaitingCall} From f85567fea17153aef07ab9aa3485fd9977f59afc Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 22:42:16 +0800 Subject: [PATCH 18/51] docs: correct the mutation counts recorded for the queued prompt rows The recorded coverage claimed nine mutations, one per component behaviour and per mirror site, each failing its own test alone. Re-running the set says otherwise: eleven mutations, and the mirror sites do not map one to one because a list never given its first entry, or never emptied, is asserted turns later. --- docs/design/2026-09-10-opentui-parity-defect-sweep.md | 10 +++++++--- .../2026-09-10-opentui-parity-defect-sweep.zh-CN.md | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 2e2d4d0b1e8..9c900141f6d 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -953,9 +953,13 @@ cap with its overflow row, the hint that stops after the fourth fill, a row cut to the terminal width minus ink's indent), the hook's assertions on the queued texts at each of those sites plus the reset, and a shell test that fixes the placement — rows in the persistent chrome between the loading indicator and the -composer, not in the scroll region. Nine mutations, one at each of the four -component behaviours, four of the five mirror sites and the mount, each fail -their own test and no other. +composer, not in the scroll region. Eleven mutations — one at each component +behaviour, one at each mirror site and one at the mount — each fail at least one +test. The five component rows and the mount fail their own and no other; the +mirror sites do not map one to one, because a list never given its first entry, +or never emptied, is still being asserted turns later: draining without +mirroring fails one assertion, pushing without it fails four, popping for +editing two. ## Coverage boundary diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index b2a7819f3c3..e3f3921a5ec 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -689,9 +689,11 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三行 的上限与它的溢出行、第四次填满后不再出现的提示、按终端宽度减掉 ink 缩进截断的一行), hook 在上述每一处以及那次重置上对被排队文本的断言,以及一条钉住位置的 shell 测试 —— -这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动区域中。九个 -变异分别落在四个组件行为、五个镜像点中的四个、以及挂载处,每个只让属于自己的那条测试 -失败。 +这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动区域中。十一 +个变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让一条测试失败。五 +条组件行与挂载处只让属于自己的那条失败;镜像点做不到一一对应,因为一个从未拿到第一条 +、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条断言失败,推入时不镜 +像让四条失败,弹出编辑让两条失败。 ## 覆盖边界 From 85c100afacde49db2fb19d572d97cbed0622f1b2 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 22:46:44 +0800 Subject: [PATCH 19/51] docs: state the queued row hint's cut-off and its assertion layers exactly The coverage sentence read as if the hint survived its fourth fill, and listed the transcript reset twice: once among the five sites that mirror the queue and again as an extra assertion layer. --- .../2026-09-10-opentui-parity-defect-sweep.md | 22 +++++++++---------- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 16 +++++++------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 9c900141f6d..0800c25cf29 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -949,17 +949,17 @@ why no scenario in the matrix produces a durable queue on either leg. Coverage is unit-level across three layers: five component tests (nothing when the queue is empty, one row per prompt with whitespace flattened, the three-row -cap with its overflow row, the hint that stops after the fourth fill, a row cut -to the terminal width minus ink's indent), the hook's assertions on the queued -texts at each of those sites plus the reset, and a shell test that fixes the -placement — rows in the persistent chrome between the loading indicator and the -composer, not in the scroll region. Eleven mutations — one at each component -behaviour, one at each mirror site and one at the mount — each fail at least one -test. The five component rows and the mount fail their own and no other; the -mirror sites do not map one to one, because a list never given its first entry, -or never emptied, is still being asserted turns later: draining without -mirroring fails one assertion, pushing without it fails four, popping for -editing two. +cap with its overflow row, the hint that shows on three fills and not the +fourth, a row cut to the terminal width minus ink's indent), the hook's +assertions on the queued texts at each of those sites, and a shell test that +fixes the placement — rows in the persistent chrome between the loading +indicator and the composer, not in the scroll region. Eleven mutations — one at +each component behaviour, one at each mirror site and one at the mount — each +fail at least one test. The five component rows and the mount fail their own +and no other; the mirror sites do not map one to one, because a list never +given its first entry, or never emptied, is still being asserted turns later: +draining without mirroring fails one assertion, pushing without it fails four, +popping for editing two. ## Coverage boundary diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index e3f3921a5ec..35fa1a8cddf 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -686,14 +686,14 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 出,但读那张表的解析器没有任何生产调用方,于是这次按键落成普通文本。这些行在这里只在 一个回合的长度内可达,这也正是矩阵里没有任何场景在任一腿上产生持久队列的原因。 -覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三行 -的上限与它的溢出行、第四次填满后不再出现的提示、按终端宽度减掉 ink 缩进截断的一行), -hook 在上述每一处以及那次重置上对被排队文本的断言,以及一条钉住位置的 shell 测试 —— -这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动区域中。十一 -个变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让一条测试失败。五 -条组件行与挂载处只让属于自己的那条失败;镜像点做不到一一对应,因为一个从未拿到第一条 -、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条断言失败,推入时不镜 -像让四条失败,弹出编辑让两条失败。 +覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三 +行的上限与它的溢出行、前三次填满时出现而第四次不再出现的提示、按终端宽度减掉 ink +缩进截断的一行),hook 在上述每一处对被排队文本的断言,以及一条钉住位置的 shell +测试 —— 这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动 +区域中。十一个变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让 +一条测试失败。五条组件行与挂载处只让属于自己的那条失败;镜像点做不到一一对应,因为 +一个从未拿到第一条、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条 +断言失败,推入时不镜像让四条失败,弹出编辑让两条失败。 ## 覆盖边界 From 2deca65d8c8cd36cd449278003d1e2e23445c032 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Mon, 14 Sep 2026 23:53:22 +0800 Subject: [PATCH 20/51] fix(cli): record the model dialog's outcome like ink does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row a `/model` dialog leaves behind reached the transcript but not the session log, so resuming a session dropped the model a pick settled on or the one an escape kept, where ink replays it. The dispatcher cannot carry it: its result phase closes while the dialog is still open, with an empty output list, and ink writes that same empty pair. Ink then pairs each of its three outcome sites with a second result-phase record naming the command and carrying that one row, which is what this commit adds here. The pairing goes over one object, so the row shown and the row recorded are the same value built once and cannot drift apart. Two silences are deliberate: a pick that fails to apply records nothing, because ink keeps the dialog open with the error and a recorded row would replay a switch that never happened; and the dialog adds no invocation-phase record, because the bare command's own invocation is already written, hidden, by the dispatcher. Three existing cases in the dialog suite now assert the recorder alongside the row and a new one covers the failed pick. Five mutations — no record, wrong phase, wrong command, empty payload, a failure that reports anyway — each fail at least one of them. On a real machine the slash-dialog scenario was run on both legs: the two session logs now hold nine `slash_command` records in the same order with the same flags, the fifth being the dialog's own result carrying `Kept model as fake-model` on both sides. What is not shown on screen is the replay itself; no scenario resumed a session, so that step rests on source reading, and the design doc records the boundary. The sweep also surfaced two parity classes left open rather than widened into here: most dialogs answer through a notice slot that the next submit clears, so their outcomes never reach history or the log at all, and an auxiliary model pick is a success row in ink where every model outcome here is an info row. Both are documented as follow-ups. --- .../2026-09-10-opentui-parity-defect-sweep.md | 60 +++++++++++++++++++ ...09-10-opentui-parity-defect-sweep.zh-CN.md | 42 +++++++++++++ .../ui/opentui/opentui-dialog-mount.test.tsx | 38 +++++++++++- .../src/ui/opentui/opentui-dialog-mount.tsx | 15 ++++- 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 0800c25cf29..25ae84b8393 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -961,6 +961,37 @@ given its first entry, or never emptied, is still being asserted turns later: draining without mirroring fails one assertion, pushing without it fails four, popping for editing two. +## Decision 31 — a model dialog outcome is recorded as well as shown + +The row a `/model` dialog leaves behind — the model a pick settled on, or the +one an escape kept — reached the transcript but not the session log, so resuming +the session dropped it where ink keeps it. The dispatcher cannot carry it: that +result phase closes while the dialog is still open, with an empty output list, +and ink writes the same empty pair. So ink pairs each of its three outcome sites +with a second result-phase record naming the command and carrying that one row. +This renderer now makes the same pairing, over one object: the row shown and the +row recorded are the same value, built once, so the two cannot drift apart. + +Two silences are deliberate. A pick that fails to apply records nothing — ink +keeps the dialog open with the error, and a recorded row would replay a switch +that never happened. And the dialog adds no invocation-phase record: the bare +command's own invocation is already written, hidden, by the dispatcher, so a +second one would double it. + +Coverage is unit-level plus one on-disk check. Three existing cases in the +dialog suite now assert the recorder alongside the row — once for an escape that +arrives twice, once for a pick still being applied, once for a repeat pick after +it lands — and a new fourth asserts a failed pick leaves no trace in either. Five +mutations — no record at all, the wrong phase, the wrong command name, an empty +payload, a failure that reports anyway — each fail at least one test. On the +real machine the slash-dialog scenario opens `/model` and escapes it on both +legs: each session log now holds nine `slash_command` records, in the same order +and with the same flags, the fifth being the dialog's own result carrying +`Kept model as fake-model` on both sides. What is not shown on screen is the +replay: the shared resume loader reads this payload shape and this renderer's +session switch calls it, but no scenario resumed a session to watch the row come +back. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -991,6 +1022,11 @@ What was verified, and how far the verification reaches: the deferral ink puts behind Ctrl+Q; this renderer does not bind that key, and no scenario presses it on either leg — so the texts, like the badge, exist only inside one turn. +- **Decision 31's recorded row is verified on disk, not on screen.** The + scenario that opens `/model` and escapes it was run on both legs and the two + session logs compared record by record. No scenario resumed a session, so the + replay of that row back onto the screen — through the shared loader this + renderer's session switch calls — rests on source reading alone. - **The non-shrinkable row prefix and the per-item top margin are verified by re-capture only.** The unit-test runtime stubs the renderer's graphics surface, so it cannot exercise layout; a test there could only echo the prop @@ -1175,6 +1211,30 @@ What was verified, and how far the verification reaches: affordance is now visible rather than merely documented. Its exit key also arms the two-press window with a non-empty draft and eats a character doing it, where ink declines to arm at all while the buffer holds text. +- Most dialogs answer in a slot that does not persist. The arena select and + stop dialogs, the editor dialog, the output-style dialog and the auth + dialog's success report answer through the shell's notice slot, which the next + submit clears; the effort dialog's pick says nothing at all. ink adds a + transcript row for every one of those outcomes and records it, so a resumed + session replays them and this one cannot. Closing them is Decision 31's shape + twice over — the row has to be added as well as recorded, since a record with + no row would replay a line the user never saw. `/statusline` is further + behind: this renderer's statusline dialog only lists the presets and closes on + Escape, so it has no save action to record — a missing feature rather than a + missing record. ink's UI layer records at fifteen sites, thirteen of them + outside the dispatcher's own invocation/result pair. Three of those thirteen + need nothing here: the theme one carries only `/theme`'s `NO_COLOR` message, + which this renderer already routes through the dispatcher's recording wrapper + (a theme selection adds no row on either side), the arena command's recorder + is shared code this renderer runs the same way, and the away-summary site + sits in a hook only ink's own container mounts. +- An auxiliary model pick is a different kind of row. ink reports a fast, voice + or vision selection as a success item — its own glyph and colour — while every + model outcome here, primary or auxiliary, goes through one info row, so the + pick reads plainer on screen and, since Decision 31 records the row that was + shown, in the session log too. Reachable on this renderer: the dispatcher + builds fast, voice and vision dialog requests. Fixing it means carrying a row + type out of the selection helper instead of a message string. - ink answers a typed free-text entry in its question dialog twice for one keystroke, because the dialog's key handler and the input widget it mounts both subscribe and the key layer has no focus stack to arbitrate between diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 35fa1a8cddf..8ac20f7387d 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -695,6 +695,28 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 一个从未拿到第一条、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条 断言失败,推入时不镜像让四条失败,弹出编辑让两条失败。 +## 决定 31 —— 模型对话框的结果,既显示也记录 + +`/model` 对话框留下的那一行 —— 一次选取定下的模型,或是一次退出保住的模型 —— 只到了 +转录,没到会话日志,于是恢复会话时它丢了,而 ink 会把它放回来。分发器兜不住:它的那 +个 result 阶段在对话框还开着时就结束了,输出列表是空的,而 ink 写的也是同样这一对空 +的。所以 ink 把自己三处结果点每一处都再配一条 result 阶段的记录,念出命令名、带上那 +一行。本渲染器现在做同样的配对,并且是同一个对象:显示的行与被记录的行是一次构建出 +来的同一个值,两者无从分叉。 + +两处沉默是有意的。一个没能落地的选取什么都不记录 —— ink 会让对话框带着错误继续开着, +记下这一行就会在恢复时重放一次从未发生的切换。而对话框不会补一条 invocation 阶段的 +记录:裸命令自己的调用已由分发器写好、且被隐去,再来一条就是重复。 + +覆盖是单元层加一次磁盘核对。对话框测试套件里三条已有用例现在连同那一行一起断言记录 +器 —— 一条管两次到达的退出、一条管仍在应用中的选取、一条管落地之后的重复选取 —— +新增的第四条断言失败的选取在两处都不留下痕迹。五个变异 —— 完全不记录、阶段写错、命 +令名写错、负载为空、失败也照记 —— 每个至少让一条测试失败。实机侧,slash 对话框场景在 +两条腿上都打开 `/model` 再退出:各自的会话日志现在都有九条 `slash_command` 记录,顺序 +与标记一致,第五条正是对话框自己的 result,两边都带着 `Kept model as fake-model`。没 +在屏幕上演的是重放:共用的恢复加载器读的就是这个负载形状,本渲染器的会话切换也调用了 +它,但没有任何场景真的恢复过一次会话、看着这一行回来。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -716,6 +738,10 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 路径里作为一帧瞬态存在于裸流中,两腿都从未进入任何一个抓取帧。活得比回合更久的队列 需要 ink 放在 Ctrl+Q 后面的那次推迟;本渲染器没有绑定这个键,而没有任何场景在任何一 条腿上按它 —— 所以这些文本和徽标一样,只存在于一个回合之内。 +- **决定 31 记录下来的那一行,只在磁盘上验证过,没在屏幕上验证。** 打开 `/model` 再 + 退出它的那个场景在两条腿上都跑过,两份会话日志被逐条记录比对。没有任何场景恢复过会 + 话,所以这一行经共用加载器重新回到屏幕 —— 本渲染器的会话切换调用的就是它 —— 只建立 + 在读源码之上。 - **不可压缩的行前缀与按条目上边距仅靠重新抓取验证。** 单元测试运行时会桩掉 渲染器的图形表面,因此无法执行布局;在那里的测试只能把这个 prop 原样回显。 - **纵向间距直到最后一次运行之前都在帧证据之外。** 比对会把每一帧归约为一串非 @@ -844,6 +870,22 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 把那个键的名字打印到屏幕上,于是这个失效的可供性从「仅有记录」变成「看得见」。它 的退出键还会在草稿非空时武装那个按两次的窗口,并在这个过程中吃掉一个字符;而 ink 在缓冲区还有文本时根本不会武装。 +- 多数对话框把回答落在一个不会留存的提示位里。arena 的选取与停止对话框、编辑器对话 + 框、输出风格对话框,以及认证对话框的成功播报,都经由 shell 的 notice 槽作答,而下一 + 次提交就会把它清掉;effort 对话框的选取则什么都不说。ink 为这些结果逐一添加转录行并 + 记录,所以恢复会话时那边能重放、这边不能。补这些是决定 31 的形状做两遍 —— 先把行加 + 上,再记录;只记录不加行,会在恢复时重放一行用户从未见过的话。`/statusline` 更靠在 + 后:本渲染器的 statusline 对话框只列出预设项、按 Escape 关闭,根本没有保存动作可记 + —— 缺口落在一个缺失的功能下游,而不是一条缺失的记录。ink 的 UI 层有十五处记录点, + 其中十三处在分发器自己那对 invocation/result 之外;这十三处里有三处在这里什么都不 + 必补:theme 那处只带 `/theme` 的 `NO_COLOR` 消息,而本渲染器已经把它走分发器的记录 + 包装(选主题本身两边都不加行)、arena 命令的记录器是两边跑同一份的共用代码、 + away-summary 那处住在只有 ink 自己的容器会挂载的 hook 里。 +- 一次辅助模型的选取是另一种类型的行。ink 把 fast、voice、vision 的选取报成 success 条 + 目 —— 它有自己的字形与颜色 —— 而这里所有模型结果,主要的或辅助的,都走同一条 info + 行,于是它在屏幕上显得更平,自决定 31 记录了被显示的那一行之后,在会话日志里也一 + 样。本渲染器这条路径可达:分发器会构造 fast、voice、vision 的对话框请求。要修就得让 + 选取助手带出行类型,而不只是一条消息字符串。 - ink 的提问对话框会对一次击键把手打的自由文本答案答两遍:对话框自己的按键处理 与它挂载的输入控件都订阅了按键,而按键层没有焦点栈在两者之间仲裁。每次回答都 推进一个标签页,所以多问题对话框上,正被回答那题之后的那题会被跳过、根本没被 diff --git a/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx b/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx index 0e8619ef387..0d26168c17c 100644 --- a/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx @@ -206,7 +206,11 @@ vi.mock('./dialogs-misc.js', () => ({ OpenTuiTrustDialog: mocks.stub('trust'), })); -const CONFIG = { getModel: () => 'fake-model' } as unknown as Config; +const recordSlashCommand = vi.fn(); +const CONFIG = { + getModel: () => 'fake-model', + getChatRecordingService: () => ({ recordSlashCommand }), +} as unknown as Config; const SETTINGS = { merged: {} } as unknown as LoadedSettings; const addItem = vi.fn(); const HOST = { @@ -459,6 +463,15 @@ describe('OpenTuiDialogMount routing', () => { expect(addItem.mock.calls[0]![0]).toMatchObject({ text: expect.stringContaining('Kept model as'), }); + // ink ModelDialog records the row it adds, so a resumed session replays it. + expect(recordSlashCommand).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'info', text: expect.stringContaining('Kept model as') }, + ], + }); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -475,11 +488,17 @@ describe('OpenTuiDialogMount routing', () => { dialogProp('model', 'onSelect')('fake-model'); dialogProp('model', 'onClose')(); expect(addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); await act(async () => { settle({ ok: true, message: 'Model set to fake-model' }); }); expect(addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [{ type: 'info', text: 'Model set to fake-model' }], + }); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -506,9 +525,26 @@ describe('OpenTuiDialogMount routing', () => { select('fake-model'); expect(applyModelSelection).toHaveBeenCalledTimes(1); expect(addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); + it('records nothing for a pick that fails to apply', async () => { + vi.mocked(applyModelSelection).mockImplementationOnce(async () => ({ + ok: false, + error: 'Selected model is unavailable.', + })); + const onClose = vi.fn(); + mount({ dialog: 'model', mode: 'primary' }, { onClose }); + dialogProp('model', 'onSelect')('fake-model'); + await act(async () => {}); + // ink keeps the dialog open with the error: a row here would let a resume + // replay a switch that never landed. + expect(addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + it('throws for an unhandled dialog kind', () => { const bad = { dialog: 'nope' } as unknown as OpenTuiDialogRequest; expect(() => mount(bad)).toThrow(/Unhandled OpenTUI dialog request/); diff --git a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx index e2823b71cc1..a4d34abf4f0 100644 --- a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx +++ b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx @@ -35,6 +35,7 @@ import type { OpenTuiDialogRequest } from './commands-registry.js'; import type { OpenTuiAppHost } from './opentui-host.js'; import { toOriginalKey } from './key-map.js'; import { t } from '../../i18n/index.js'; +import type { HistoryItemInfo } from '../types.js'; import { MessageType } from '../types.js'; import { HelpOverlay } from './help-overlay.js'; import { @@ -589,8 +590,18 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { // ink writes all three model outcomes to the transcript — a pick, an // escape, an auxiliary pick — so the row outlives the dialog. The shell's // notify slot is transient and closes with it. - const reportModel = (text: string) => - host.addItem({ type: MessageType.INFO, text }, Date.now()); + const reportModel = (text: string) => { + const item: HistoryItemInfo = { type: MessageType.INFO, text }; + host.addItem(item, Date.now()); + // ink ModelDialog records the same row it adds: the dispatcher's result + // phase closes while the dialog is still open, so nothing else here + // would replay on resume. + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [{ ...item }], + }); + }; return ( Date: Tue, 15 Sep 2026 02:36:37 +0800 Subject: [PATCH 21/51] fix(cli): let OpenTUI dialog fields move the caret like ink's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every text field in these dialogs kept its value in the dialog's own state and only ever appended to it, so the keyboard could add a character and drop the last one: the arrows, Home and End did nothing, and a mistyped character in the middle of a value could only be repaired by erasing everything after it. ink hands the same fields to one text input component whose editing lives in a buffer that measures the caret in code points, so those keys move it there. The buffer is not a rewrite of what ink's ought to do. It is a one-line slice of ink's own operations, and its differential suite holds no hand-written expectations: each fixture drives the same keystrokes through ink's reducer and this model and compares the text and the caret offset after every step — across a paste that leaves a line break behind, across code points ink weighs as one cell or two, and across both walls when the caret is already at one. Word segmentation is reused as ink's, with two helpers that were module-private now exported so what ctrl+W erases is what ink erases; the key order is ink's too, taking only the keys a one-line field can receive and handing every other key back to the dialog, which is what ink needs because its dialog stops handling the arrows once a field owns the row. Word jumps, delete-word-right, kill-line and undo/redo are recorded as not ported, and ink binds all four. Two absences are forced: ctrl+D belongs to the app's global exit binding, and a field whose value is replaced wholesale pulls its caret back inside the string rather than leaving it past the end. Three rendering differences are kept and documented: ink windows the field at a fixed column count and shows only the caret's line, so a multi-line paste hides what it cannot see, while these rows render the whole value; ink blinks the cursor cell every 530 ms, where a steady cell spares the dialog a repaint timer — measurable, not just source, since in a scenario parked on one of these rows ink's leg wrote 270 KB per minute sitting there and burned four of seven idle waits in full, taking 251 s against this renderer's 14; and the cell carries the theme accent, as the composer's cursor does, where ink paints gray read from the terminal background. Mount semantics differ per field because ink's do. The question dialog mounts the row's input only while its option is selected, at the end of the stored value, so this port treats the row becoming selected again as a fresh mount and drops a caret left mid-value; the wizard's custom-model field is not unmounted when the list below takes focus, so Tab returns it to where it was left. One field's text is not the model's either. The context-window setter keeps digits only and this port renders what it is handed, so a character that setter rejects — a letter, or the Space that step binds — moves the caret and never appears in the row, where ink's buffer is the source of truth and shows what its own flow discarded. Both land the caret in the same column, and that display gap predates this change, so it is recorded as follow-up work rather than copied. Coverage is twenty-two new cases across three suites — thirteen the model itself, against ink, the rest using its fields — plus seven mutations, each failing the tests that own the behaviour: the left arrow stops moving, the context-row input branch, both end-of-line bindings, forward delete, a modified Delete that erases a character instead of passing through, ctrl+W, and a row that stops re-mounting. On a real machine a new scenario answers one question from the free text row, and only one, so the tab clamping and the double trigger recorded earlier cannot be the source of the frame: type six characters, step the caret back two, insert one. Both legs show `> abcdXef` at the same column of the same row and both record `abcdXef` as the answer. The styled capture pins that down — in the frame both legs caught together, ink's cursor cell and this renderer's sit on the same character in the same column, differing in two colours only; in the two frames before it ink's cell is absent at all, which is its blink interval and not a missing cursor. No scenario reaches the auth wizard, as before: its four fields' editing rests on the unit suites alone. One test-runtime note is recorded with it, because it cost a debugging round: the DOM runtime used by the field tests keeps stale renders of a re-rendered row mounted beside the live one, so the probe reads the last cursor cell — the same JSX shape driven through five state changes in the real renderer holds one row, so this is scaffolding, not the dialog. The full ink/OpenTUI matrix was rerun on this tree: twenty-six scenarios, no scenario errors, twenty-two of seventy-seven checkpoints byte-identical, and the frames of the three earlier dialog scenarios unchanged against the previous run. --- .../2026-09-10-opentui-parity-defect-sweep.md | 103 ++++- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 80 +++- .../src/ui/components/shared/text-buffer.ts | 4 +- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 150 ++++++- packages/cli/src/ui/opentui/dialogs-auth.tsx | 133 ++++--- .../src/ui/opentui/dialogs-confirm.test.tsx | 70 +++- .../cli/src/ui/opentui/dialogs-confirm.tsx | 33 +- packages/cli/src/ui/opentui/line-edit.test.ts | 376 ++++++++++++++++++ packages/cli/src/ui/opentui/line-edit.ts | 269 +++++++++++++ 9 files changed, 1133 insertions(+), 85 deletions(-) create mode 100644 packages/cli/src/ui/opentui/line-edit.test.ts create mode 100644 packages/cli/src/ui/opentui/line-edit.ts diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 25ae84b8393..25b8a3f51ea 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,13 +57,14 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Twenty-five scenarios cover boot, a +rather than at the same wall-clock moment. Twenty-six scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear and exit, a long hold that cycles the loading phrases, a to-do card, the question dialog on its own, the same dialog across three questions with a -multi-select and a typed answer, the release of a parked confirmation when the +multi-select and a typed answer, that dialog's free-text row corrected in the +middle of a typed value, the release of a parked confirmation when the approval mode changes, the approval of a gated server at startup, a tool call whose arguments are long enough to be capped, a control arm that repeats that call with the arguments row switched off, an answer long enough to overflow @@ -992,17 +993,103 @@ replay: the shared resume loader reads this payload shape and this renderer's session switch calls it, but no scenario resumed a session to watch the row come back. +## Decision 32 — a dialog field's caret moves, as ink's does + +Every text field in these dialogs kept its value in the dialog's own state and +only ever appended to it. The keyboard could add a character and erase the last +one; nothing else. ← and → did nothing, so did Home and End, and a mistake +anywhere but the end of the value could be repaired only by erasing everything +after it. ink routes the same fields through one text-input component whose +editing lives in a buffer with a caret measured in code points, which is why +those keys move a cursor there. Two field families were affected: the question +dialog's free-text row, whose own help line advertises ← and → as tab switches +while the row gave them up entirely, and the four text fields of the +authentication wizard. + +The buffer was not reimplemented from a reading of what it ought to do. What +exists is the one-line slice of ink's own operations, and the tests assert no +hand-written expectation: each fixture replays a keystroke sequence through +ink's reducer and through this model side by side, comparing the text and the +caret offset after every step rather than at the end — across a paste that +leaves a line break behind, over characters ink weighs as one cell and as two, +and at both walls where a move has nowhere to go. ink's word segmentation is +reused as itself: two helpers that had been module-private became exported, so +the span ctrl+W erases is the span ink erases, and ink's own behaviour is +unchanged. The key order is ink's as well, restricted to the keys a one-line +field can receive, and every other key goes back to the dialog — which is what +ink needs, since its dialog stops handling the arrows once the field owns the +row. Word jumps, delete-word-right, kill-line and undo/redo are recorded as not +ported, all four of them bound by ink. Two absences are forced: ctrl+D belongs +to the app's global exit binding, which takes the key before any field sees it, +and a field whose value another keystroke replaced wholesale has its caret pulled +back inside the string rather than left pointing past it. + +Three rendering differences stand. ink windows a field to a fixed column count +and shows only the line the caret is on, so a pasted multi-line value hides +everything off that line, while these rows render the whole value. ink paints its +cell a gray read off the terminal background, falling back to an underline where +a block would corrupt IME composition, while this cell carries the theme accent +as the composer's cursor does. And ink runs a 530 ms interval that toggles the +cell's visibility for as long as a field holds focus, where a steady cell keeps +the dialog off a repaint timer. That interval shows up in a measurement, not just +in the source: over a scenario that parks the caret in the free-text row, the ink +leg wrote 270 KB in each sixty-second window it sat there, four of the scenario's +seven settling waits burned their full timeout, and the leg took 251 s where this +renderer's took 14 s. + +Mount semantics differ per field, because ink's do. The question dialog's row is +mounted by that dialog only while its own option is the selected one, and mounted +with the caret past the value it holds, so this port treats the row as freshly +mounted whenever it is (re)selected and drops a caret left mid-value. The wizard's +custom-model field is not unmounted when the list below it takes focus, so there +the caret is still where it was left when Tab comes back. + +One field's text is not the model's own either. The context-window setter keeps +digits only, and this port renders the value it is handed, so a character that +setter rejects — a letter, or the Space that step binds — moves the caret and +never appears in the row. ink's buffer is the source of truth there and is never +re-read from the property, so it goes on showing what its own flow threw away. +The caret lands in the same column both ways, and what is displayed took this +shape before the caret existed at all — ink showing a value its flow has already +discarded is recorded as a follow-up rather than reproduced here. + +Coverage is twenty-two new unit tests in three suites — thirteen of them the +model compared against ink, the rest the fields that use it — plus seven +mutations, each failing the tests that own the behaviour taken away: a left +arrow that moves nothing, the context-window field's typing branch, the two +end-of-line jumps, forward delete, a modified Delete that erases a character +instead of passing through, ctrl+W, and a row that stops re-mounting. On a +machine, a new scenario answers one question from its free-text row on both +legs, with a single question so that neither the tab clamp nor the double-fire +recorded under Decision 21 can be what produced the frame: six characters +typed, the caret taken back two cells, one character inserted. Both legs show +`> abcdXef` at the same column of the same row and both record `abcdXef` as +the answer. The styled capture pins the cell itself — at the one checkpoint +where both frames caught ink's cursor visible it sits on the same column over +the same character on both sides, differing only in the two colours; at the +two before it ink's cell is simply absent, which is its interval rather than a +missing cursor. What no scenario reaches is the authentication wizard, as +before: its four fields' editing rests on the unit suites alone. One +test-runtime note belongs here because it cost a debugging pass: the DOM +runtime the field tests use keeps earlier renders of a re-rendered row +mounted beside the live one, so the probe that reads the cursor cell reads +the last one. Rendering the same shape through the real renderer over five +state changes leaves exactly one row, so this is the harness, not the dialog. + ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for twenty-five scenarios at 100×40 and, for the narrow - and resize scenarios, at 60×24 — seventy-two checkpoints in all. Both legs - from one bundle and one set of boot arguments. -- **Colour was not verified.** The reconstruction is text. Several rows are - known to differ only in which theme token they use, and a styled capture - exists that could settle it but was not read. + reconstructed screen, for twenty-six scenarios at 100×40 and, for the narrow + and resize scenarios, at 60×24 — seventy-seven checkpoints in all, of which + twenty-two match byte for byte. Both legs from one bundle and one set of + boot arguments. +- **Colour was not verified, apart from one row family.** The reconstruction is + text. Several rows are known to differ only in which theme token they use. + A styled capture backs all but three of the seventy-seven checkpoints and was + read for one family only — Decision 32's cursor cell — leaving the rest + unread. - **The scrollbar costs the transcript no column.** Measured at a hundred columns with two answer rows of 95 and 96 characters: this renderer fills 99 columns before it breaks a row where ink fills 98, so the one-column gap sits diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 8ac20f7387d..72a48611d07 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,14 +41,15 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。二十五个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。二十六个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 -问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、审批模式变化时被 -拦下的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用、 -把同一次调用在关掉参数行的情况下重跑的对照臂、一份长到溢出终端的回答、在溢出部分 -上滚下去再滚回来、两行宽到足以展示折行发生在哪一列的回答,以及一个把两个调用同时 -停在审批上的回合 —— 于是第一个要排在第二个的对话框后面。 +问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、把那条手打答案改到 +值的中间、审批模式变化时被拦下的确认框的释放、启动时对受门禁服务器的审批、一次 +参数长到会被截断的工具调用、把同一次调用在关掉参数行的情况下重跑的对照臂、一份 +长到溢出终端的回答、在溢出部分上滚下去再滚回来、两行宽到足以展示折行发生在哪一 +列的回答,以及一个把两个调用同时停在审批上的回合 —— 于是第一个要排在第二个的 +对话框后面。 比较方式有三个性质决定了结果该怎么读。 @@ -717,15 +718,72 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 在屏幕上演的是重放:共用的恢复加载器读的就是这个负载形状,本渲染器的会话切换也调用了 它,但没有任何场景真的恢复过一次会话、看着这一行回来。 +## 决定 32 —— 对话框字段的光标会动了,和 ink 一样 + +这些对话框里的每一个文本字段都把值存在对话框自己的 state 里,而且只会往后追加。键盘能 +做的只有加一个字符、删掉最后一个。← 和 → 什么都不做,Home、End 也一样;值中间任何一处 +打错了,唯一的修法是把那之后的内容全擦掉。ink 把同样的字段都交给同一个文本输入组件, +它的编辑逻辑活在一个以码点计量光标的缓冲区里,所以那些键在那里是动得了光标的。受影响的 +有两类字段:提问对话框的自由文本行 —— 它自己的提示行把 ← 和 → 宣传成切换标签,而这一 +行却把它们整个让了出去 —— 以及认证向导的那四个文本字段。 + +这个缓冲区不是照着「它应该怎么做」重写的。落地的就是 ink 自己那些操作的一行切片,而测试 +里没有一条手写期望:每条夹具把同一串按键并排灌进 ink 的 reducer 和这个模型,在每一步之后 +而不是最后比对文本与光标偏移 —— 跨过一次留下换行的粘贴,跨过 ink 记作一格与两格的字 +符,也跨过两头那堵动不动了的墙。ink 的分词是按它本身复用的:两个原本只模块内可见的辅助 +函数改成了导出,于是 ctrl+W 擦掉的那一段就是 ink 擦掉的那一段,而 ink 自己的行为没有变 +动。按键顺序也是 ink 的,只取一个一行字段收得到的那些键,其余每个键都交还给对话框 —— +这正是 ink 需要的,因为它的对话框一旦让字段拥有这一行就不再处理方向键。词跳转、向右删 +词、整行删除与撤销重做被记录为未移植,这四样 ink 都绑了键。两处缺席是被迫的:ctrl+D 属 +于应用的全局退出绑定,任何字段都轮不到它;而一个值被另一次按键整体换掉的字段,光标会被 +拉回串内,而不是继续指着串外。 + +三处渲染差异保留下来。ink 把字段按固定列数开窗,并且只画光标所在的那一行,所以一次多行 +粘贴会把它自己看不着的那几行全藏起来,而这里的行渲染整个值。ink 把光标格涂成从终端背景 +读出来的灰,并且在实心块会破坏输入法拼字时退到下划线,而这一格带的是主题强调色,和 +composer 的光标一致。还有 ink 会跑一个 530 毫秒的定时器,只要字段握着焦点就不断翻转那 +一格的可见性,而一格常亮让对话框不必按定时器重绘。这个定时器不只是源码里的一行,它在测量 +里看得见:在一个把光标停在自由文本行上的场景里,ink 那条腿每坐在这一行的六十秒里就写出 +270 KB,场景的七次静止等待有四次把超时整个烧完,那条腿走了 251 秒,而本渲染器这条是 +14 秒。 + +挂载语义按字段区分,因为 ink 的就是区分的。提问对话框的那一行只在自己的选项被选中时才 +被对话框挂载,而且挂载时光标就在所存值之后,所以本移植把「该行被(重新)选中」当作一次 +全新挂载,落在值中间的光标就此丢掉。向导里的自定义模型字段在下面那排列表拿到焦点时并不 +被卸载,所以 Tab 回来时光标还在原地。 + +有一个字段的文本也不归模型自己管。上下文窗口的 setter 只留数字,而本移植渲染的就是拿到 +的那个值,所以在那里敲一个被该 setter 拒绝的字符 —— 一个字母,或那一步显式绑定的 +Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那里才是唯一来源,且从不回头读 +那个属性,于是它会一直显示自己被流程丢掉的东西。两种下落点在同一列,而显示内容在光标 +还不存在时就是这个样子 —— ink 显示一个自己的流程已经作废的值,这一条记为后续工作, +没有照搬。 + +覆盖是三个测试套件里新增的二十二条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 +用它的字段 —— 外加七个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 +上下文窗口字段的输入分支、两个跳至行尾的绑定、向前删除、带修饰键的 Delete 擦掉一个字符 +而不是把键交还、ctrl+W,以及一行不再重新挂载。 +实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问一个问题,这样标签钳制和 +决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符,光标往回退两格,再插入一个 +字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 `abcdXef` 记成了答案。带样式的 +抓取把那一定位钉死了 —— 在两条腿同时抓到的那一帧里,ink 的光标格与本渲染器的落在同一 +列的同一个字符上,两边只有两种颜色不同;在它之前那两帧,ink 的那一格根本不在,那是它的 +翻转间隔,不是缺了光标。没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的 +编辑只有单元测试套件兜着。还有一条测试运行时的说明值得写在这里,因为它花掉了一轮排障: +字段测试用的那套 DOM 运行时会把重渲染行的旧渲染留在活的那一行旁边,所以读光标格的探针读 +的是最后一个。把同样的 JSX 形状放进真实渲染器改五次状态,行始终只有一行,所以这是脚手架 +而不是对话框的问题。 + ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十五个场景, - 100×40;窄终端与 resize 场景另加 60×24,总计七十二个检查点。两腿出自同一个 - bundle 与同一组启动参数。 -- **颜色未验证。** 重建结果是文本。已知有若干行只在所用主题 token 上不同,而 - 存在一份带样式的抓取可以定这件事,但没有读。 +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十六个场景, + 100×40;窄终端与 resize 场景另加 60×24,总计七十七个检查点,其中二十二个 + 逐字节一致。两腿出自同一个 bundle 与同一组启动参数。 +- **颜色除一类行之外未验证。** 重建结果是文本。已知有若干行只在所用主题 token + 上不同。七十七个检查点里除三个外都有带样式的抓取兜着,而读过的只有一类行 —— + 决定 32 的光标格 —— 其余没有读。 - **滚动条没有从转录里拿走一列。** 在一百列下用两条分别为 95 与 96 字符的回答行 测量:本渲染器折满 99 列才断行,ink 折满 98 列,所以这一列的差与清单上那条既存的 换行分歧同向,而不是滚动区域拿走了一列 —— 且整份语料的行长分布在这次改动前后完全 diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 8b4d8eaf6e5..c6ffc8f35e4 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -161,7 +161,7 @@ export function __resetWordSegmenter(): void { * Returns an array of { start, end } where end is exclusive. * @param codePoints - Optional pre-computed code points array to avoid redundant toCodePoints calls. */ -function getWordBoundaries( +export function getWordBoundaries( line: string, codePoints?: string[], ): Array<{ start: number; end: number }> { @@ -299,7 +299,7 @@ function binarySearchCpIndex(cpToStrIdx: number[], target: number): number { * - Cursor inside a word → jump to that word's start * - Cursor exactly at a word's start → jump to previous word's start */ -function findPrevWordStart( +export function findPrevWordStart( boundaries: Array<{ start: number; end: number }>, col: number, ): number | null { diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 99a29c546aa..a03c54d9b7a 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -54,9 +54,15 @@ const mocks = vi.hoisted(() => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; if (type === 'box' || type === 'text') { + // `bg` is the one style prop carried through: the dialog gives a + // background colour to exactly one cell, the software cursor. + const bg = (config as { bg?: string }).bg; return React.createElement( type === 'box' ? 'div' : 'span', - key === undefined ? null : { key }, + { + key: key ?? null, + ...(bg === undefined ? null : { 'data-bg': bg }), + }, children, ); } @@ -131,13 +137,26 @@ function lastKeyboardHandler(): (key: unknown) => void { return handler; } -async function press(name: string): Promise { +async function press( + name: string, + overrides: Record = {}, +): Promise { const handler = lastKeyboardHandler(); await act(async () => { - handler(baseKeyEvent({ name, sequence: name })); + handler(baseKeyEvent({ name, sequence: name, ...overrides })); }); } +/** + * The cell the software cursor is drawn on, or null with no focused field. The + * jsdom harness keeps the earlier renders of one row mounted beside the live one + * (the native renderer does not), so the newest cell is the last. + */ +function cursorCell(): HTMLElement | null { + const cells = document.querySelectorAll('[data-bg]'); + return cells[cells.length - 1] ?? null; +} + async function typeText(text: string): Promise { // One act per character: the flow state lives in React state, so each // keystroke must flush a render before the next handler closure is fresh. @@ -519,3 +538,128 @@ describe('bracketed-paste into dialog inputs (#57)', () => { expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); }); }); + +describe('caret editing in dialog text fields (#107)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + core.applyProviderInstallPlan.mockReset().mockResolvedValue(undefined); + core.logAuth.mockReset(); + }); + + /** Walk to the base-URL step, whose field starts empty. */ + async function runToBaseUrlStep(): Promise { + renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → protocol + await press('return'); // protocol: OpenAI-compatible → baseUrl input + } + + /** Walk to the advanced-config step with the context-window row focused. */ + async function runToContextWindowRow(): Promise { + await runToBaseUrlStep(); + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('down'); // thinking → modality + await press('down'); // modality → context window (index 2 while it's closed) + } + + /** + * The focused field's rendered text and the cell the cursor sits on, read off + * the cursor cell's neighbours so a row that labels its own field — the + * context-window row — contributes only the value. Valid while the field holds + * a value: an empty field puts the cell on its placeholder's first character, + * which is the row's first text element. + */ + function focusedField(): { text: string; cell: string } { + const cell = cursorCell(); + if (!cell) throw new Error('no field renders a cursor cell'); + const at = cell.textContent ?? ''; + return { + text: + (cell.previousElementSibling?.textContent ?? '') + + at + + (cell.nextElementSibling?.textContent ?? ''), + cell: at, + }; + } + + it('inserts at the position the arrows left the caret', async () => { + await runToBaseUrlStep(); + await typeText('abcdef'); + await press('left'); + await press('left'); + await typeText('X'); + expect(focusedField()).toEqual({ text: 'abcdXef', cell: 'e' }); + }); + + it('keeps the caret at either end for ctrl+A and ctrl+E', async () => { + await runToBaseUrlStep(); + await typeText('ab'); + await press('a', { ctrl: true, sequence: '\x01' }); + expect(focusedField()).toEqual({ text: 'ab', cell: 'a' }); + await press('e', { ctrl: true, sequence: '\x05' }); + // past the last code point ink draws a blank cell, which the text keeps + expect(focusedField()).toEqual({ text: 'ab ', cell: ' ' }); + await typeText('c'); + expect(focusedField()).toEqual({ text: 'abc ', cell: ' ' }); + }); + + it('erases backward with backspace and forward with delete', async () => { + await runToBaseUrlStep(); + await typeText('abcd'); + await press('a', { ctrl: true, sequence: '\x01' }); + await press('backspace'); + // nothing left of the caret, so the value stands + expect(focusedField()).toEqual({ text: 'abcd', cell: 'a' }); + await press('delete'); + expect(focusedField()).toEqual({ text: 'bcd', cell: 'b' }); + }); + + it('erases the word left of the caret with ctrl+W', async () => { + await runToBaseUrlStep(); + await typeText('https://openai'); + await press('w', { ctrl: true, sequence: '\x17' }); + expect(focusedField()).toEqual({ text: 'https:// ', cell: ' ' }); + // the edited value, not the typed one, is what the step submits + await press('return'); + expect(screen.getByText(/Step 3\/6 · API Key/)).toBeTruthy(); + }); + + it('sends End past the line break a paste leaves behind', async () => { + await runToBaseUrlStep(); + await pasteText('https://one.test\nhttps://two.test'); + await press('a', { ctrl: true, sequence: '\x01' }); + expect(focusedField().cell).toBe('h'); + await press('e', { ctrl: true, sequence: '\x05' }); + await typeText('s'); + // ink's End is the end of the value, not of the caret's own line + expect(focusedField()).toEqual({ + text: 'https://one.test\nhttps://two.tests ', + cell: ' ', + }); + }); + + it('edits the context-window field in place', async () => { + await runToContextWindowRow(); + await typeText('1234'); + await press('left'); + await press('left'); + await typeText('9'); + expect(focusedField()).toEqual({ text: '12934', cell: '3' }); + // The step's setter keeps digits only, so a letter never reaches the value. + // The caret still advances one cell, as ink's does; ink also keeps the letter + // on screen, because its field renders its own buffer rather than the value. + await typeText('x'); + expect(focusedField()).toEqual({ text: '12934', cell: '4' }); + await press('return'); + expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); + expect(document.body.textContent).toContain('"contextWindowSize": 12934'); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index 8c1036f5f89..48721bf2214 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -20,7 +20,7 @@ * - documentation/TOS links render as plain text (no OSC 8 in dialogs). */ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useKeyboard, usePaste, useRenderer } from '@opentui/react'; import type { PasteEvent } from '@opentui/core'; import { decodePasteBytes } from '@opentui/core'; @@ -57,6 +57,7 @@ import { normalizeModelIds } from '../auth/useAuth.js'; import { toOriginalKey } from './key-map.js'; import { isPrintableKeyInput } from './input-prompt-key.js'; import { normalizePastedText } from './input-prompt-model.js'; +import { caretSpans, useLineEdit } from './line-edit.js'; import { Shell } from './dialogs-misc.js'; import { C } from './theme.js'; @@ -213,55 +214,82 @@ function RadioList({ items, cursor }: { items: RadioItem[]; cursor: number }) { ); } +/** + * A dialog field's text with ink's software cursor: the cell under the caret + * drawn on a background, and an empty field putting that cell on its + * placeholder's first character so it still shows where text will start. + */ +function FieldText({ + value, + caret, + placeholder, + active, +}: { + value: string; + caret: number; + placeholder?: string; + active?: boolean; +}) { + if (value.length === 0 && placeholder) { + if (!active) return {placeholder}; + return ( + <> + {placeholder.slice(0, 1)} + {placeholder.slice(1)} + + ); + } + const spans = caretSpans({ text: value, cursor: caret }); + return ( + <> + {spans.before} + {active && {spans.at || ' '}} + {spans.after} + + ); +} + function InputLine({ value, + caret, placeholder, active, }: { value: string; + caret: number; placeholder?: string; active?: boolean; }) { - const empty = value.length === 0; return ( - - {empty ? (placeholder ?? '') : value} - - {active && {'█'}} + ); } -/** Shared single-line text-input key handling (backend ask-user parity). */ +/** + * Shared single-line text-input key handling (backend ask-user parity). Returns + * the caret offset so the row can put its cursor cell where ink's would be. + */ function useLineInputKeys( value: string, onChange: (next: string) => void, onSubmit: () => void, -) { - // Key events can land in one React batch, where the value captured by the - // render that registered the handler is already stale by the second - // keystroke. The mirror is written synchronously so each event appends to - // what the previous one produced, and re-synced on render so a value set - // from anywhere else is not lost. - const latest = useRef(value); - latest.current = value; - const change = (next: string) => { - latest.current = next; - onChange(next); - }; +): number { + const line = useLineEdit(value, onChange); useKeyboard((key) => { const o = toOriginalKey(key); if (o.name === 'return' || o.name === 'enter') { onSubmit(); return; } - if (o.name === 'backspace' || o.name === 'delete') { - change(latest.current.slice(0, -1)); - return; - } - if (isPrintableKeyInput(key)) { - change(latest.current + key.sequence); + if (!line.handleKey(o) && isPrintableKeyInput(key)) { + line.insert(key.sequence); } }); // Bracketed pastes arrive as one PasteEvent with no keypress per character @@ -273,8 +301,9 @@ function useLineInputKeys( const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - change(latest.current + text); + line.insert(text); }); + return line.caret; } // --------------------------------------------------------------------------- @@ -360,7 +389,7 @@ function BaseUrlInputStep({ flow: ProviderSetupFlow; documentationUrl?: string; }) { - useLineInputKeys(flow.state.baseUrl, flow.changeBaseUrl, () => + const caret = useLineInputKeys(flow.state.baseUrl, flow.changeBaseUrl, () => flow.submitBaseUrl(), ); return ( @@ -368,6 +397,7 @@ function BaseUrlInputStep({ {t('Enter the API endpoint for this protocol.')} + const caret = useLineInputKeys(flow.state.apiKey, flow.changeApiKey, () => flow.submitApiKey(flow.state.apiKey), ); return ( @@ -412,6 +442,7 @@ function ApiKeyStep({ )} @@ -480,6 +511,10 @@ function ModelsStep({ [checked, syncModelIds], ); + // ink keeps this field in a TextInput whose buffer survives the list taking + // focus, so the caret is still where it was left when Tab comes back. + const custom = useLineEdit(customText, updateCustom); + const toggleRecommended = useCallback( (id: string) => { const next = new Set(checked); @@ -523,12 +558,8 @@ function ModelsStep({ submit(); return; } - if (o.name === 'backspace' || o.name === 'delete') { - updateCustom(customText.slice(0, -1)); - return; - } - if (isPrintableKeyInput(key)) { - updateCustom(customText + key.sequence); + if (!custom.handleKey(o) && isPrintableKeyInput(key)) { + custom.insert(key.sequence); } }); // Pastes land in the custom-ID input only when it owns focus; while the @@ -538,7 +569,7 @@ function ModelsStep({ const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - updateCustom(customText + text); + custom.insert(text); }); return ( @@ -548,7 +579,12 @@ function ModelsStep({ 'Enter model IDs directly. Use commas to configure multiple models.', )} - + {flow.state.modelIdsError && ( {flow.state.modelIdsError} @@ -608,6 +644,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { } = flow.state; const ctxIdx = modalityEnabled ? 6 : 2; const onCtxRow = focusedConfigIndex === ctxIdx; + const ctxField = useLineEdit(contextWindowSize, flow.changeContextWindowSize); useKeyboard((key) => { const o = toOriginalKey(key); // Focus-row navigation restricted to unambiguous shortcuts (ink parity: @@ -623,7 +660,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { if (o.name === 'space') { // On the context row Space inserts a space into the field; the flow's // toggleFocusedAdvancedOption has no case for ctxIdx (ink parity). - if (onCtxRow) flow.changeContextWindowSize(contextWindowSize + ' '); + if (onCtxRow) ctxField.insert(' '); else flow.toggleFocusedAdvancedOption(); return; } @@ -631,14 +668,8 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { flow.submitAdvancedConfig(); return; } - if (onCtxRow) { - if (o.name === 'backspace' || o.name === 'delete') { - flow.changeContextWindowSize(contextWindowSize.slice(0, -1)); - return; - } - if (isPrintableKeyInput(key)) { - flow.changeContextWindowSize(contextWindowSize + key.sequence); - } + if (onCtxRow && !ctxField.handleKey(o) && isPrintableKeyInput(key)) { + ctxField.insert(key.sequence); } }); // Only the context-window field accepts text; a paste while another row is @@ -648,7 +679,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - flow.changeContextWindowSize(contextWindowSize + text); + ctxField.insert(text); }); const checkmark = (v: boolean) => (v ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY); const cursor = (index: number) => (focusedConfigIndex === index ? '›' : ' '); @@ -701,10 +732,12 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { {`${cursor(ctxIdx)} ${t('Context window')}: `} - - {contextWindowSize || 'auto'} - - {onCtxRow && {'█'}} + diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index bb830159949..a159dc5668f 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -42,9 +42,15 @@ const mocks = vi.hoisted(() => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; if (type === 'box' || type === 'text') { + // `bg` is the one style prop carried through: the dialog gives a + // background colour to exactly one cell, the software cursor. + const bg = (config as { bg?: string }).bg; return React.createElement( type === 'box' ? 'div' : 'span', - key === undefined ? null : { key }, + { + key: key ?? null, + ...(bg === undefined ? null : { 'data-bg': bg }), + }, children, ); } @@ -479,6 +485,13 @@ describe('OpenTuiToolConfirmation', () => { }); } + /** The character the software cursor is drawn on. */ + function cursorCell(container: HTMLElement): string { + const cell = container.querySelector('[data-bg]'); + if (!cell) throw new Error('no cursor cell is drawn'); + return cell.textContent ?? ''; + } + beforeEach(() => { vi.useFakeTimers(); }); @@ -556,6 +569,61 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('edits the middle of a typed answer', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('abcdef'); + press({ name: 'left' }); + press({ name: 'left' }); + expect(cursorCell(container)).toBe('e'); + typeChars('X'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'abcdXef' } }, + ); + }); + + it('moves the caret instead of the question with the arrows', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('ab'); + press({ name: 'left' }); + press({ name: 'left' }); + press({ name: 'right' }); + // the row owns the cursor, so neither arrow reached the tab switch + expect(cursorCell(container)).toBe('b'); + expect(container.textContent ?? '').toContain('Pick a deploy target?'); + press({ name: 'return', sequence: '\r' }); + expect(container.textContent ?? '').not.toContain('Pick a region?'); + settleAdvance(); + expect(container.textContent ?? '').toContain('Pick a region?'); + }); + + it('restarts the caret past the value when the row is selected again', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('abc'); + press({ name: 'left' }); + press({ name: 'left' }); + press({ name: 'left' }); + expect(cursorCell(container)).toBe('a'); + // ink mounts this field per selected row, and mounts it at the end of the + // value it holds, so leaving the row and returning drops that position + press({ name: 'up' }); + press({ name: 'down' }); + typeChars('X'); + expect(container.textContent ?? '').toContain('> abcX'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'abcX' } }, + ); + }); + it('advances exactly one tab when a typed answer is committed', () => { const onConfirm = vi.fn(async () => {}); const container = mount(multiAskDetails(onConfirm)); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 29acec0835d..c2bb40f3ffa 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -78,6 +78,7 @@ import { } from '../utils/textUtils.js'; import { isPrintableKeyInput } from './input-prompt-key.js'; import { normalizePastedText } from './input-prompt-model.js'; +import { caretSpans, useLineEdit } from './line-edit.js'; import type { ShellConfirmationResolution } from './commands-context.js'; import { McpApprovalChoice } from '../components/mcp/MCPServerApprovalDialog.js'; import { computeHeaderCap } from '../components/messages/AskUserQuestionDialog.js'; @@ -707,8 +708,7 @@ function AskUserQuestionFlow(props: { return labels.length > 0 ? labels.join(', ') : undefined; }; - const setCustomValue = (update: (previous: string) => string) => { - const next = update(typedValue(tab)); + const writeCustomValue = (next: string) => { typedRef.current = { ...typedRef.current, [tab]: next }; setTyped((prev) => ({ ...prev, [tab]: next })); // A multi-select box tracks whether its free-text entry counts, so typing @@ -718,6 +718,15 @@ function AskUserQuestionFlow(props: { } }; + // ink mounts this field only while its row is the selected option, and mounts + // it with the caret past the value it holds. Coming back to the row, or to + // another question's row, drops the position it was left at. + const custom = useLineEdit( + customValue, + writeCustomValue, + `${isCustomRow}:${tab}`, + ); + const submitCustomRow = () => { // Re-read rather than use the rendered value: the keystroke that fills the // row and this Enter can share one batch. @@ -772,10 +781,8 @@ function AskUserQuestionFlow(props: { setSelected(Math.min(totalOptions - 1, selected + 1)); } else if (original.name === 'return') { submitCustomRow(); - } else if (original.name === 'backspace') { - setCustomValue((previous) => previous.slice(0, -1)); - } else if (isPrintableKeyInput(key)) { - setCustomValue((previous) => previous + key.sequence); + } else if (!custom.handleKey(original) && isPrintableKeyInput(key)) { + custom.insert(key.sequence); } return; } @@ -855,7 +862,7 @@ function AskUserQuestionFlow(props: { const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - setCustomValue((previous) => previous + text); + custom.insert(text); }); // Defensive: an empty question list, or a question with no options, has @@ -945,6 +952,7 @@ function AskUserQuestionFlow(props: { const customEmphasis = isCustomAnswer || typedChecked[tab] === true; const customLabel = `${question.options.length + 1}. `; const placeholder = t('Type something...'); + const customSpans = caretSpans({ text: customValue, cursor: custom.caret }); return ( <> @@ -999,10 +1007,15 @@ function AskUserQuestionFlow(props: { {'> '} {customValue ? ( <> - {sanitizeTerminalText(customValue)} + + {sanitizeTerminalText(customSpans.before)} + {/* ink's software cursor: a background-filled cell at the - insert position, which a text frame cannot show. */} - + caret, which a text frame cannot show. */} + {customSpans.at || ' '} + + {sanitizeTerminalText(customSpans.after)} + ) : ( <> diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts new file mode 100644 index 00000000000..3f98a21a869 --- /dev/null +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -0,0 +1,376 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + textBufferReducer, + type TextBufferAction, + type TextBufferState, +} from '../components/shared/text-buffer.js'; +import type { Key } from '../contexts/KeypressContext.js'; +import { cpLen } from '../utils/textUtils.js'; +import { + applyLineKey, + backspaceAtCaret, + caretSpans, + clampCaret, + deleteAtCaret, + deleteWordLeftAtCaret, + endOfLine, + insertAtCaret, + moveCaretEnd, + moveCaretHome, + moveCaretLeft, + moveCaretRight, + type LineState, +} from './line-edit.js'; + +type LineKey = Pick; + +const key = ( + name: string, + modifiers: { ctrl?: boolean; meta?: boolean; sequence?: string } = {}, +): LineKey => ({ + name, + ctrl: modifiers.ctrl ?? false, + meta: modifiers.meta ?? false, + sequence: modifiers.sequence ?? '', +}); + +/** + * ink's reducer state for a buffer holding `text` with the caret at code-point + * `cursor`. The visual layout is the identity one `calculateLayout` produces + * while every line fits the viewport, which is what these fixtures use. + */ +function inkState(text: string, cursor: number): TextBufferState { + const lines = text.length === 0 ? [''] : text.split('\n'); + let row = 0; + let remaining = cursor; + while (row < lines.length - 1 && remaining > lines[row].length) { + remaining -= [...lines[row]].length + 1; + row++; + } + return { + lines, + cursorRow: row, + cursorCol: Math.min(remaining, [...lines[row]].length), + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + viewportWidth: 80, + viewportHeight: 1, + visualLayout: { + visualLines: lines, + logicalToVisualMap: lines.map( + (_, i): Array<[number, number]> => [[i, 0]], + ), + visualToLogicalMap: lines.map((_, i): [number, number] => [i, 0]), + }, + }; +} + +/** ink's `[row, col]` cursor flattened to a code-point offset in joined text. */ +function inkOffset(state: TextBufferState): number { + let offset = 0; + for (let i = 0; i < state.cursorRow; i++) { + offset += [...(state.lines[i] ?? '')].length + 1; + } + return offset + state.cursorCol; +} + +interface Op { + ink: (state: TextBufferState) => TextBufferState; + line: (state: LineState) => LineState; +} + +const step = ( + state: TextBufferState, + action: TextBufferAction, +): TextBufferState => textBufferReducer(state, action); + +const typed = (text: string): Op => ({ + ink: (state) => step(state, { type: 'insert', payload: text }), + line: (state) => insertAtCaret(state, text), +}); +const moveHome: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'home' } }), + line: moveCaretHome, +}; +// ink's END binding is this pair: TextInput dispatches the line-end move and +// then jumps to the end of the whole value. +const moveEnd: Op = { + ink: (state) => + step(step(state, { type: 'move', payload: { dir: 'end' } }), { + type: 'move_to_offset', + payload: { offset: cpLen(inkText(state)) }, + }), + line: moveCaretEnd, +}; +const stepLeft: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'left' } }), + line: moveCaretLeft, +}; +const stepRight: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'right' } }), + line: moveCaretRight, +}; +const eraseBack: Op = { + ink: (state) => step(state, { type: 'backspace' }), + line: backspaceAtCaret, +}; +const eraseForward: Op = { + ink: (state) => step(state, { type: 'delete' }), + line: deleteAtCaret, +}; +const eraseWord: Op = { + ink: (state) => step(state, { type: 'delete_word_left' }), + line: deleteWordLeftAtCaret, +}; + +/** Apply every op to both models and compare after each one, not just at the end. */ +function agreesWithInk(initial: string, ops: Op[]): void { + let line = endOfLine(initial); + let ink = inkState(initial, initial.length); + for (const [i, op] of ops.entries()) { + line = op.line(line); + ink = op.ink(ink); + expect({ step: i, text: inkText(ink), cursor: inkOffset(ink) }).toEqual({ + step: i, + text: line.text, + cursor: line.cursor, + }); + } +} + +const inkText = (state: TextBufferState): string => state.lines.join('\n'); + +describe('line-edit', () => { + it("produces ink's textBufferReducer results, keystroke by keystroke", () => { + // A URL typed into the /auth base-URL field, then corrected in place. + agreesWithInk('', [ + typed('api.openai.com'), + moveHome, + typed('https://'), + stepLeft, + stepLeft, + typed('x'), + eraseBack, + eraseWord, + moveEnd, + eraseBack, + eraseForward, + ]); + }); + + it('agrees with ink across the line break a multi-line paste leaves behind', () => { + agreesWithInk('', [ + typed('first\r\nsecond\rthird'), + stepLeft, + stepLeft, + typed('!'), + eraseBack, + eraseWord, + moveHome, + typed('#'), + moveEnd, + eraseForward, + ]); + agreesWithInk('first\nsecond', [ + stepLeft, + stepLeft, + typed('fourth\n'), + moveHome, + eraseForward, + eraseBack, + ]); + }); + + it('follows ink in jumping End past a line break but Home only to its own', () => { + expect(moveCaretEnd({ text: 'ab\ncd', cursor: 0 })).toEqual({ + text: 'ab\ncd', + cursor: 5, + }); + expect(moveCaretHome({ text: 'ab\ncd', cursor: 5 })).toEqual({ + text: 'ab\ncd', + cursor: 3, + }); + agreesWithInk('ab\ncd', [ + stepLeft, + stepLeft, + moveEnd, + moveHome, + typed('z'), + eraseBack, + eraseBack, + ]); + }); + + it('agrees with ink over code points ink weighs as one cell or two', () => { + agreesWithInk('sk-abc123', [ + stepLeft, + stepLeft, + typed('😀'), + eraseWord, + moveHome, + typed('a😀b中c'), + eraseWord, + stepRight, + eraseBack, + ]); + }); + + it('agrees with ink when the caret is already at a wall', () => { + agreesWithInk('', [ + eraseBack, + eraseForward, + stepLeft, + stepRight, + eraseWord, + ]); + agreesWithInk('ab', [ + moveHome, + stepLeft, + eraseBack, + moveEnd, + stepRight, + eraseForward, + moveEnd, + eraseWord, + moveHome, + eraseWord, + ]); + }); + + it("strips what ink strips and normalizes a paste's line endings", () => { + expect(insertAtCaret(endOfLine('a'), 'b\x07c')).toEqual({ + text: 'abc', + cursor: 3, + }); + expect(insertAtCaret(endOfLine('a'), 'b\r\nc\rd')).toEqual({ + text: 'ab\nc\nd', + cursor: 6, + }); + }); + + it('starts with the caret past the value and clamps one an outside edit left behind', () => { + expect(endOfLine('a😀b')).toEqual({ text: 'a😀b', cursor: 3 }); + expect(clampCaret({ text: 'ab', cursor: 9 })).toEqual({ + text: 'ab', + cursor: 2, + }); + const alreadyInside: LineState = { text: 'ab', cursor: 1 }; + expect(clampCaret(alreadyInside)).toBe(alreadyInside); + }); + + it('splits the value at the caret for cursor rendering', () => { + expect(caretSpans({ text: 'abc', cursor: 1 })).toEqual({ + before: 'a', + at: 'b', + after: 'c', + }); + expect(caretSpans({ text: 'abc', cursor: 3 })).toEqual({ + before: 'abc', + at: '', + after: '', + }); + expect(caretSpans({ text: 'ab', cursor: 7 })).toEqual({ + before: 'ab', + at: '', + after: '', + }); + }); + + describe('applyLineKey', () => { + const mid: LineState = { text: 'ab_cd', cursor: 2 }; + + it('moves the caret with the arrows and their ctrl bindings', () => { + expect(applyLineKey(mid, key('left'))).toEqual({ ...mid, cursor: 1 }); + expect(applyLineKey(mid, key('b', { ctrl: true }))).toEqual({ + ...mid, + cursor: 1, + }); + expect(applyLineKey(mid, key('right'))).toEqual({ ...mid, cursor: 3 }); + expect(applyLineKey(mid, key('f', { ctrl: true }))).toEqual({ + ...mid, + cursor: 3, + }); + }); + + it('jumps to the ends of the line by key name and by ctrl+A/ctrl+E', () => { + expect(applyLineKey(mid, key('home'))).toEqual({ ...mid, cursor: 0 }); + expect(applyLineKey(mid, key('a', { ctrl: true }))).toEqual({ + ...mid, + cursor: 0, + }); + expect(applyLineKey(mid, key('end'))).toEqual({ ...mid, cursor: 5 }); + expect(applyLineKey(mid, key('e', { ctrl: true }))).toEqual({ + ...mid, + cursor: 5, + }); + }); + + it('erases relative to the caret, not the end of the value', () => { + expect(applyLineKey(mid, key('backspace'))).toEqual({ + text: 'a_cd', + cursor: 1, + }); + expect(applyLineKey(mid, key('', { sequence: '\x7f' }))).toEqual({ + text: 'a_cd', + cursor: 1, + }); + expect(applyLineKey(mid, key('h', { ctrl: true }))).toEqual({ + text: 'a_cd', + cursor: 1, + }); + expect(applyLineKey(mid, key('delete'))).toEqual({ + text: 'abcd', + cursor: 2, + }); + expect(applyLineKey({ text: 'ab_cd', cursor: 1 }, key('delete'))).toEqual( + { text: 'a_cd', cursor: 1 }, + ); + }); + + it('deletes a word from ctrl+W and a modified backspace', () => { + for (const k of [ + key('w', { ctrl: true }), + key('backspace', { ctrl: true }), + key('backspace', { meta: true }), + ]) { + expect(applyLineKey({ text: 'https://openai', cursor: 14 }, k)).toEqual( + { + text: 'https://', + cursor: 8, + }, + ); + } + }); + + it('hands every other key back to the dialog', () => { + for (const k of [ + key('return'), + key('up'), + key('down'), + key('tab'), + key('d', { ctrl: true }), + key('k', { ctrl: true }), + key('u', { ctrl: true }), + key('z', { ctrl: true }), + key('left', { meta: true }), + key('right', { meta: true }), + key('b', { meta: true }), + key('f', { meta: true }), + key('d', { meta: true }), + key('delete', { ctrl: true }), + key('delete', { meta: true }), + ]) { + expect(applyLineKey(mid, k)).toBeNull(); + } + }); + }); +}); diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts new file mode 100644 index 00000000000..44cda58464a --- /dev/null +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * One-line edit model for the OpenTUI dialog inputs. + * + * ink routes every dialog text field through ``, whose editing lives + * in `useTextBuffer`. The OpenTUI port kept each field's value in the dialog's + * own state and only ever appended to it, so the caret could not move: ←/→, + * Home/End, and inserting or deleting in the middle of the string all fell off + * the keyboard. This module is the single-line slice of that buffer — code-point + * offsets, ink's own word segmentation, ink's key order — shared by every + * OpenTUI dialog field. + * + * What the model holds is one string, while ink's buffer holds logical lines. + * The two are equivalent for every operation mapped here, because a line break + * is just another code point: ink's "join with the previous line" and its + * "insert a new line" both reduce to adding or removing that character. + * + * Not ported, and recorded as follow-ups: word jumps (ctrl/alt+←/→, alt+b/f), + * delete-word-right (alt+d, ctrl/alt+Delete), kill-line (ctrl+k/ctrl+u) and + * undo/redo (ctrl+z) — all of which ink's TextInput does bind. ctrl+D is + * deliberately absent: the app's global EXIT binding takes that key before any + * text field sees it. Three rendering differences remain as well: ink windows + * the field at `inputWidth` columns, and its one-line viewport shows only the + * line the caret is on, so a pasted multi-line value hides everything off that + * line, while these rows render the whole value; ink blinks the cursor cell + * every 530 ms, where a steady cell keeps the dialog from repainting on a + * timer; and the cell carries the theme accent, as the composer's cursor does, + * where ink paints a gray read from the terminal background (and falls back to + * an underline where a block would corrupt IME composition). + */ + +import { useRef, useState } from 'react'; +import { + findPrevWordStart, + findPrevWordStartInLine, + getWordBoundaries, +} from '../components/shared/text-buffer.js'; +import type { Key } from '../contexts/KeypressContext.js'; +import { + cpLen, + cpSlice, + stripUnsafeCharacters, + toCodePoints, +} from '../utils/textUtils.js'; + +export interface LineState { + /** The field's whole value, line breaks included. */ + text: string; + /** Caret position as a code-point offset into {@link text}. */ + cursor: number; +} + +/** The state ink's TextInput starts a field in: value filled, caret at its end. */ +export function endOfLine(text: string): LineState { + return { text, cursor: cpLen(text) }; +} + +/** Pull a caret that an externally replaced value left past the end back to it. */ +export function clampCaret(state: LineState): LineState { + const end = cpLen(state.text); + const cursor = state.cursor < 0 ? 0 : Math.min(state.cursor, end); + return cursor === state.cursor ? state : { text: state.text, cursor }; +} + +/** The three pieces a caret splits the value into, for cursor rendering. */ +export function caretSpans(state: LineState): { + before: string; + at: string; + after: string; +} { + const clamped = clampCaret(state); + return { + before: cpSlice(clamped.text, 0, clamped.cursor), + at: cpSlice(clamped.text, clamped.cursor, clamped.cursor + 1), + after: cpSlice(clamped.text, clamped.cursor + 1), + }; +} + +/** `[start, end)` code-point bounds of the line the caret sits on. */ +function lineBounds( + text: string, + cursor: number, +): { start: number; end: number; line: string } { + const codePoints = toCodePoints(text); + let start = cursor; + while (start > 0 && codePoints[start - 1] !== '\n') start--; + let end = cursor; + while (end < codePoints.length && codePoints[end] !== '\n') end++; + return { start, end, line: codePoints.slice(start, end).join('') }; +} + +export function insertAtCaret(state: LineState, inserted: string): LineState { + const { text, cursor } = clampCaret(state); + const clean = stripUnsafeCharacters( + inserted.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), + ); + const width = cpLen(clean); + if (width === 0) return state; + return { + text: cpSlice(text, 0, cursor) + clean + cpSlice(text, cursor), + cursor: cursor + width, + }; +} + +export function backspaceAtCaret(state: LineState): LineState { + const { text, cursor } = clampCaret(state); + if (cursor === 0) return state; + return { + text: cpSlice(text, 0, cursor - 1) + cpSlice(text, cursor), + cursor: cursor - 1, + }; +} + +export function deleteAtCaret(state: LineState): LineState { + const { text, cursor } = clampCaret(state); + if (cursor >= cpLen(text)) return state; + return { + text: cpSlice(text, 0, cursor) + cpSlice(text, cursor + 1), + cursor, + }; +} + +export function moveCaretLeft(state: LineState): LineState { + const { cursor } = clampCaret(state); + return cursor === 0 ? state : { ...state, cursor: cursor - 1 }; +} + +export function moveCaretRight(state: LineState): LineState { + const clamped = clampCaret(state); + return clamped.cursor >= cpLen(clamped.text) + ? clamped + : { ...clamped, cursor: clamped.cursor + 1 }; +} + +/** To the first code point of the caret's line. */ +export function moveCaretHome(state: LineState): LineState { + const clamped = clampCaret(state); + return { + text: clamped.text, + cursor: lineBounds(clamped.text, clamped.cursor).start, + }; +} + +/** + * Past the last code point of the value. ink's END binding does that rather + * than the line-end jump its reducer would make: TextInput moves to the line + * end and then to `cpLen(text)`, so the two differ only after a paste has left + * the caret on an earlier line. + */ +export function moveCaretEnd(state: LineState): LineState { + const clamped = clampCaret(state); + const end = cpLen(clamped.text); + return clamped.cursor === end ? clamped : { text: clamped.text, cursor: end }; +} + +export function deleteWordLeftAtCaret(state: LineState): LineState { + const { text, cursor } = clampCaret(state); + const { start, line } = lineBounds(text, cursor); + const col = cursor - start; + // Column 0 of the first line deletes nothing; on a later line the caret sits + // after that line's break, so deleting it joins the lines — ink's own + // delete-word-left fallback. + if (col === 0) return cursor === 0 ? state : backspaceAtCaret(state); + const boundary = findPrevWordStart(getWordBoundaries(line), col); + const fallback = findPrevWordStartInLine(line, col); + const target = start + (boundary ?? fallback ?? 0); + return { + text: cpSlice(text, 0, target) + cpSlice(text, cursor), + cursor: target, + }; +} + +/** + * ink's `TextBuffer#handleInput` order, restricted to the keys a one-line + * dialog field can receive. `null` means the field leaves the key to the + * dialog: Enter, the option-list navigation keys, and every modifier combo + * this port does not bind. + */ +export function applyLineKey( + state: LineState, + key: Pick, +): LineState | null { + const { name, ctrl, meta, sequence } = key; + const modified = ctrl || meta; + const next = clampCaret(state); + + if (name === 'left' && !modified) return moveCaretLeft(next); + if (ctrl && name === 'b') return moveCaretLeft(next); + if (name === 'right' && !modified) return moveCaretRight(next); + if (ctrl && name === 'f') return moveCaretRight(next); + if (name === 'home' || (ctrl && name === 'a')) return moveCaretHome(next); + if (name === 'end' || (ctrl && name === 'e')) return moveCaretEnd(next); + if (ctrl && name === 'w') return deleteWordLeftAtCaret(next); + if (modified && (name === 'backspace' || sequence === '\x7f')) { + return deleteWordLeftAtCaret(next); + } + if (name === 'backspace' || sequence === '\x7f' || (ctrl && name === 'h')) { + return backspaceAtCaret(next); + } + if (name === 'delete' && !modified) return deleteAtCaret(next); + return null; +} + +/** + * Caret for a dialog field that keeps its value in the dialog's own state. + * + * The mirror exists for the same reason ink's buffer does: a key-event batch + * sees the render that registered the handler, whose `value` prop is already + * stale by the second keystroke. Writing it synchronously lets each event edit + * what the previous one produced, and re-syncing it during render keeps a value + * replaced from elsewhere (a preset, a go-back) from stranding the caret. + */ +export function useLineEdit( + value: string, + onChange: (next: string) => void, + /** + * Change it to treat the field as freshly mounted: the caret starts past the + * value, which is where ink's TextInput puts it on mount. + */ + mountKey?: unknown, +): { + /** Caret offset to render. */ + caret: number; + /** + * ink's TextInput key handling. `false` means the field leaves the key to the + * dialog — Enter, list navigation, and every combo this port doesn't bind. + */ + handleKey: (key: Pick) => boolean; + /** Inserts at the caret: a paste, or a printable key. */ + insert: (text: string) => void; +} { + const [, repaint] = useState(0); + const mirror = useRef(endOfLine(value)); + const mounted = useRef(mountKey); + if (mounted.current !== mountKey) { + mounted.current = mountKey; + mirror.current = endOfLine(value); + } else { + mirror.current = clampCaret({ + text: value, + cursor: mirror.current.cursor, + }); + } + const commit = (next: LineState) => { + const changed = next.text !== mirror.current.text; + mirror.current = next; + // A caret-only move changes no text, so it needs its own repaint. + repaint((frame) => frame + 1); + if (changed) onChange(next.text); + }; + return { + caret: mirror.current.cursor, + handleKey: (key) => { + const edited = applyLineKey(mirror.current, key); + if (!edited) return false; + commit(edited); + return true; + }, + insert: (text) => { + const edited = insertAtCaret(mirror.current, text); + if (edited !== mirror.current) commit(edited); + }, + }; +} From a842a44918b4e603ea60398bcdd4a7cc05840222 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 02:47:36 +0800 Subject: [PATCH 22/51] docs: correct the cursor-cell frames recorded for the dialog caret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage paragraph claimed ink's cursor cell was absent in the two frames before the one where both legs show it, and attributed that to the blink interval. Reading the captures says otherwise. The cell is on screen at two checkpoints, not one — over the same character in the same column on both legs each time — and in the frame between them the caret rests past the last character, so the cell each leg would paint is a trailing blank the capture drops and neither side shows one. The frame where the two legs really do differ is the first, where the field is empty and this renderer paints the cursor over the placeholder's first character while ink shows the placeholder whole; that one is the blink phase, and it is the only absence the paragraph should lean on. The same re-read puts this renderer's leg of that scenario at 13 s rather than the 14 recorded, against ink's 251. The commit message of the change these notes were written for carries the earlier wording; it is left as pushed, and this is the correction to the record. --- .../2026-09-10-opentui-parity-defect-sweep.md | 27 ++++++++++--------- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 16 ++++++----- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 25b8a3f51ea..22bfc71daa7 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1035,7 +1035,7 @@ the dialog off a repaint timer. That interval shows up in a measurement, not jus in the source: over a scenario that parks the caret in the free-text row, the ink leg wrote 270 KB in each sixty-second window it sat there, four of the scenario's seven settling waits burned their full timeout, and the leg took 251 s where this -renderer's took 14 s. +renderer's took 13 s. Mount semantics differ per field, because ink's do. The question dialog's row is mounted by that dialog only while its own option is the selected one, and mounted @@ -1064,17 +1064,20 @@ legs, with a single question so that neither the tab clamp nor the double-fire recorded under Decision 21 can be what produced the frame: six characters typed, the caret taken back two cells, one character inserted. Both legs show `> abcdXef` at the same column of the same row and both record `abcdXef` as -the answer. The styled capture pins the cell itself — at the one checkpoint -where both frames caught ink's cursor visible it sits on the same column over -the same character on both sides, differing only in the two colours; at the -two before it ink's cell is simply absent, which is its interval rather than a -missing cursor. What no scenario reaches is the authentication wizard, as -before: its four fields' editing rests on the unit suites alone. One -test-runtime note belongs here because it cost a debugging pass: the DOM -runtime the field tests use keeps earlier renders of a re-rendered row -mounted beside the live one, so the probe that reads the cursor cell reads -the last one. Rendering the same shape through the real renderer over five -state changes leaves exactly one row, so this is the harness, not the dialog. +the answer. The styled capture pins the cell itself. At the two checkpoints +where ink draws it, the cell sits over the same character in the same column on +both sides and differs only in its two colours. At the checkpoint before those +the caret rests past the last character, so the cell each leg would draw is a +trailing blank the capture drops, and neither shows one. At the first, over an +empty field's placeholder, ink's cell is absent where this renderer's is +present, which is its interval rather than a missing cursor. What no scenario +reaches is the authentication wizard, as before: its four fields' editing rests +on the unit suites alone. One test-runtime note belongs here because it cost a +debugging pass: the DOM runtime the field tests use keeps earlier renders of a +re-rendered row mounted beside the live one, so the probe that reads the cursor +cell reads the last one. Rendering the same shape through the real renderer over +five state changes leaves exactly one row, so this is the harness, not the +dialog. ## Coverage boundary diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 72a48611d07..6a11382f464 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -745,7 +745,7 @@ composer 的光标一致。还有 ink 会跑一个 530 毫秒的定时器,只 一格的可见性,而一格常亮让对话框不必按定时器重绘。这个定时器不只是源码里的一行,它在测量 里看得见:在一个把光标停在自由文本行上的场景里,ink 那条腿每坐在这一行的六十秒里就写出 270 KB,场景的七次静止等待有四次把超时整个烧完,那条腿走了 251 秒,而本渲染器这条是 -14 秒。 +13 秒。 挂载语义按字段区分,因为 ink 的就是区分的。提问对话框的那一行只在自己的选项被选中时才 被对话框挂载,而且挂载时光标就在所存值之后,所以本移植把「该行被(重新)选中」当作一次 @@ -762,14 +762,16 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 覆盖是三个测试套件里新增的二十二条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 用它的字段 —— 外加七个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 上下文窗口字段的输入分支、两个跳至行尾的绑定、向前删除、带修饰键的 Delete 擦掉一个字符 -而不是把键交还、ctrl+W,以及一行不再重新挂载。 -实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问一个问题,这样标签钳制和 +而不是把键交还、ctrl+W,以及一行不再重新挂载。实机侧,新增场景在两条腿上都从自由文本行 +回答一个问题,而且只问一个问题,这样标签钳制和 决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符,光标往回退两格,再插入一个 字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 `abcdXef` 记成了答案。带样式的 -抓取把那一定位钉死了 —— 在两条腿同时抓到的那一帧里,ink 的光标格与本渲染器的落在同一 -列的同一个字符上,两边只有两种颜色不同;在它之前那两帧,ink 的那一格根本不在,那是它的 -翻转间隔,不是缺了光标。没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的 -编辑只有单元测试套件兜着。还有一条测试运行时的说明值得写在这里,因为它花掉了一轮排障: +抓取把那一定位钉死了。在 ink 画出那一格的两帧里,它与本渲染器的那一格落在同一个字符的 +同一列上,只有两种颜色不同;在它之前的那一帧,光标停在最后一个字符之后,两条腿各自要画 +的那一格都是一个被抓取丢掉的尾随空格,所以两边都不见格;而在最早那一帧,字段还空着、 +显示的是占位符,ink 的那一格不在而本渲染器的在 —— 那是它的翻转间隔,不是缺了光标。 +没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的编辑只有单元测试套件兜着。 +还有一条测试运行时的说明值得写在这里,因为它花掉了一轮排障: 字段测试用的那套 DOM 运行时会把重渲染行的旧渲染留在活的那一行旁边,所以读光标格的探针读 的是最后一个。把同样的 JSX 形状放进真实渲染器改五次状态,行始终只有一行,所以这是脚手架 而不是对话框的问题。 From aff548156e3affe0edb2862943ed97b1b2e73b50 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 13:32:25 +0800 Subject: [PATCH 23/51] fix(cli): point the awaiting arrow at the call the queue can answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker that says "this card is the one on screen asking for you" was derived from the transcript: the first row that is a pending, unfinished tool card. That is not the same call as the one whose confirmation is showing. A PreToolUse ask hook re-arms a call that was already approved by putting it at the back of the waiting queue, while its own card goes back to pending where it already sits in the transcript — so the two orders disagree, and the arrow landed on a card with nothing behind it to answer while the card that was actually waiting showed none. The queue knows which call is on screen, so the view is told its identifier and draws the marker only on a row that both carries it and is still an unanswered pending card. Keeping the second condition is what preserves the one-arrow rule: a card the queue names but the transcript has already settled shows nothing, exactly as before. Where there is no queue at all, no row is marked. --- .../cli/src/ui/opentui/start-opentui-ui.tsx | 3 +- .../src/ui/opentui/transcript-view.test.tsx | 29 +++++++++++++++++++ .../cli/src/ui/opentui/transcript-view.tsx | 13 ++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx index ec4b686b199..4ca5b37b807 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -247,10 +247,11 @@ function OpenTuiEntryApp({ availableTerminalHeight={height} thoughtsExpanded={thoughtsExpanded} showToolCallArgs={settings.merged.ui?.showToolCallArgs === true} + awaitingCallId={live.waitingCalls[0]?.callId} /> ), - [live.items, width, height, thoughtsExpanded, settings], + [live.items, live.waitingCalls, width, height, thoughtsExpanded, settings], ); const handleRenderError = useCallback( diff --git a/packages/cli/src/ui/opentui/transcript-view.test.tsx b/packages/cli/src/ui/opentui/transcript-view.test.tsx index 5cc790b9c75..c228dcf8322 100644 --- a/packages/cli/src/ui/opentui/transcript-view.test.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.test.tsx @@ -77,6 +77,7 @@ describe('OpenTuiTranscriptView', () => { // args — so the card is the only surface that carries the arguments. const { container } = render( { const pending = render( { const settled = render( { const { container } = render( { { // every pending row would point at calls with nothing on screen to answer. const { container } = render( { expect(text.indexOf('←')).toBeLessThan(text.indexOf('echo two')); }); + it('marks the awaiting call the queue names, not the first pending row', () => { + // A PreToolUse `ask` hook re-arms a call that already left the queue: it is + // appended behind the call still waiting, while its own card goes back to + // pending where it sits in the transcript. The dialog on screen is the + // queue's head, so that is the card carrying the arrow. + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text.split('←')).toHaveLength(2); + expect(text.indexOf('←')).toBeGreaterThan(text.indexOf('echo two')); + }); + it('renders the ! shell row with the ink $ prefix', () => { const { container } = render( item.kind === 'tool' && item.confirm === 'pending' && !item.done, + (item) => + item.kind === 'tool' && + item.confirm === 'pending' && + !item.done && + item.id === awaitingCallId, )?.id; return ( From 843d481325cb16d0aa04ab7d2dbd259bbf3748d5 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 13:32:55 +0800 Subject: [PATCH 24/51] fix(cli): keep a keystroke burst intact across the dialog fields and rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first review round found, for the shared line-input helper, the same defect that had already been closed for the composer: a dialog read the value out of its own state, so a keystroke handled after another one in the same batch appended to what the last render had drawn and the earlier character was lost. That class was not confined to one helper. Every dialog row that answers from the position it last drew has it, and a terminal delivers three ordinary things as one burst: a held arrow repeats about every thirty milliseconds, a bracketed paste arrives with the Enter its terminal appends, and a fast typist's keys land together. The question dialog now keeps its tab, its cursor row, its checked options and each free-text entry in a mirror written as the key is handled, and derives what a key acts on from the burst's live position rather than from the render that installed the handler. So a held arrow walks every row it repeats through and a Space ticks the one it reached, and an Enter that follows a typed answer within one read commits what was typed instead of refusing the empty value it was showing when the read began. The same fix in the authentication wizard carries the endpoint typed in one batch with its Enter all the way to the saved plan, where it used to be discarded and replaced by the protocol default. Two consequences of making a mid-read submit work are closed with it. A field now stops taking keys once the Enter it handled has moved the step on, so the rest of that read cannot write into the step the burst already left behind; a refused Enter, which leaves the step mounted, keeps taking them. And a manual tab move calls off the swap the answer scheduled a beat earlier, which otherwise fired one tab further on and skipped a question nobody had answered. A caret the value's owner refused now falls back to the last offset the accepted text agrees with, so a character a field strips leaves no trace at all: previously it stepped the caret right, and the next erasure deleted a character the user never typed. Along with it, a field that has lost the focus prints the character the caret is on rather than dropping it — the highlight is what says which row owns the cursor, and omitting the character printed a value one shorter than the one held. None of this reaches a machine: the acceptance harness writes one character per write and never pastes into a dialog, so every case here is a unit test, and each of the behaviours above is pinned by a mutation that fails exactly the test owning it. --- .../cli/src/ui/auth/useProviderSetupFlow.ts | 44 +-- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 267 +++++++++++++++++- packages/cli/src/ui/opentui/dialogs-auth.tsx | 64 +++-- .../src/ui/opentui/dialogs-confirm.test.tsx | 198 +++++++++++++ .../cli/src/ui/opentui/dialogs-confirm.tsx | 177 ++++++++---- packages/cli/src/ui/opentui/line-edit.test.ts | 24 ++ packages/cli/src/ui/opentui/line-edit.ts | 71 ++++- 7 files changed, 739 insertions(+), 106 deletions(-) diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts index 19ba8b0d392..9e51ffe5575 100644 --- a/packages/cli/src/ui/auth/useProviderSetupFlow.ts +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -224,25 +224,31 @@ export function useProviderSetupFlow( [goNext], ); - const submitBaseUrl = useCallback((): boolean => { - // Empty input falls back to the placeholder default so the visible hint - // matches what gets written. - const effective = baseUrl.trim() || baseUrlPlaceholder.trim(); - if (!effective) { - setBaseUrlError(t('Base URL cannot be empty.')); - return false; - } - if (!/^https?:\/\//i.test(effective)) { - setBaseUrlError(t('Base URL must start with http:// or https://.')); - return false; - } - if (!baseUrl.trim()) { - setBaseUrl(effective); - } - setBaseUrlError(null); - goNext(); - return true; - }, [baseUrl, baseUrlPlaceholder, goNext]); + const submitBaseUrl = useCallback( + (valueOverride?: string): boolean => { + // The caller's live field text, when it has one: a keystroke burst can leave + // `baseUrl` a whole batch behind. + const current = valueOverride ?? baseUrl; + // Empty input falls back to the placeholder default so the visible hint + // matches what gets written. + const effective = current.trim() || baseUrlPlaceholder.trim(); + if (!effective) { + setBaseUrlError(t('Base URL cannot be empty.')); + return false; + } + if (!/^https?:\/\//i.test(effective)) { + setBaseUrlError(t('Base URL must start with http:// or https://.')); + return false; + } + if (!current.trim()) { + setBaseUrl(effective); + } + setBaseUrlError(null); + goNext(); + return true; + }, + [baseUrl, baseUrlPlaceholder, goNext], + ); const changeBaseUrl = useCallback((value: string) => { setBaseUrl(value); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index a03c54d9b7a..28d611950cb 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -112,6 +112,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { import { AuthType } from '@qwen-code/qwen-code-core'; import * as coreRuntime from '@qwen-code/qwen-code-core'; +import { ICON } from '../constants.js'; import { OpenTuiAuthDialog } from './dialogs-auth.js'; function baseKeyEvent(overrides: Record = {}) { @@ -474,6 +475,39 @@ describe('bracketed-paste into dialog inputs (#57)', () => { await press('return'); // baseUrl → apiKey } + /** Walk the wizard up to the base-URL input step. */ + async function runToBaseUrlInput(): Promise { + renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → protocol + await press('return'); // protocol: OpenAI-compatible → baseUrl input + } + + /** The step titles the wizard walks through from the API key to the review. */ + async function runToReviewStep(): Promise { + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('return'); // advancedConfig → review + } + + /** + * A held key repeats out of one stdin read, and so does a bracketed paste's + * trailing Enter: every keystroke and the Enter that commits them are handled + * against the render that registered the handler. + */ + async function typeBatchedThenEnter(text: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of text) { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + } + it('keeps every character of a burst that shares one batch', async () => { await runToApiKeyStep(); await typeBatched('sk-burst-key'); @@ -485,6 +519,133 @@ describe('bracketed-paste into dialog inputs (#57)', () => { expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); }); + it('submits the burst that shares one batch with its Enter', async () => { + await runToApiKeyStep(); + await typeBatchedThenEnter('sk-burst-key'); + // The Enter read the flow's state, which no render had refreshed yet, so the + // step refused the key it was showing and stayed put. + expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); + }); + + it('carries the URL typed in the same batch as its Enter to the review', async () => { + await runToBaseUrlInput(); + await typeBatchedThenEnter('https://x.test'); + // A stale empty URL fell back to the protocol default without an error, so + // the wizard advanced and saved the endpoint nobody typed. + expect(screen.getByText(/Step 3\/6 · API Key/)).toBeTruthy(); + await runToReviewStep(); + expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); + expect(document.body.textContent).toContain('"baseUrl": "https://x.test"'); + expect(document.body.textContent).not.toContain('api.openai.com'); + }); + + it('ignores the keystrokes that trail the Enter of the same read', async () => { + await runToApiKeyStep(); + const build = vi.spyOn(coreRuntime, 'buildInstallPlan'); + try { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of 'sk-test') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + // The read is not over: the Enter moved the wizard on, and this key is + // still dispatched to the step that was on screen when it started. + handler(baseKeyEvent({ name: 'Z', sequence: 'Z' })); + }); + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('return'); // advancedConfig → review + await press('return'); // review → save + await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); + expect(build.mock.calls[0]?.[1]?.apiKey).toBe('sk-test'); + } finally { + build.mockRestore(); + } + }); + + it('ignores the keystroke that trails the models step Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models (custom input focused) + const build = vi.spyOn(coreRuntime, 'buildInstallPlan'); + try { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of 'mod') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + handler(baseKeyEvent({ name: 'Z', sequence: 'Z' })); + }); + await press('return'); // advancedConfig → review + await press('return'); // review → save + await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); + expect(build.mock.calls[0]?.[1]?.modelIds).toEqual(['mod']); + } finally { + build.mockRestore(); + } + }); + + it('ignores the digit that trails the advanced-config step Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('down'); // thinking → modality + await press('down'); // modality → context window + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of '12') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + handler(baseKeyEvent({ name: '3', sequence: '3' })); + }); + // The review step prints what will be saved, and the Enter that left the + // advanced-config step carried 12. The trailing digit belongs to no step. + expect(document.body.textContent).toContain('"contextWindowSize": 12'); + expect(document.body.textContent).not.toContain('"contextWindowSize": 123'); + }); + + it('keeps taking the read after an Enter the step refused', async () => { + await runToBaseUrlInput(); + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of 'abc') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + // A refused Enter keeps the step mounted, so the read is still this + // field's: only an Enter that moved the wizard ends it. + for (const char of 'def') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + }); + expect( + screen.getByText((_, element) => element?.textContent === 'abcdef'), + ).toBeTruthy(); + expect( + screen.getByText(/Enter the API endpoint for this protocol/), + ).toBeTruthy(); + }); + + it('submits a paste that shares its read with the Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models + const pasteHandler = mocks.state.pasteHandlers.at(-1); + if (!pasteHandler) throw new Error('no paste handler registered'); + const keyHandler = lastKeyboardHandler(); + await act(async () => { + pasteHandler(makePasteEvent('pasted-model')); + keyHandler(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + await press('return'); // advancedConfig: skip → review + expect(document.body.textContent).toContain('"id": "pasted-model"'); + }); + it('inserts a paste into the API-key input and prevents default', async () => { await runToApiKeyStep(); const event = await pasteText('sk-pasted-key'); @@ -653,13 +814,109 @@ describe('caret editing in dialog text fields (#107)', () => { await press('left'); await typeText('9'); expect(focusedField()).toEqual({ text: '12934', cell: '3' }); - // The step's setter keeps digits only, so a letter never reaches the value. - // The caret still advances one cell, as ink's does; ink also keeps the letter - // on screen, because its field renders its own buffer rather than the value. + // The step's setter keeps digits only, so a letter never reaches the value — + // and never moves the caret either, so the next digit lands where this one + // was parked. ink stores the rejected letter in its own buffer instead, + // which is recorded as a divergence rather than ported. await typeText('x'); - expect(focusedField()).toEqual({ text: '12934', cell: '4' }); + expect(focusedField()).toEqual({ text: '12934', cell: '3' }); + await typeText('7'); + expect(focusedField()).toEqual({ text: '129734', cell: '3' }); await press('return'); expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); - expect(document.body.textContent).toContain('"contextWindowSize": 12934'); + expect(document.body.textContent).toContain('"contextWindowSize": 129734'); + }); + + /** The text the row owning the context-window field renders. */ + function contextRow(): string { + const rows = [...document.querySelectorAll('div')].filter((el) => + (el.textContent ?? '').includes('Context window'), + ); + const last = rows[rows.length - 1]; + if (!last) throw new Error('no context-window row rendered'); + return last.textContent ?? ''; + } + + it('prints the whole value of a field that lost the cursor', async () => { + await runToContextWindowRow(); + await typeText('1234'); + await press('left'); + expect(focusedField()).toEqual({ text: '1234', cell: '4' }); + // ↑ hands the cursor to the toggle row above, as ink's does, and the field + // keeps printing every character: the highlight marks which row the caret is + // in, it is not part of the value. + await press('up'); + expect(contextRow()).toMatch(/Context window: 1234$/); + await press('down'); + expect(focusedField()).toEqual({ text: '1234', cell: '4' }); + }); +}); + +describe('recommended-model checkboxes out of one read (#113)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + core.applyProviderInstallPlan.mockReset().mockResolvedValue(undefined); + core.logAuth.mockReset(); + }); + + const SPACE = { name: 'space', sequence: ' ' }; + const ENTER = { name: 'return', sequence: '\r' }; + + /** Every key of one stdin read, dispatched without a render in between. */ + async function pressBatched( + keys: Array>, + ): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const key of keys) handler(baseKeyEvent(key)); + }); + } + + /** + * Walk the DeepSeek wizard to the model-IDs step with the recommended list + * holding focus. A custom provider ships no recommended list, so a preset is + * the only route to the checkboxes. + */ + async function runToRecommendedList(): Promise { + renderDialog(); + await press('down'); // main: THIRD_PARTY_PROVIDERS + await press('return'); // → thirdparty-select, DeepSeek on top + await press('return'); // DeepSeek → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models (custom-ID input focused) + await press('tab'); // → recommended list, first row + } + + function recommendedRow(id: string): string { + const row = screen.getByText(id).parentElement; + if (!row) throw new Error(`the ${id} row is not mounted`); + return row.textContent ?? ''; + } + + it('applies every tick of a held Space to the same checkbox', async () => { + await runToRecommendedList(); + // DeepSeek pre-checks both of its models, so the step opens filled. + expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.RADIO_FILLED); + await pressBatched([SPACE, SPACE]); + // Two ticks out of one read cancel each other. Held from the render that + // armed the handler, the second one re-toggles what the first one changed. + expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.RADIO_FILLED); + await pressBatched([SPACE]); + // ...and one tick still clears the row, so the batch above is not a no-op. + expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.CIRCLE_EMPTY); + }); + + it('submits the tick made in the same read as Enter', async () => { + await runToRecommendedList(); + const build = vi.spyOn(coreRuntime, 'buildInstallPlan'); + try { + await pressBatched([SPACE, ENTER]); + await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); + expect(build.mock.calls[0]?.[1]?.modelIds).toEqual(['deepseek-v4-flash']); + } finally { + build.mockRestore(); + } }); }); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index 48721bf2214..83dce745c9c 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -20,7 +20,7 @@ * - documentation/TOS links render as plain text (no OSC 8 in dialogs). */ -import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useKeyboard, usePaste, useRenderer } from '@opentui/react'; import type { PasteEvent } from '@opentui/core'; import { decodePasteBytes } from '@opentui/core'; @@ -243,7 +243,14 @@ function FieldText({ return ( <> {spans.before} - {active && {spans.at || ' '}} + {/* The character belongs to the value whatever owns the focus; only the + highlight says which field the caret is in. Dropping it while inactive + would print a value one character shorter than the one held. */} + {active ? ( + {spans.at || ' '} + ) : ( + {spans.at} + )} {spans.after} ); @@ -274,18 +281,21 @@ function InputLine({ /** * Shared single-line text-input key handling (backend ask-user parity). Returns - * the caret offset so the row can put its cursor cell where ink's would be. + * the caret offset so the row can put its cursor cell where ink's would be. The + * submit's own verdict is what settles the field, so a step that reports nothing + * cannot leave the latch unarmed. */ function useLineInputKeys( value: string, onChange: (next: string) => void, - onSubmit: () => void, + onSubmit: (text: string) => boolean, ): number { const line = useLineEdit(value, onChange); useKeyboard((key) => { + if (line.settled) return; const o = toOriginalKey(key); if (o.name === 'return' || o.name === 'enter') { - onSubmit(); + if (onSubmit(line.text)) line.settle(); return; } if (!line.handleKey(o) && isPrintableKeyInput(key)) { @@ -389,8 +399,10 @@ function BaseUrlInputStep({ flow: ProviderSetupFlow; documentationUrl?: string; }) { - const caret = useLineInputKeys(flow.state.baseUrl, flow.changeBaseUrl, () => - flow.submitBaseUrl(), + const caret = useLineInputKeys( + flow.state.baseUrl, + flow.changeBaseUrl, + (text) => flow.submitBaseUrl(text), ); return ( @@ -430,8 +442,8 @@ function ApiKeyStep({ flow: ProviderSetupFlow; }) { const docUrl = resolveDocumentationUrl(provider, flow.state.baseUrl); - const caret = useLineInputKeys(flow.state.apiKey, flow.changeApiKey, () => - flow.submitApiKey(flow.state.apiKey), + const caret = useLineInputKeys(flow.state.apiKey, flow.changeApiKey, (text) => + flow.submitApiKey(text), ); return ( @@ -494,6 +506,11 @@ function ModelsStep({ () => new Set(selectedModelIds.filter((id) => recommendedIds.has(id))), ); + // Keystrokes of one burst are handled against the render that registered the + // handler, whose `checked` set is already stale by the second Space. The + // mirror is written synchronously so each tick sees the previous one. + const checkedRef = useRef>(checked); + const syncModelIds = useCallback( (custom: string, keys: ReadonlySet) => { flow.changeModelIds( @@ -506,9 +523,9 @@ function ModelsStep({ const updateCustom = useCallback( (next: string) => { setCustomText(next); - syncModelIds(next, checked); + syncModelIds(next, checkedRef.current); }, - [checked, syncModelIds], + [syncModelIds], ); // ink keeps this field in a TextInput whose buffer survives the list taking @@ -517,22 +534,31 @@ function ModelsStep({ const toggleRecommended = useCallback( (id: string) => { - const next = new Set(checked); + const next = new Set(checkedRef.current); if (next.has(id)) next.delete(id); else next.add(id); + checkedRef.current = next; setChecked(next); - syncModelIds(customText, next); + syncModelIds(custom.text, next); }, - [checked, customText, syncModelIds], + [custom, syncModelIds], ); const submit = useCallback(() => { - flow.submitModelIds({ - modelIds: uniqueIds([...normalizeModelIds(customText), ...checked]), - }); - }, [customText, checked, flow]); + if ( + flow.submitModelIds({ + modelIds: uniqueIds([ + ...normalizeModelIds(custom.text), + ...checkedRef.current, + ]), + }) + ) { + custom.settle(); + } + }, [custom, flow]); useKeyboard((key) => { + if (custom.settled) return; const o = toOriginalKey(key); if (focus >= 0) { if (o.name === 'tab') { @@ -646,6 +672,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { const onCtxRow = focusedConfigIndex === ctxIdx; const ctxField = useLineEdit(contextWindowSize, flow.changeContextWindowSize); useKeyboard((key) => { + if (ctxField.settled) return; const o = toOriginalKey(key); // Focus-row navigation restricted to unambiguous shortcuts (ink parity: // a letter typed into the context-window field must not move the row). @@ -666,6 +693,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { } if (o.name === 'return') { flow.submitAdvancedConfig(); + ctxField.settle(); return; } if (onCtxRow && !ctxField.handleKey(o) && isPrintableKeyInput(key)) { diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index a159dc5668f..5383a014865 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -134,6 +134,7 @@ const askDetails = ( outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload, ) => Promise = async () => {}, + multiSelect?: boolean, ): ToolCallConfirmationDetails => ({ type: 'ask_user_question', title: 'A question', @@ -141,6 +142,7 @@ const askDetails = ( { question: 'Pick one', header: 'Choice', + multiSelect, options: options ?? [{ label: 'A', description: 'option a' }], }, ], @@ -467,6 +469,21 @@ describe('OpenTuiToolConfirmation', () => { }); } + /** + * Several keys in one batch. A held arrow key auto-repeats at ~30 ms and the + * terminal delivers the events in one read, so every key of the burst is + * handled against the same render. + */ + function pressBatched(keys: Array<{ name: string; sequence?: string }>) { + act(() => { + for (const key of keys) { + for (const handler of mocks.state.keyboardHandlers) { + handler(key); + } + } + }); + } + function paste(text: string) { act(() => { for (const handler of mocks.state.pasteHandlers) { @@ -569,6 +586,94 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('opens the free-text row for the letters that follow its digit in one batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + // The digit moves the cursor onto the free-text row and the letters land in + // the same read, so the row has to own them without a render in between. + typeBatched('3abc'); + expect(container.textContent ?? '').toContain('> abc'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'abc' } }, + ); + }); + + it('walks a held arrow key over every row it repeats through', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + pressBatched([ + { name: 'down' }, + { name: 'down' }, + { name: 'down' }, + { name: 'x', sequence: 'x' }, + ]); + expect(container.textContent ?? '').toContain('> x'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('commits the row the cursor ended on when its Enter shares one batch', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + pressBatched([{ name: 'down' }, { name: 'return', sequence: '\r' }]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + + it('unticks an option when both ticks of a burst share one batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + pressBatched([ + { name: 'space', sequence: ' ' }, + { name: 'space', sequence: ' ' }, + ]); + expect(container.textContent ?? '').not.toContain('[✓]'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('sends an option ticked in the same batch as its Enter', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm, true)); + pressBatched([ + { name: 'space', sequence: ' ' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'A' } }, + ); + }); + + it('ticks the option an arrow reached in the same batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm, true)); + pressBatched([{ name: 'down' }, { name: 'space', sequence: ' ' }]); + // Held arrow + Space out of one read: the cursor moved to B, and the Space + // has to follow it instead of re-toggling the row this render drew. + expect(container.textContent ?? '').toContain('[✓] 2. B'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + + it('answers the option an arrow reached in the same batch', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + pressBatched([{ name: 'down' }, { name: 'return', sequence: '\r' }]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + it('edits the middle of a typed answer', () => { const onConfirm = vi.fn(async () => {}); const container = mount(askDetails(twoOptions, onConfirm)); @@ -658,6 +763,22 @@ describe('OpenTuiToolConfirmation', () => { expect(onConfirm).not.toHaveBeenCalled(); }); + it('keeps the question a manual tab move lands on during a pending pause', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + act(() => { + vi.advanceTimersByTime(50); + }); + // The answer to the first question still has its swap pending, and looking + // ahead by hand must not have that swap fire one tab further on. + press({ name: 'right' }); + settleAdvance(); + expect(container.textContent ?? '').toContain('Pick a region?'); + expect(container.textContent ?? '').not.toContain('Pick channels?'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + it('joins the checked options and counts the typed entry on a multi-select', () => { const onConfirm = vi.fn(async () => {}); const container = mount(multiAskDetails(onConfirm)); @@ -679,6 +800,83 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('takes the typed entry of the tab the cursor is on, not the one last drawn', () => { + // The tab move and the Enter that follows it are handled against the render + // that drew the previous question's empty entry, so the value has to be + // read from the live tab the way submitCustomRow reads it. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('sms'); + press({ name: 'up' }); + press({ name: 'left' }); + pressBatched([{ name: 'right' }, { name: 'return', sequence: '\r' }]); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: sms'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('drops the letter that trails the Enter of the same read', () => { + // A multi-select's answer is assembled from the field again at submit-all + // time, so a letter handled after the Enter that already gave the answer + // would widen it: the read has to stop at its own Enter. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + pressBatched([ + { name: 's', sequence: 's' }, + { name: 'm', sequence: 'm' }, + { name: 's', sequence: 's' }, + { name: 'return', sequence: '\r' }, + { name: 'Z', sequence: 'Z' }, + ]); + settleAdvance(); + expect(container.textContent ?? '').not.toContain('smsZ'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('drops the letters of a burst that moved to another tab', () => { + // The free-text field re-seeds its buffer during render, so the letters + // after a tab move in the same read would append to the question that + // render drew and store it under the tab the burst reached. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('ab'); + // The row owns ←/→ for in-field caret movement, so the cursor has to be + // off it before the arrow becomes a tab move. + press({ name: 'up' }); + pressBatched([ + { name: 'right' }, + { name: 'down' }, + { name: 'down' }, + { name: 'x', sequence: 'x' }, + ]); + expect(container.textContent ?? '').not.toContain('abx'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).not.toHaveBeenCalled(); + press({ name: 'up' }); + press({ name: 'left' }); + press({ name: 'down' }); + press({ name: 'down' }); + expect(container.textContent ?? '').toContain('> ab'); + }); + it('reviews every answer on the Submit tab and cancels from its second row', () => { const onConfirm = vi.fn(async () => {}); const container = mount(multiAskDetails(onConfirm)); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index c2bb40f3ffa..704a46086e8 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -646,17 +646,33 @@ function AskUserQuestionFlow(props: { // keystroke. The mirror is written synchronously so each event appends to // what the previous one produced. const typedRef = useRef>({}); + const tabRef = useRef(0); + const selectedRef = useRef(0); + const checkedRef = useRef>({}); const advanceTimer = useRef | null>(null); const { width } = useTerminalDimensions(); - const isSubmitTab = hasMultipleQuestions && tab === totalTabs - 1; - const question = isSubmitTab ? undefined : questions[tab]; - const isMultiSelect = question?.multiSelect === true; - // The free-text row sits after the predefined options. - const totalOptions = question ? question.options.length + 1 : 2; - const isCustomRow = - question !== undefined && selected === question.options.length; + // Derived from the two indices alone so the keyboard handler can read them + // for the batch's live position rather than for the one this render saw. + const viewOf = (tabIdx: number, selIdx: number) => { + const onSubmit = hasMultipleQuestions && tabIdx === totalTabs - 1; + const q = onSubmit ? undefined : questions[tabIdx]; + return { + isSubmitTab: onSubmit, + question: q, + isMultiSelect: q?.multiSelect === true, + // The free-text row sits after the predefined options. + totalOptions: q ? q.options.length + 1 : 2, + isCustomRow: q !== undefined && selIdx === q.options.length, + }; + }; + const { isSubmitTab, question, isMultiSelect, isCustomRow } = viewOf( + tab, + selected, + ); const typedValue = (idx: number) => typedRef.current[idx] ?? typed[idx] ?? ''; + const checkedLabels = (idx: number) => + checkedRef.current[idx] ?? checked[idx] ?? []; const customValue = typedValue(tab); const isCustomAnswer = question !== undefined && @@ -667,28 +683,52 @@ function AskUserQuestionFlow(props: { const answerFor = (idx: number): string | undefined => { const current = questions[idx]; if (!current?.multiSelect) return picked[idx]; - const labels = [...(checked[idx] ?? [])]; + const labels = [...checkedLabels(idx)]; const own = typedValue(idx).trim(); if (typedChecked[idx] && own) labels.push(own); return labels.length > 0 ? labels.join(', ') : undefined; }; + const moveToTab = (next: number) => { + tabRef.current = next; + setTab(next); + }; + + const moveToOption = (next: number) => { + selectedRef.current = next; + setSelected(next); + }; + + const toggleChecked = (idx: number, label: string) => { + const current = checkedLabels(idx); + const next = current.includes(label) + ? current.filter((value) => value !== label) + : [...current, label]; + checkedRef.current = { ...checkedRef.current, [idx]: next }; + setChecked((prev) => ({ ...prev, [idx]: next })); + }; + + const cancelPendingAdvance = () => { + if (advanceTimer.current) clearTimeout(advanceTimer.current); + }; + const selectAndAdvance = (value: string) => { - setPicked((prev) => ({ ...prev, [tab]: value })); + const idx = tabRef.current; + setPicked((prev) => ({ ...prev, [idx]: value })); if (!hasMultipleQuestions) { - onAnswered({ [tab]: value }); + onAnswered({ [idx]: value }); return; } - if (tab >= totalTabs - 1) return; + if (idx >= totalTabs - 1) return; // ink's pause, so the ✓ on the row just answered is visible before the tab // swap carries it up into the chip row. An answer landing inside that pause // supersedes the swap scheduled by the previous keystroke: left running, // both timers fire and the question between them is skipped without ever // being drawn. - if (advanceTimer.current) clearTimeout(advanceTimer.current); + cancelPendingAdvance(); advanceTimer.current = setTimeout(() => { - setTab((prev) => Math.min(prev + 1, totalTabs - 1)); - setSelected(0); + moveToTab(Math.min(tabRef.current + 1, totalTabs - 1)); + moveToOption(0); }, 150); }; @@ -702,19 +742,20 @@ function AskUserQuestionFlow(props: { }; const multiAnswer = (includeTyped: boolean, value: string) => { - const labels = [...(checked[tab] ?? [])]; + const labels = [...checkedLabels(tabRef.current)]; const own = value.trim(); if (includeTyped && own) labels.push(own); return labels.length > 0 ? labels.join(', ') : undefined; }; const writeCustomValue = (next: string) => { - typedRef.current = { ...typedRef.current, [tab]: next }; - setTyped((prev) => ({ ...prev, [tab]: next })); + const idx = tabRef.current; + typedRef.current = { ...typedRef.current, [idx]: next }; + setTyped((prev) => ({ ...prev, [idx]: next })); // A multi-select box tracks whether its free-text entry counts, so typing // into it checks the box and emptying it unchecks it again. - if (isMultiSelect) { - setTypedChecked((prev) => ({ ...prev, [tab]: next.trim().length > 0 })); + if (questions[idx]?.multiSelect === true) { + setTypedChecked((prev) => ({ ...prev, [idx]: next.trim().length > 0 })); } }; @@ -730,14 +771,18 @@ function AskUserQuestionFlow(props: { const submitCustomRow = () => { // Re-read rather than use the rendered value: the keystroke that fills the // row and this Enter can share one batch. - const current = typedValue(tab); + const idx = tabRef.current; + const current = typedValue(idx); const value = current.trim(); - if (isMultiSelect) { - setTypedChecked((prev) => ({ ...prev, [tab]: value.length > 0 })); + const isMulti = questions[idx]?.multiSelect === true; + if (isMulti) { + setTypedChecked((prev) => ({ ...prev, [idx]: value.length > 0 })); } if (!value) return; - const answer = isMultiSelect ? multiAnswer(true, current) : value; - if (answer !== undefined) selectAndAdvance(answer); + const answer = isMulti ? multiAnswer(true, current) : value; + if (answer === undefined) return; + custom.settle(); + selectAndAdvance(answer); }; useEffect( @@ -768,87 +813,107 @@ function AskUserQuestionFlow(props: { useKeyboard((key) => { const original = toOriginalKey(key); - - if (isCustomRow) { + // One stdin burst runs every key it carries against this one closure, so + // the position each branch acts on comes from the mirrors rather than from + // what this render drew. + const { + isSubmitTab: onReview, + question: asked, + isMultiSelect: multi, + totalOptions: optionCount, + isCustomRow: onCustomRow, + } = viewOf(tabRef.current, selectedRef.current); + + // The field re-seeds its buffer during render, so a tab move inside this + // burst leaves it holding the question that render drew. A keystroke handled + // now would append to that text and store it under the new tab, so the row + // keeps its keys only while the burst still stands on the tab it drew — and + // none at all once its answer has been given. + const fieldIsMine = tabRef.current === tab && !custom.settled; + + if (onCustomRow) { // Bare letters belong to the input and ←/→ must not switch tabs while it // owns the cursor, so only unambiguous shortcuts are honoured here. if (original.name === 'up' || (original.ctrl && original.name === 'p')) { - setSelected(Math.max(0, selected - 1)); + moveToOption(Math.max(0, selectedRef.current - 1)); } else if ( original.name === 'down' || (original.ctrl && original.name === 'n') ) { - setSelected(Math.min(totalOptions - 1, selected + 1)); + moveToOption(Math.min(optionCount - 1, selectedRef.current + 1)); } else if (original.name === 'return') { submitCustomRow(); - } else if (!custom.handleKey(original) && isPrintableKeyInput(key)) { + } else if ( + fieldIsMine && + !custom.handleKey(original) && + isPrintableKeyInput(key) + ) { custom.insert(key.sequence); } return; } - if (hasMultipleQuestions && original.name === 'left' && tab > 0) { - setTab(tab - 1); - setSelected(0); + if ( + hasMultipleQuestions && + original.name === 'left' && + tabRef.current > 0 + ) { + cancelPendingAdvance(); + moveToTab(tabRef.current - 1); + moveToOption(0); return; } if ( hasMultipleQuestions && original.name === 'right' && - tab < totalTabs - 1 + tabRef.current < totalTabs - 1 ) { - setTab(tab + 1); - setSelected(0); + cancelPendingAdvance(); + moveToTab(tabRef.current + 1); + moveToOption(0); return; } if (matchesCommand(Command.SELECTION_UP, key)) { - setSelected(Math.max(0, selected - 1)); + moveToOption(Math.max(0, selectedRef.current - 1)); return; } if (matchesCommand(Command.SELECTION_DOWN, key)) { - setSelected(Math.min(totalOptions - 1, selected + 1)); + moveToOption(Math.min(optionCount - 1, selectedRef.current + 1)); return; } const numKey = /^[1-9]\d*$/.test(original.sequence) ? Number(original.sequence) : NaN; - if (Number.isSafeInteger(numKey) && numKey <= totalOptions) { + if (Number.isSafeInteger(numKey) && numKey <= optionCount) { const target = numKey - 1; - setSelected(target); + moveToOption(target); // Single-select commits a predefined option straight from its digit; the // free-text row's digit only moves the cursor onto it. - const option = !isMultiSelect ? question?.options[target] : undefined; + const option = !multi ? asked?.options[target] : undefined; if (option) selectAndAdvance(option.label); return; } - if (original.name === 'space' && isMultiSelect && question) { - const option = question.options[selected]; - if (option) { - const current = checked[tab] ?? []; - setChecked((prev) => ({ - ...prev, - [tab]: current.includes(option.label) - ? current.filter((label) => label !== option.label) - : [...current, option.label], - })); - } + if (original.name === 'space' && multi && asked) { + const option = asked.options[selectedRef.current]; + if (option) toggleChecked(tabRef.current, option.label); return; } if (original.name === 'return') { - if (isSubmitTab) { - if (selected === 0) submitAll(); + if (onReview) { + if (selectedRef.current === 0) submitAll(); else onAnswered(null); return; } - if (isMultiSelect) { - const answer = multiAnswer(typedChecked[tab] === true, customValue); + if (multi) { + const idx = tabRef.current; + const answer = multiAnswer(typedChecked[idx] === true, typedValue(idx)); if (answer !== undefined) selectAndAdvance(answer); return; } - const option = question?.options[selected]; + const option = asked?.options[selectedRef.current]; if (option) selectAndAdvance(option.label); } // Escape is owned by OpenTuiToolConfirmation (it settles the whole call). diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 3f98a21a869..8e73c750095 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -15,6 +15,7 @@ import { cpLen } from '../utils/textUtils.js'; import { applyLineKey, backspaceAtCaret, + caretForAcceptedValue, caretSpans, clampCaret, deleteAtCaret, @@ -267,6 +268,29 @@ describe('line-edit', () => { expect(clampCaret(alreadyInside)).toBe(alreadyInside); }); + it('lets a value the owner refused leave no trace of its own', () => { + // The owner's text is what the caret indexes, so a character the field's + // setter strips back the caret to the last offset that text agrees with. + expect( + caretForAcceptedValue({ text: '129x34', cursor: 4 }, '12934'), + ).toEqual({ text: '12934', cursor: 3 }); + // Repeated refusals cannot walk the caret right: each starts from a state + // the accepted value already owns. + expect( + caretForAcceptedValue({ text: '12934', cursor: 3 }, '12934'), + ).toEqual({ text: '12934', cursor: 3 }); + // An unrelated value replaces the text, so the caret restarts at its front. + expect(caretForAcceptedValue({ text: 'abcdef', cursor: 6 }, 'xy')).toEqual({ + text: 'xy', + cursor: 0, + }); + // Same text: the plain clamp of an externally shortened value applies. + expect(caretForAcceptedValue({ text: 'ab', cursor: 9 }, 'ab')).toEqual({ + text: 'ab', + cursor: 2, + }); + }); + it('splits the value at the caret for cursor rendering', () => { expect(caretSpans({ text: 'abc', cursor: 1 })).toEqual({ before: 'a', diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index 44cda58464a..e4b41b854bc 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -67,6 +67,35 @@ export function clampCaret(state: LineState): LineState { return cursor === state.cursor ? state : { text: state.text, cursor }; } +/** + * Caret for a value the field's owner may have rewritten. + * + * The caret indexes the value the owner acknowledged, not the text this module + * last proposed: an owner that refuses a character would otherwise leave the + * caret one cell right of where the user put it, and the next Backspace would + * delete a character the user never inserted. Capping the caret at the code + * points the two texts agree on makes a refused keystroke leave no trace at all, + * and is just the clamp when they are equal. + */ +export function caretForAcceptedValue( + state: LineState, + accepted: string, +): LineState { + if (accepted === state.text) return clampCaret(state); + const proposed = toCodePoints(state.text); + const current = toCodePoints(accepted); + let shared = 0; + while ( + shared < proposed.length && + shared < current.length && + proposed[shared] === current[shared] + ) { + shared++; + } + const cursor = Math.min(state.cursor, shared); + return { text: accepted, cursor }; +} + /** The three pieces a caret splits the value into, for cursor rendering. */ export function caretSpans(state: LineState): { before: string; @@ -213,7 +242,8 @@ export function applyLineKey( * sees the render that registered the handler, whose `value` prop is already * stale by the second keystroke. Writing it synchronously lets each event edit * what the previous one produced, and re-syncing it during render keeps a value - * replaced from elsewhere (a preset, a go-back) from stranding the caret. + * replaced from elsewhere (a preset, a go-back) from stranding the caret, or one + * the owner refused a character of, from leaving that character's trace behind. */ export function useLineEdit( value: string, @@ -224,8 +254,13 @@ export function useLineEdit( */ mountKey?: unknown, ): { - /** Caret offset to render. */ - caret: number; + /** + * The field's value as the keystroke being handled sees it. Read per access, + * because the handler a burst runs in belongs to a render that predates it. + */ + readonly text: string; + /** Caret offset to render. Read per access, as {@link text} is. */ + readonly caret: number; /** * ink's TextInput key handling. `false` means the field leaves the key to the * dialog — Enter, list navigation, and every combo this port doesn't bind. @@ -233,18 +268,27 @@ export function useLineEdit( handleKey: (key: Pick) => boolean; /** Inserts at the caret: a paste, or a printable key. */ insert: (text: string) => void; + /** + * The field submitted and the wizard moved on. A keystroke handled now would + * land in the step this read already left, because the read keeps dispatching + * to the handler of the render that armed it. + */ + settle: () => void; + /** Whether {@link settle} was called on this field. */ + readonly settled: boolean; } { const [, repaint] = useState(0); + // Per render, not per mount: a fresh handler closes over a fresh copy of this, + // so the flag spans exactly one stdin read while the render that follows the + // submit clears it. A field whose submit was rejected stays editable. + let settled = false; const mirror = useRef(endOfLine(value)); const mounted = useRef(mountKey); if (mounted.current !== mountKey) { mounted.current = mountKey; mirror.current = endOfLine(value); } else { - mirror.current = clampCaret({ - text: value, - cursor: mirror.current.cursor, - }); + mirror.current = caretForAcceptedValue(mirror.current, value); } const commit = (next: LineState) => { const changed = next.text !== mirror.current.text; @@ -254,7 +298,12 @@ export function useLineEdit( if (changed) onChange(next.text); }; return { - caret: mirror.current.cursor, + get text() { + return mirror.current.text; + }, + get caret() { + return mirror.current.cursor; + }, handleKey: (key) => { const edited = applyLineKey(mirror.current, key); if (!edited) return false; @@ -265,5 +314,11 @@ export function useLineEdit( const edited = insertAtCaret(mirror.current, text); if (edited !== mirror.current) commit(edited); }, + settle: () => { + settled = true; + }, + get settled() { + return settled; + }, }; } From a2e4b8e5d3f8e4a21293b9310ee7d5f191583886 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 13:33:12 +0800 Subject: [PATCH 25/51] docs: record the keystroke-burst decisions and correct what the sweep claimed Five entries are added for this round: why a burst of keystrokes is handled by the render it started in and what the dialogs now mirror against that, why a manual tab move calls off a scheduled swap where the reference implementation lets both fire, why a caret the owner refused falls back and a field out of focus still prints its character, why the marker for an awaiting call is named by the queue rather than by transcript order, and what makes a field stop taking keys after its own read has submitted it. The coverage notes are corrected where they overclaimed. The reference implementation's own wizard suite was recorded as failing a fixed set of tests here; six runs at this head say the count moves with the machine, and a one-variable control says the shared provider-setup hook edited this round is not what fails them. Two gaps that this round leaves open are written down as well: the stop that settles a field does not settle a step, and it stops keys but not a paste that shares its read. --- .../2026-09-10-opentui-parity-defect-sweep.md | 283 ++++++++++++++++++ ...09-10-opentui-parity-defect-sweep.zh-CN.md | 204 +++++++++++++ 2 files changed, 487 insertions(+) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 22bfc71daa7..840eec94f70 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1079,6 +1079,236 @@ cell reads the last one. Rendering the same shape through the real renderer over five state changes leaves exactly one row, so this is the harness, not the dialog. +## Decision 33 — the keystrokes of one read are handled by the render they started in + +The first review round found the class Decision 24 had closed for the line-input +helper waiting in every other place that reads dialog state and writes it back. +The renderer's key hook keeps the handler in a reference it refreshes during the +layout phase of a commit, and one stdin read hands over the keys it carries back +to back — a held arrow's auto-repeat at ~30 ms, or a bracketed paste together +with the Enter the terminal tacks on. Nothing commits between them, so every key +after the first is handled by the closure the previous commit installed, and every +value it reads is one keystroke old. At human speed the bug cannot be seen, since +each keystroke flushes a render of its own, and a suite that spends one act per +character cannot see it either — which is how the existing dialog tests missed it. + +Each piece of state those handlers read to place the next keystroke now has a +mirror written in the same call as the setter, and the handler reads the mirror: +the question dialog's tab, its option cursor, its per-question checkbox sets and +its typed entries; the wizard's recommended-model set; the line editor's text and +caret. The question dialog's handler derives its whole view from the mirrors — +which question this is, single or multi select, free-text row or not — rather than +from what this render drew, so no branch can act on one keystroke's position with +another keystroke's value. Two reads of the answers themselves are the recorded +exceptions below. The line editor hands out getters over its mirror instead of +snapshots, since a consumer that captured its text once would have reintroduced +exactly this bug in one line. + +One read is narrower than the mirrors. The free-text row keeps its keys only while +the burst still stands on the tab that row was drawn for: the row's buffer is +re-seeded during render, so a letter landing after a tab move inside one read would +append to the question the render drew and store it as the answer of the question +the burst reached. Those keys are dropped, as they were before this round, and a +test of an arrow held across a tab boundary pins it. + +Submitting was the same bug pointed the other way. The wizard's endpoint and +key steps called their submit functions with no argument, so an Enter sharing a +read with the characters in front of it read the parent's state — still the value +from before those characters — and the wizard advanced on a half-typed endpoint, +falling back to the protocol's default host without an error. They take the live +text as an argument now, which is how ink's text input calls its submit with the +buffer's own text and how ink's question dialog declares its submit signature. The +recommended-model checkboxes came with the same finding: two ticks of a held Space +in one read, or a tick and the Enter that submits it, are one checkbox's two states +and a submission that knows about neither until the mirror is read. + +The hook is shared with ink's wizard, and ink's endpoint step already passes its +buffer text into it, so in this same case the ink leg now submits the value it +shows. Away from a read that carries several keys the two strings are identical; +this is the one point where the round changes ink's behaviour. + +Two mirrors are read where no keystroke sequence can show them disagreeing, and +that is written down here rather than counted as coverage. Ticking a recommended +model syncs the shared model-id string with the field's live text, and committing +the free-text row takes the tab it is standing on, but a burst cannot both edit a +field and tick a list row — the two branches are chosen by a focus the batch itself +cannot change — and a manual tab move puts the cursor on the first row, which is +the free-text row only for a question with no predefined options, which the tool's +own validation rejects. They are kept so that a handler has exactly one source for +each fact. Two siblings are recorded instead of fixed, both still read from state: +whether a multi-select's typed entry counts, so a read that types into the +free-text row, steps off it and submits leaves that entry out of the answer; and +the picked answers behind the review tab's submit, so a read that answers a +question, steps to the review tab and submits leaves that answer out. Each needs +three keystrokes in one read to show, which no scenario on either leg produces; +closing them adds one mirror each and joins the follow-ups. + +This change stops at the handlers the round was asked about, and the class is +wider than they are. Every other list in this renderer reads its cursor the same +way — the shared select hook seven other dialog files mount, the arena dialog's +model list, the composer's completion rows, the wizard's protocol and +endpoint-option steps, the focus of its recommended-model list (its check set is +mirrored above), its advanced-config row, and this dialog's own main +and sub menus. There the accepting key acts on the row the previous render drew +rather than the one the burst reached, and where a step is computed from that drawn +cursor — the shared hook, the endpoint-option list, and those two menus — a held +arrow moves one row per read instead of one per key. Closing it belongs to those +widgets together rather than to either dialog fixed here, and is recorded under +Follow-up. + +Coverage is fourteen new unit tests, five in the authentication suite and nine +in the confirmation dialog's, and eleven mutations, nine of which fail the tests that +own the behaviour taken away: a line editor back to a render snapshot, an Enter +submitting the prop value, the models step's check set read twice over (the tick +and the submission) with its field's live text read at the submission as well, a +question handler working from rendered state, the option reads of the Space +and Enter branches, and a burst that moves tab editing the question it left behind. +The two surviving mutations are the unreachable reads above. +What no evidence reaches is a machine: the acceptance harness types one character +per write on purpose, so a keystroke lands in its own read, and it never pastes +into these dialogs — every burst case here rests on the unit suites. + +## Decision 34 — a manual tab move calls off the swap already scheduled + +The question dialog answers a question, pauses 150 ms so the tick on the row just +answered is visible, then swaps to the next one. ink schedules that swap and never +calls it off: neither a second answer inside the pause nor a left or right arrow of +the user's own clears the timer, so two swaps can run and the question between them +is never drawn. The port inherited that, and the review round caught it on the +arrow case — the user had navigated to a question and the dialog took it away again +a tenth of a second later. + +Both paths now cancel a pending swap first: the arrow because the keystroke is the +user's own answer to "which question am I on", the second answer because it +supersedes the swap the first one scheduled. This is one of the places where the +port deliberately does not match ink; the same 150 ms pause, the same clamping at +the last tab, and the same tick-before-swap are kept. ink's behaviour is recorded +as a defect of its own rather than reproduced, and the double-fire the acceptance +matrix had already watched on ink's leg is the same defect seen from outside. + +Both paths are pinned in the confirmation dialog's suite, each by a mutation of +its own: a right arrow that lands during a pending pause keeps the question it +moved to, and removing that cancel fails this test alone; two answers inside one +pause leave exactly the swap the second one scheduled, in a test that predates +this round, and removing the cancel in the answer path fails that one alone. On a +machine the pause and the arrow were already covered by the matrix scenarios that +answer several questions; the cancellation itself was not observed there, since it +needs two keystrokes inside 150 ms. + +## Decision 35 — the cursor cell prints the character that owns the focus + +The field row draws its value in three parts: before the caret, the cell the caret +is on, after it. The first version of the highlighted cell printed the middle part +only while the field was focused, so a field that lost the cursor to a sibling lost +a character as well — the row displayed a value one code point shorter than the one +stored, which is what a review round's Critical was. The character belongs to the +value whoever owns the focus; only the background says which field the caret is in, +as it does in ink, whose text input decides whether to draw the cursor's +highlight while always drawing the text under it. + +The same row's caret now follows a rule the previous pass had inverted. The caret +indexes the value the field's owner acknowledged, not the one the keys typed: when +a keystroke reaches a field whose owner refuses it — a letter into the +context-window field, which keeps digits only — the accepted value has no character +at that position to hold a caret, so the cap at the shared prefix between the two +texts leaves the caret exactly where it was. Before that, each refused key moved +the caret one code point right, and the first backspace after a burst of them ate a +real character. Where the two texts are equal the rule reduces to the plain clamp, +so every accepted edit still behaves as it did. + +The row prints only what the flow accepted, which is the divergence Decision 32 +records against ink rather than a new one: ink's buffer is the source of truth and +keeps showing the character its own flow threw away, so the same keystroke leaves a +visible trace there. Matching that would mean moving the display onto the buffer, +which is ink's defect to fix; the caret rule above was chosen so that a refused +keystroke leaves no trace of its own in either renderer's column layout. One test +had pinned the drift as the expected behaviour, since the caret it asserted was the +drift; it pins the absence of one now, and the difference from the shape the review +asked for is stated in that thread. + +Two tests and three mutations. The cell that dropped its character when the field +lost focus fails the new row test in the authentication suite; the resync that +calls the plain clamp where the shared-prefix cap belongs, and that cap reduced to +a length clamp, fail the context-window field's edit test and the new unit test of +the rule itself. Each mutation fails exactly the tests that own the behaviour it +takes away. The machine leg is unchanged here — a caret in a field the wizard +renders is still not reachable by any scenario, so the row's rendering rests on the +unit suites. + +## Decision 36 — the arrow names the call the queue can answer + +The arrow that marks an awaiting call had been derived from the transcript: the +first card that is pending and unfinished. That is ink's rule read from the wrong +list. ink's marker names the call whose confirmation is on screen, and only the +waiting queue knows which call that is — the same rule that decides which call +Ctrl+C settles and which call the shell dialog names. An `ask` decision from a +PreToolUse hook re-arms a call the user has already approved by appending it +_behind_ another waiting call while its card returns to pending where it stands, so +transcript order and queue order disagree, and the arrow sat on a card the user had +no dialog for. + +The shell now passes the queue's first call id down, and the row still has to be a +pending, unfinished tool card to draw the marker. Keeping that second condition is +what preserves Decision 25's one-arrow rule: a card the queue names but the +transcript no longer shows pending gets no arrow, and neither does any other card. +The prop is optional on the view, so a caller that has no queue to consult gets no +marker rather than a guessed one. + +The new case asserts the arrow sits on the call the queue names when that is not +the first pending row, and that exactly one arrow is drawn — it fails, one test in +a suite of seventeen, if the id is dropped and the transcript's order decides +again. Six +existing renders in that suite gained the id, because a marker that must come from +the queue cannot be asserted by a render that supplies none. On a machine the +ordering this needs — a re-armed call behind a waiting one — was not reproduced; +the scenario that drove the arrow in the matrix has a single awaiting call, where +both rules agree. + +## Decision 37 — a field stops taking keys once its read has submitted it + +Making a mid-read submit succeed (Decision 33) made the next keystroke of that read +meaningful, and it is not: the Enter leaves the wizard on the following step while the +read still holds keys, and those keys are handled by the step the burst just left. That +step keeps its value in the flow's own state, and the review step's submit rebuilds the +install plan from that state, so a single trailing letter was written to `settings.json` +— the key step showed `sk-test`, submitted `sk-test`, and saved `sk-testZ`. + +A field now settles when its Enter moves the wizard, and takes no further key of that +read. The latch sits on the shared line model, which every leaking field already goes +through, and each field's handler checks it before its branches: the endpoint and key +steps via the shared helper, the model-ID step (whose Enter also stops a Space from +ticking the recommended list behind it), the advanced-config step's context-window +field, and the question dialog's free-text row. That last one was the same defect seen +from the other end: a multi-select's answer is re-assembled from the field when the +review tab submits, so a letter trailing the Enter widened an answer the user had +already given. + +The latch arms only on an Enter that reports the step moved. An Enter refused by +validation keeps the step mounted and the read belongs to it, so `abc`, Enter, `def` in +one read still leaves `abcdef` in the endpoint field. It is a per-render flag rather than +a per-mount one: the renderer installs a fresh handler per commit and a read cannot span +a commit, so the flag spans exactly one read, and a field whose submit was rejected — or +whose auth failed a step later — is editable again on the next render. + +What stays un-latched is deliberate. The question dialog's tab arrows keep working +inside the pause Decision 34 protects, because there the keystroke is the user's answer +to "which question am I on"; only the field stops taking keys, not the dialog. + +ink cannot reach this leak. Its buffer notifies the parent from an effect run at commit +(`text-buffer.ts`), so a keystroke handled after the Enter never gets a commit where its +step is still mounted, and its text never reaches the state the plan is built from. Ours +writes through the setter synchronously, which is what makes a mid-read submit read the +live text at all, so it needs the explicit stop. + +Coverage is five new unit tests, four in the authentication suite and one in the +confirmation dialog's, and five mutations (M15–M19). Each fails exactly the test that +owns the behaviour taken away: the endpoint and key field's guard, the guard armed on a +refused Enter as well, the model-ID step's guard, the advanced-config step's guard, and +the settled clause of the free-text row's burst-ownership guard. M2's anchor moved with +the line it rewrote. Nothing here was reached on a machine, for the reason recorded in +Decision 33: the acceptance harness writes one character per write and never pastes into +these dialogs. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -1186,6 +1416,26 @@ What was verified, and how far the verification reaches: confirmation and then rotates the mode. The wiring is pinned by a test with its negative control — the intermediate mode that must release nothing — and the selection rule it consumes was already covered on its own. +- **Every burst case is unit evidence only.** The acceptance harness writes one + character per pty write on purpose, so a keystroke lands in a stdin read of its + own, and it never pastes into a dialog input. Neither leg of the matrix can + therefore produce the multi-key reads Decisions 33, 34 and 37 are about; those + rest on the two dialog suites they added tests to, and on the mutation record, + with no screen evidence behind any of them. +- **ink's own auth-wizard suite fails locally, and this change is not shown to be + why.** That file guards nineteen tests behind a check its own comment explains — + simulated TUI input is unreliable on slow runners — and skips them on CI and on + win32, so CI never runs them. Outside CI, thirteen of that family fail here, each + at the suite's own five-second wait, all thirteen among the guarded ones. Six runs + at this head gave five copies of that same set and one run twelve of its names, so + the count moves with the machine while no failure lands outside the guarded + family. A one-variable control says this round's edit to the shared provider-setup + hook is not what does it: the same thirteen names failed, in the same list, with + that file back at the version it has on `origin/main` — which is also the version + this branch's commits leave it at, read off an empty diff against that ref. What + was not observed is a clean upstream checkout in this environment: a separate + worktree stops at the workspace build prerequisite before any test runs, so "these + fail there too" is inferred from the arms above rather than measured. - **The thought toggle's keystroke is not covered at all.** The entry's own test replaces the keyboard hook with a no-op, so only the consumer half is asserted: the flag reaching the row and opening it. Nor is it frame-covered, @@ -1335,6 +1585,39 @@ What was verified, and how far the verification reaches: scope for a sweep that measures itself against ink's behaviour as it stands; it is recorded here so the divergence between the two renderers is not re-reported as a porting gap. +- Two reads of the answer still come from rendered state. A single stdin read that + types into a multi-select's free-text row, steps off it and submits can leave that + entry out of the answer, because the branch deciding whether the text counts + consults what the last render drew rather than a mirror; and one that answers a + question, steps to the review tab and submits can leave that answer out, for the + same reason on the picked set. Decision 33 mirrors the four pieces of state that + handler reads to place a keystroke; these are the fifth and sixth, recorded rather + than fixed because no scenario on either leg can put three keystrokes into one + read, so either change would ship unobservable. +- The stale-cursor class is open everywhere except the two handlers Decision 33 + touched. Read from the source: the shared select hook, the arena dialog's model + list, the composer's completion rows, and the auth wizard's four list steps plus + its main and sub menus all answer from the row the last render drew. The mirror + shape is already set by the question flow and the line editor, so this is one + change over those widgets and their tests, not a per-dialog one — and each site + needs its own burst case, since a mutation that fails the question dialog proves + nothing about a list it does not touch. +- Decision 37's latch stops a field, not a step. The wizard's list steps and its + review row still run one branch per key. The review row applied the plan twice + for a burst of two Enters — two writer calls, read off a scratch test at this + head — and the protocol and endpoint rows have the same shape read from the + source: each of their selects advances a step, so a second Enter in the same + read skips the step between. ink's steps are the same shape, so closing this is + not a divergence to land ahead of ink's, and it belongs with the widget pass + above, which the same one-action-per-read latch would cover. +- The latch stops keys, not a paste. Every field it settles also subscribes to the + renderer's paste event, and that subscription never consults the latch: a read + carrying the Enter that moves the wizard and then a bracketed paste would let + the pasted text write into the step the burst left. The other order — a paste + and the Enter that submits it — is what a terminal actually sends and is pinned + by a test; this one needs a second input channel to land inside the same read, + and no leg of the harness pastes into a dialog. It joins the widget pass above, + where the same guard belongs on every field. - A settled card keeps the position it was created at, where ink commits it to permanent history after whatever notices arrived meanwhile, so a notice printed during a tool call lands after the card here and before it there. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 6a11382f464..1ab8349a381 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -776,6 +776,171 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 的是最后一个。把同样的 JSX 形状放进真实渲染器改五次状态,行始终只有一行,所以这是脚手架 而不是对话框的问题。 +## 决定 33 —— 一次读取里的按键,由它开始时的那次渲染处理 + +第一轮评审发现:决定 24 为行输入辅助函数关掉的那一类缺陷,还在每一个「读对话框 state +再写回去」的地方等着。渲染器的按键 hook 把处理函数存在一个引用里,并在一次提交的 +layout 阶段刷新它;而一次 stdin 读取会把它带回来的按键一个接一个直接派发 —— 按住方向 +键的自动重复约 30 毫秒一次,或者一次括起式粘贴连带终端附在末尾的那个 Enter。它们之间 +没有任何提交,所以第一个之后的每个按键都由上一次提交装好的闭包处理,它读到的每个值都 +慢了一次按键。人手速度看不见这个 bug,因为每次按键都会先冲掉一次渲染;一个逐字符花一 +次 act 的套件同样看不见 —— 已有的对话框测试就是这么漏掉它的。 + +现在,那两个处理函数用来安放下一个按键的每一块 state 都有一份镜像,与它的 setter 在 +同一次调用里写入,而处理函数读的是镜像:提问对话框的标签、它的选项光标、每题各自的 +复选集合与手输内容;向导的推荐模型集合;行编辑器的文本与光标。提问对话框的处理函数 +整个视图都从镜像导出 —— 这是第几题、单选还是多选、有没有自由文本行 —— 而不是从 +「这次渲染画了什么」导出,于是没有任何分支能拿一个按键的位置去配另一个按键的值。 +关于答案本身还有两处读取是例外,记录在下文。行编辑器交出的是一层读镜像的 +getter 而不是快照,因为一个只捕获一次文本的消费者会把这个 bug 原样带回来,只需一行。 + +有一处读取比镜像更窄。自由文本行只在突发仍然站在「该行被画出来时所属的那个标签」上时, +才收下按键:这一行的缓冲区是在渲染期间重新植入的,所以一次读取里在切标签之后落下的字母, +会追加到那次渲染所画的那一题上,并被存成这串按键到达的那一题的答案。这些按键被丢掉, +和本轮之前一样;一个让方向键跨过标签边界的用例把它钉住。 + +提交是同一个 bug 反过来指。向导的 endpoint 与 key 两步调用它们的提交函数时不带参数, +所以一个与前面那些字符共享一次读取的 Enter 读的是父组件的 state —— 还是那些字符之前 +的值 —— 向导就在一个只输入了一半的 endpoint 上前进,无声无息地退回到协议的默认 host。 +它们现在把实时文本作为参数传入,这正是 ink 的文本输入拿缓冲区自己的文本调用 submit 的 +方式,也是 ink 的提问对话框声明其提交签名的方式。推荐模型的复选框带着同一个发现而来: +一次读取里按住 Space 的两次勾选,或一次勾选连带提交它的那个 Enter,是一个复选框的两 +种状态,以及一次对两者都一无所知的提交 —— 直到镜像被读出来。 + +这个 hook 与 ink 的向导共用,而 ink 的 endpoint 那一步本来就把缓冲区的文本传给它, +所以在同样的场景下 ink 那条腿现在提交的也是它屏幕上显示的值。离了那次携带多个按键 +的读取,两个字符串完全相同;这是本轮唯一一处改到 ink 行为的地方。 + +两面镜像被读取的地方,没有任何按键序列能让它们显出分歧,这一条写在这里而不算作覆盖。 +勾选一个推荐模型会把共享的 model-id 串与该字段的实时文本同步,而提交自由文本行取的是 +它所站着的那个标签;但一次突发不可能既编辑字段又勾选列表行 —— 这两个分支由焦点决定, +而焦点是这一批按键自身改变不了的 —— 并且一次手动切标签会把光标放到第一行,而第一行 +只有在「没有任何预置选项的问题」上才是自由文本行,这恰恰被工具自己的校验拒绝。保留它 +们,是为了让处理函数对每一个事实只有一个来源。还有两个同胞是记录而不修,两处仍从 +state 取:一是多选里手输内容算不算数,所以一次读取里先在自由文本行输入、再移出该行、 +最后提交,就会把该内容留在答案之外;二是复核标签页提交时所取的那批已选答案,所以一次 +读取里先答一题、再切到复核标签页、最后提交,就会把那个答案留在之外。二者都要三个键落在 +同一次读取里才显出来,而两条腿上的任何场景都做不到;补上是各加一面镜像,已并入后续工作。 + +本轮停在被点名的那几个处理函数上,而这一类缺陷比它们宽。本渲染器里其余的列表都是用同 +一种方式读自己的光标 —— 另有七个对话框文件挂载的共享选择 hook、竞技场对话框的模型列 +表、输入框的补全行、向导的协议与 endpoint 两步、推荐模型列表的焦点(它的复选集合已在 +上文配了镜像)、它的高级配置行,以及本对话框的主、子菜单。在那里,接键作用在上一轮渲 +染画出的那一行,而不是这串按键到达的那一行;而凡是按画出的光标算下一步的地方 —— 共 +享 hook、endpoint 选项列表,以及主、子这两个菜单 —— 按住方向键是一次读取走一行,而不 +是一个键走一行。收口属于这些控件整体,而不属于本轮修的两个对话框,已记在「后续工作」 +里。 + +覆盖是十四条新单元测试,认证套件五条、确认对话框套件九条,外加十一个变异,其中九个会让 +真正拥有被拿走行为的那些测试失败:行编辑器退回一次渲染快照、Enter 提交 prop 的值、 +模型那一步的复选集合被读两次(勾选与提交各一次)并在提交处再读一次该字段的实时文本、 +提问处理函数按已渲染的 state 工作、Space 与 Enter 两个分支各自的选项读取、以及一次换了 +标签的突发去编辑它抛在身后的那一题。存活的那两个变异就是上面那些不可达的读取。没有任何 +证据走到的是实机:验收 harness 故意每次写只打一个字符,于是一次按键落在自己那一次读取里, +而它从不向这些对话框粘贴 —— 这里每一个突发用例都只由单元测试套件兜着。 + +## 决定 34 —— 手动切标签会叫停已经排定的那次换题 + +提问对话框答完一题,暂停 150 毫秒让刚答完那一行的勾选可见,然后换到下一题。ink 排定 +这次换题却从不取消它:暂停之内的第二次回答,或用户自己按的左右方向键,都不清那个定时 +器,于是两次换题都会跑,中间那一题根本没被画出来。本移植继承了这个行为,而评审轮在 +方向键这条路上抓到了它 —— 用户已经翻到某一题,对话框在零点一秒之后又把它带走了。 + +两条路径现在都会先取消排定的换题:方向键那一条,是因为这次按键就是用户对「我在答哪一 +题」给出的答案;第二次回答那一条,是因为它取代了第一次排定的换题。这是本移植有意与 +ink 不一致的几处之一;同样的 150 毫秒暂停、同样的在最后一个标签处钳制、同样的先勾选 +后换题都保留了。ink 的行为作为它自己的缺陷记录在案,而不是照搬,而验收矩阵已经在 ink +那条腿上看到的双触发,就是同一个缺陷从外面看的样子。 + +两条路径都在确认对话框的套件里被钉住,各自对应一个自己的变异:一次落在暂停之中的右 +方向键会保住它翻到的那一题,把那次取消拿掉就只有这一条测试失败;一次暂停之内的两次 +回答只会留下第二次排定的换题,这条测试早于本轮,把回答路径里的取消拿掉就只有它失败。 +实机侧,暂停与方向键已经被矩阵里那些回答多个问题的场景覆盖了;取消本身在那里没有被 +观察到,因为它需要 150 毫秒之内的两次按键。 + +## 决定 35 —— 光标格印出握着焦点的那个字符 + +字段那一行把值分三段画:光标之前、光标所在的那一格、之后。高亮那一格的第一个版本只在 +字段握着焦点时才打印中间那段,于是一个把光标让给兄弟字段的字段连带丢了一个字符 —— +行显示的值比存着的值短一个码点,这正是那一轮评审的 Critical。字符归值所有,不管谁握着 +焦点;只有底色说明光标在哪个字段里 —— ink 也是这样,它的文本输入决定的是要不要画光标 +的高亮,而其下的文字始终要画。 + +同一行的光标现在遵循一条被上一版弄反了的规则。光标索引的是字段的主人认下的那个值, +而不是按键敲出来的那个:当一次按键到达一个主人会拒绝它的字段 —— 往上下文窗口字段里敲 +一个字母,而它只留数字 —— 被接受的值在那个位置上并没有可以承载光标的字符,于是以两段 +文本共同前缀为界的钳制会把光标留在原地。在那之前,每个被拒绝的键都把光标向右推一个码 +点,一连串之后第一个 backspace 就吃掉一个真实字符。两段文本相等时,这条规则退化回普通 +的钳制,所以每一次被接受的编辑行为如旧。 + +这一行只打印流程接受过的内容,而这正是决定 32 记下的那条对 ink 的分歧,不是新的:ink +的缓冲区才是事实来源,会一直显示它的流程自己丢掉的那个字符,所以同一次按键在那里留下 +可见的痕迹。要匹配它就得把显示搬到缓冲区上,那是 ink 该修的缺陷;上面那条光标规则之所 +以这么定,是为了让一次被拒绝的按键在两个渲染器的列布局里都不留下自己的痕迹。曾有一条 +测试把这个漂移钉成了期望行为,因为它断言的那个光标就是漂移本身;现在它钉的是没有漂移, +而与评审要求的那个形状之间的差别已经写在那条 thread 里。 + +两条测试、三个变异。字段失焦时丢掉字符的那一格,让认证套件里新增的那条行测试失败;在 +应当放共同前缀钳制的地方改用普通钳制的重新同步,以及把那条钳制退化成按长度钳制,分别 +让上下文窗口字段的编辑测试、和这条规则本身的新单元测试失败。每个变异都恰好让拥有被 +拿走行为的那些测试失败。实机腿在这里没有变化 —— 向导渲染的字段里的光标仍然没有任何 +场景能够到达,所以这一行的渲染只由单元测试套件兜着。 + +## 决定 36 —— 箭头指向队列答得了的那个调用 + +标记待审批调用的箭头原本是从转录里推出来的:第一张 pending 且未完成的卡片。这是拿 +ink 的规则去读了错的列表。ink 的标记指向的是「确认框就在屏幕上」的那个调用,而只有等待 +队列知道那是哪一个 —— 和决定哪个调用由 Ctrl+C 结算、哪个调用被 shell 对话框点名的,是 +同一条规则。PreToolUse 钩子给出的一个 `ask` 决定,会重新武装一个用户已经批准过的调用: +把它 _追加到_ 另一个正在等待的调用之后,而它的卡片在原位回到 pending —— 于是转录顺序 +与队列顺序不一致,而箭头就停在了用户根本没有对话框可答的那张卡片上。 + +现在 shell 把队列的第一个调用 id 传下来,而那一行仍然必须是一张 pending、未完成的工具 +卡片才画得出标记。保住第二个条件,正是保住决定 25 那条「只有一个箭头」的规则:队列点名 +但转录不再显示为 pending 的卡片没有箭头,别的卡片也都没有。这个 prop 在视图上是可选的, +所以没有队列可查的调用方得到的是「没有标记」,而不是猜一个。 + +新用例断言:当队列点名的调用不是第一行 pending 时,箭头停在那个调用上,而且画出的箭头 +恰好一个 —— 把 id 丢掉、让转录的顺序重新做主,它就失败,一个十七条测试的套件里的一条。 +该套件里已有的六处渲染补上了这个 id,因为一个必须来自队列的标记,没法由一个不提供队列 +的渲染去断言。实机侧,这里需要的顺序 —— 被重新武装的调用排在等待中的那个之后 —— 没有 +复现;矩阵里驱动过箭头的那个场景只有一个待审批调用,此时两条规则一致。 + +## 决定 37 —— 字段在自己那次读取提交之后就停止接键 + +让「一次读取中间的提交」能够成功(决定 33),也就让那次读取的下一个按键有了意义,而它 +其实没有意义:Enter 已经把向导留在下一步之后,读取里却还有按键没派发完,而这些按键仍归 +刚被离开的那一步处理。那一步把值存在 flow 自己的 state 里,而 review 那一步的提交是从 +这份 state 重新组装安装计划的,于是一个多余的字母会被写进 `settings.json` —— 密钥那一步 +显示的是 `sk-test`,提交的也是 `sk-test`,存下来的却是 `sk-testZ`。 + +现在字段在自己的 Enter 移动了向导之后就结算,那次读取余下的按键一概不接。闩放在共享的 +行编辑模型上,每个会漏的字段本来都经过它;各字段的处理函数在分支之前先查它:端点与密钥 +两步经由共享 helper、模型 id 那一步(它的 Enter 同时也阻止空格去勾选身后的推荐列表)、 +高级配置步的上下文窗口字段,以及提问对话框的自由文本行。最后这一处是同一个缺陷的另一端: +多选的答案是在 review 标签提交时从字段重新拼出来的,所以跟在 Enter 后面的字母会把用户早已 +给出的那个答案加宽。 + +闩只在接受步「真的动了」的 Enter 上武装。被校验拒绝的 Enter 会把这一步留在屏幕上,这次读取 +仍然属于它,所以同一次读取里的 `abc`、Enter、`def` 依旧会在端点字段里留下 `abcdef`。它是 +每次渲染一个旗标,而不是每次挂载一个:渲染器每次提交安装一个新的处理函数,而一次读取不可能 +跨越一次提交,于是这个旗标恰好覆盖一次读取;一个提交被拒绝的字段 —— 或者认证在晚一步才失败的 +字段 —— 到下一次渲染就又能编辑了。 + +有意不闩的部分也写下来。提问对话框的左右切题键在决定 34 保护的那段暂停里照常工作,因为那里 +的按键就是用户自己对「我在答哪一题」的回答;停下来的是字段,不是对话框。 + +ink 到不了这个漏。它的缓冲区是在提交时运行的 effect 里通知父级的(`text-buffer.ts`),所以 +Enter 之后处理的按键永远等不到一次「自己那一步还挂载着」的提交,它的文本也就永远到不了组装 +计划所依据的那份 state。我们这边是同步调用 setter 写回的 —— 这正是「读取中间的提交能读到 +实时文本」的原因 —— 所以需要这个显式的停止。 + +覆盖是五条新单元测试,认证套件四条、确认对话框套件一条,外加五个变异(M15–M19)。每个变异 +恰好让真正拥有被拿走行为的那条测试失败:端点与密钥字段的守卫、把闩连被拒绝的 Enter 也武装上、 +模型 id 那一步的守卫、高级配置那一步的守卫、以及自由文本行突发归属守卫里的 settled 那一项。 +M2 的锚点随着它改写的那一行一起挪了。这里没有任何证据走到实机,原因记在决定 33:验收 harness +故意每次写只打一个字符,而且从不向这些对话框粘贴。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -849,6 +1014,21 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 - **审批释放只有单元测试覆盖。** 没有任何场景会先停住一个确认框、再轮换模式。这条 接线由一个带反证对照的测试钉住 —— 那个必须什么都不释放的中间模式 —— 而它消费的 选取规则本来就已被单独覆盖。 +- **每一个突发用例都只有单元证据。** 验收 harness 故意每次写只打一个字符,所以一次 + 按键落在自己那一次 stdin 读取里,而它从不向对话框输入粘贴。因此矩阵的两条腿都产生 + 不了决定 33、34、37 所针对的多键读取;这些结论只靠它们新增用例的那两个对话框套件, + 以及变异记录,背后没有任何屏幕证据。 +- **ink 自己的认证向导套件在本地失败,而本轮改动没有被证明是原因。** 那个文件把十九条 + 测试藏在一个由它自己注释解释的检查之后 —— 模拟 TUI 输入在慢速 runner 上不可靠 —— + 并在 CI 与 win32 上跳过它们,所以 CI 从不运行它们。在 CI 之外,这一家里有十三条在这 + 里失败,每一条都卡在该套件自己的五秒等待上,十三条全都在被守卫的那一家里。本 head + 上跑了六次,五次得到同一组名单,一次得到其中的十二条,所以随机器漂移的是条数,而不是 + 会落到被守卫那一家之外的失败。一个只变一个变量的对照说明,本轮对共享 provider-setup + hook 的改动不是原因:把那个文件放回它在 `origin/main` 上的版本 —— 本分支的提交也正好 + 没有动过它,这是对着那个 ref 的空 diff 直接读出来的 —— 失败的还是同样这十三条,列表 + 一字不差。没有观察到的是这个环境里一次干净的上游签出:独立的 worktree 在任何测试跑起 + 来之前就会停在 workspace 构建前置条件上,所以「它们在那里也失败」是从上面两个臂推出来 + 的,不是量出来的。 - **思考开关的那次按键完全没有覆盖。** 入口自己的测试把键盘 hook 换成了空操作,所以 被断言的只有消费的那一半:标志到达那一行并把它展开。它也没有帧覆盖,因为没有任何 场景会让模型产出一段思考 —— 新标签与这条绑定都不出现在任何一帧抓取里。 @@ -952,6 +1132,30 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 画出来,而复核标签页把它报告为未回答。这是在真机上观察到的,不是推出来的。 决定 21 不跟随它,而在以 ink 现状行为度量自己的清扫里去修 ink 超出了范围; 记在这里,免得两个渲染器间的这处分歧被再次当成移植缺口上报。 +- 关于答案的两处读取仍然来自已渲染的 state。一次 stdin 读取里先在多选的自由文本 + 行输入、再移出该行、最后提交,就可能把那条内容留在答案之外,因为决定它算不算的那一 + 支看的是上一次渲染画了什么,而不是某面镜像;同样的一次读取里先答一题、再切到复核标签 + 页、最后提交,也可能把那个答案留在之外,原因落在已选集合上是一样的。决定 33 给那个 + 处理函数用来安放按键的四块 state 都配了镜像,这是第五块和第六块;它们被记录而不修, + 因为两条腿上的任何场景都没法把三个键放进同一次读取,改了也无法被观察到。 +- 读到过期光标这一类缺陷,只在决定 33 动过的那两个处理函数里被关掉。其余读源码即可确认: + 共享选择 hook、竞技场对话框的模型列表、输入框的补全行,以及认证向导的那四个列表步骤 + 加它的主、子菜单,全都是按上一次渲染画出的那一行作答。镜像的形状已经由提问流程和行 + 编辑器立好,所以这是一次覆盖这些控件本身的改动,而不是逐个对话框各修一遍 —— 并且每 + 一处都要有自己的突发用例:一个让提问对话框失败的变异,对一个它不碰的列表什么都没说 + 明。 +- 决定 37 的闩停住的是字段,不是步骤。向导的那几个列表步骤和最后的 review 行仍然是 + 一个按键跑一个分支。review 行在一次两个 Enter 的突发里会把计划应用两次 —— 两次 + 写入调用,本轮一条临时测试直接读到的 —— 而协议行与端点选项行是源码读出的同一形状: + 它们各自的 select 都会前进一步,于是同一次读取里的第二个 Enter 就跳掉中间那一步。 + ink 的步骤也是这个形状,所以这不是一条要走在 ink 前面的分歧,它属于上面那轮控件级 + 的改动,同一个「一次读取一个动作」的闩一并覆盖它。 +- 闩停住的是按键,不是粘贴。每个被它结算的字段同时订阅了渲染器的粘贴事件,而那份订阅 + 从不查这个闩:一次读取里先有让向导前进的 Enter、随后再到一个括起式粘贴,粘贴的文字 + 就会写进这串按键已经离开的那一步。反过来的顺序 —— 先粘贴、再有提交它的 Enter —— + 才是终端真正会发的那一种,已由一条测试钉住;这一种需要第二个输入通道落在同一次读取 + 里,而 harness 的任何一条腿都不会向对话框粘贴。它并入上面那轮控件级的改动,每个字段 + 都该带上同一道守卫。 - 一张已结算的卡片会停在它被创建时的位置,而 ink 会把它提交进永久历史、排在期间到 达的那些提示之后,所以一条在工具调用期间打印的提示,在这里落在卡片之后,在那里 落在卡片之前。这一条曾经还记着卡片缺少的行内参数行,以及一个画在每张卡片上、而 From 2e94387d39f2cfc300086ccdb6eba4091b7ba0b8 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 15:08:43 +0800 Subject: [PATCH 26/51] fix(cli): read the loading-phrase setting for the waiting row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ink's Composer drops the phrase, not the row, when ui.accessibility.enableLoadingPhrases is off; this renderer ignored the setting. The parked-row tests now read the mechanism too — that no timer is left running, and that no cancel affordance is offered for a request that is not in flight — rather than only the glyph the cell happens to show. Co-authored-by: Qwen-Coder --- .../src/ui/opentui/opentui-app-shell.test.tsx | 20 +++++++++++++++++++ .../cli/src/ui/opentui/opentui-app-shell.tsx | 6 ++++++ .../src/ui/opentui/opentui-footer.test.tsx | 17 ++++++++++++++++ .../cli/src/ui/opentui/opentui-footer.tsx | 9 ++++++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index db3b67db080..9e22fc9c9ea 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -279,6 +279,7 @@ vi.mock('../hooks/useAutoAcceptIndicator.js', () => ({ const CONFIG = { getContextFilePaths: () => [], + getAccessibility: () => ({}), } as unknown as Config; const SETTINGS = { merged: {} } as unknown as LoadedSettings; const getSessionStats = () => ({}) as unknown as SessionStatsState; @@ -658,6 +659,7 @@ describe('OpenTuiApp shell wiring', () => { const onTranscriptEvent = vi.fn(); renderApp({ config: { + ...CONFIG, getContextFilePaths: () => ['/repo/AGENTS.md'], } as unknown as Config, onTranscriptEvent, @@ -1695,6 +1697,7 @@ describe('OpenTuiApp approval-mode cycling (F-2)', () => { let mode = initial; const writes: ApprovalMode[] = []; const config = { + ...CONFIG, getApprovalMode: () => mode, setApprovalMode(next: ApprovalMode) { if (options.refuse) throw new Error('approval mode is pinned'); @@ -1929,6 +1932,23 @@ describe('OpenTuiApp approval-mode cycling (F-2)', () => { }); describe('OpenTuiApp transcript scroll region', () => { + beforeEach(() => { + mocks.state.handleResult = { kind: 'handled' }; + mocks.state.handleResults.length = 0; + mocks.state.handledTexts.length = 0; + mocks.state.host = null; + mocks.state.hosts.length = 0; + mocks.state.dispatcherConstructions = 0; + mocks.state.inputProps = null; + mocks.state.dialogProps = null; + mocks.state.footerProps = null; + mocks.state.exitInProgress = false; + mocks.state.emitAutoModeEntryNotices.mockClear(); + mocks.state.mcpApprovalProps = null; + mocks.state.mcpQueue.length = 0; + mocks.state.handleMcpApprovalSelect.mockClear(); + }); + const layoutOf = ( node: Element | null | undefined, ): Record => diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index 8d2dfb9e4c0..bdad4e0a08e 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -918,6 +918,10 @@ export function OpenTuiApp(props: OpenTuiAppProps) { [host, host.getVersion()], ); + // ink's Composer drops the phrase (not the row) when this setting is off. + const showLoadingPhrases = + config.getAccessibility()?.enableLoadingPhrases !== false; + return ( @@ -1034,6 +1039,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { streaming={Boolean(streaming)} streamingCharsRef={streamingCharsRef} isReceivingContent={isReceivingContent} + showPhrase={showLoadingPhrases} /> { const { container } = render( , ); + // The frame stays put because no tick exists to move it, not because the + // next tick happens to draw the same glyph. + expect(vi.getTimerCount()).toBe(0); expect(container.textContent).toContain('Waiting for user confirmation...'); + // ink drops the cancel suffix here: there is no in-flight request to cancel. + expect(container.textContent).not.toContain('esc to cancel'); act(() => { vi.advanceTimersByTime(SPINNER_INTERVAL_MS * 20); }); expect(spinnerCell(container)).toBe(WAITING_SPINNER_FRAME); }); + it('keeps the waiting row but loses its phrase when loading phrases are off', () => { + // ui.accessibility.enableLoadingPhrases: ink's Composer passes no phrase, + // and the row stays so the waiting-row sequence is unchanged. + const { container } = render( + , + ); + expect(container.textContent).not.toContain( + 'Waiting for user confirmation', + ); + expect(spinnerCell(container)).toBe(WAITING_SPINNER_FRAME); + }); + it('ticks the elapsed counter once per second', () => { const { container } = render(); act(() => { diff --git a/packages/cli/src/ui/opentui/opentui-footer.tsx b/packages/cli/src/ui/opentui/opentui-footer.tsx index 9e2b85deb6c..cb27445971f 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.tsx @@ -86,6 +86,12 @@ export interface OpenTuiLoadingIndicatorProps { streamingCharsRef?: RefObject; /** False while waiting on the API (↑), true once content arrives (↓). */ isReceivingContent?: boolean; + /** + * False when `ui.accessibility.enableLoadingPhrases` is off. ink's `Composer` + * passes no phrase in that case and the row still renders, so the row is not + * what the setting removes — only its text. + */ + showPhrase?: boolean; } /** Spinner + witty phrase + elapsed time + token estimate, above the composer. */ @@ -94,6 +100,7 @@ export function OpenTuiLoadingIndicator({ waiting = false, streamingCharsRef, isReceivingContent = false, + showPhrase = true, }: OpenTuiLoadingIndicatorProps) { const { width } = useTerminalDimensions(); // The shared cycler resolves the phrase list for all nine locales and owns @@ -134,7 +141,7 @@ export function OpenTuiLoadingIndicator({ 0, width - 5 - (isNarrow ? 0 : getCachedStringWidth(suffix)), ); - const phraseText = truncateToWidth(phrase, phraseBudget); + const phraseText = showPhrase ? truncateToWidth(phrase, phraseBudget) : ''; return ( From eea432e3c34a14836d59e26b469bc7b422185667 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 15:08:58 +0800 Subject: [PATCH 27/51] fix(cli): sanitize a queued prompt before the waiting row shows it A row listed the prompt raw while the same string goes through sanitizeTerminalText once the queue is consumed, so an escape sequence typed into the composer reached the terminal from a row that only displays text. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/opentui/queued-message.test.tsx | 8 ++++++++ packages/cli/src/ui/opentui/queued-message.tsx | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/opentui/queued-message.test.tsx b/packages/cli/src/ui/opentui/queued-message.test.tsx index d5e9dbca975..5d2d1366f45 100644 --- a/packages/cli/src/ui/opentui/queued-message.test.tsx +++ b/packages/cli/src/ui/opentui/queued-message.test.tsx @@ -114,4 +114,12 @@ describe('OpenTuiQueuedMessageDisplay', () => { mocks.state.dimensions = { width: 100, height: 40 }; } }); + + it('strips terminal escapes from a queued prompt before it reaches the screen', () => { + // UserRow prints this same string through sanitizeTerminalText once the + // queue is consumed; the waiting row must not write escapes raw first. + const { container } = render(queueRows(['\u001b[31mred\u001b[0m'])); + const row = container.querySelector('div > span'); + expect(row?.textContent ?? '').not.toContain('\u001b'); + }); }); diff --git a/packages/cli/src/ui/opentui/queued-message.tsx b/packages/cli/src/ui/opentui/queued-message.tsx index 1437ee09fe8..2a18046e7af 100644 --- a/packages/cli/src/ui/opentui/queued-message.tsx +++ b/packages/cli/src/ui/opentui/queued-message.tsx @@ -15,7 +15,7 @@ import { useRef } from 'react'; import { useTerminalDimensions } from '@opentui/react'; import { t } from '../../i18n/index.js'; -import { truncateToWidth } from '../utils/textUtils.js'; +import { sanitizeTerminalText, truncateToWidth } from '../utils/textUtils.js'; import { C } from './theme.js'; const MAX_DISPLAYED_QUEUED_MESSAGES = 3; @@ -58,7 +58,10 @@ export const OpenTuiQueuedMessageDisplay = ({ .map((message, index) => ( - {truncateToWidth(message.replace(/\s+/g, ' '), rowBudget)} + {truncateToWidth( + sanitizeTerminalText(message.replace(/\s+/g, ' ')), + rowBudget, + )} ))} From 7430c4ffc495fb0ba43aa30c8ffc707d78c556ca Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 15:09:16 +0800 Subject: [PATCH 28/51] refactor(cli): drop the @-mention card's unreachable arguments row handleAtCommand builds every display it returns as a closed literal with no arguments, so formatToolArgs never produced a string here and the row was never drawn. The test that covered it passed on a fake that supplied an argument object production cannot give, which is why the suite stayed green over dead code. Co-authored-by: Qwen-Coder --- .../cli/src/ui/opentui/live-session.test.ts | 31 ------------------- packages/cli/src/ui/opentui/live-session.ts | 3 -- 2 files changed, 34 deletions(-) diff --git a/packages/cli/src/ui/opentui/live-session.test.ts b/packages/cli/src/ui/opentui/live-session.test.ts index df1e608b714..46deabfb804 100644 --- a/packages/cli/src/ui/opentui/live-session.test.ts +++ b/packages/cli/src/ui/opentui/live-session.test.ts @@ -990,37 +990,6 @@ describe('livePromptEvents', () => { ); }); - it("carries an @-mention card's arguments for ink's args row", async () => { - const sendMessageStream = oneToolBatchStream({ - callId: 't1', - name: 'test_tool', - args: {}, - }); - const config = createFakeConfig(sendMessageStream); - atMocks.result = { - processedQuery: [{ text: 'look @src/a.ts' }], - shouldProceed: true, - toolDisplays: [readDisplay({ args: { file_path: 'src/a.ts' } })], - }; - - const events = (await drain( - livePromptEvents(config, 'start', undefined, { - drainSteering: () => ['look @src/a.ts'], - }), - )) as OpenTuiStreamEvent[]; - - const argsRows = events.filter( - (e) => e.type === 'tool-args' && e.id === 'client-read-1', - ); - expect(argsRows).toEqual([ - { - type: 'tool-args', - id: 'client-read-1', - args: '{"file_path":"src/a.ts"}', - }, - ]); - }); - it('joins drained steering texts with a blank line between messages', async () => { const sendMessageStream = oneToolBatchStream({ callId: 't1', diff --git a/packages/cli/src/ui/opentui/live-session.ts b/packages/cli/src/ui/opentui/live-session.ts index 17d82c407a3..77cebffe528 100644 --- a/packages/cli/src/ui/opentui/live-session.ts +++ b/packages/cli/src/ui/opentui/live-session.ts @@ -55,7 +55,6 @@ import type { Part, PartListUnion } from '@google/genai'; import { createEventMapper, extractStructuredResult, - formatToolArgs, renderResultDisplay, toolResultEvent, type OpenTuiStreamEvent, @@ -254,8 +253,6 @@ function atMentionCardEvents( title: display.description, }, ]; - const args = formatToolArgs(display.args); - if (args) events.push({ type: 'tool-args', id: display.callId, args }); const result = toolResultEvent(display.callId, display.resultDisplay); if (result) events.push(result); const failed = display.status === ToolCallStatus.Error; From a0fb2dedcdb2eec62d3ffe6bf3e414d748a52c73 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 15:09:26 +0800 Subject: [PATCH 29/51] test(cli): stop handing React a null key in the dialog harnesses The stub renderer forwarded whatever key the element carried, and an absent one arrived as null, which React reports as a reserved prop on every element the two dialog suites build. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/opentui/dialogs-auth.test.tsx | 2 +- packages/cli/src/ui/opentui/dialogs-confirm.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 28d611950cb..34cff15f69b 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -60,7 +60,7 @@ const mocks = vi.hoisted(() => { return React.createElement( type === 'box' ? 'div' : 'span', { - key: key ?? null, + ...(key === undefined ? null : { key }), ...(bg === undefined ? null : { 'data-bg': bg }), }, children, diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 5383a014865..7658e86a952 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -48,7 +48,7 @@ const mocks = vi.hoisted(() => { return React.createElement( type === 'box' ? 'div' : 'span', { - key: key ?? null, + ...(key === undefined ? null : { key }), ...(bg === undefined ? null : { 'data-bg': bg }), }, children, From f1ec59cdeb9baa6dafa17a7cac0f40e890ee7653 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 15:09:42 +0800 Subject: [PATCH 30/51] docs(cli): correct what two source comments claim The line editor said the global exit binding takes ctrl+D before a field sees it; the binding acts on the key without swallowing it, and the reducer hands it back unhandled. The expanded-window comment had the two failure directions of its reserve swapped, and cited a line number in another file. Co-authored-by: Qwen-Coder --- .../cli/src/ui/opentui/dialogs-confirm.tsx | 6 ++++-- packages/cli/src/ui/opentui/line-edit.ts | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 704a46086e8..c83294028cf 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -101,8 +101,10 @@ const MAX_BODY_ROWS = 20; * that keeps its place above it. The expanded tail window is budgeted as * terminal height minus this reserve, so the end of the content — where the * options still are — stays on screen (ink reaches the same visible outcome - * through terminal scrollback). Deliberately generous: erring low hides a few - * payload rows, erring high pushes the options off screen. + * through terminal scrollback). Deliberately generous: erring low makes the + * window taller than the chrome allows and pushes the options off screen, + * erring high only hides a few payload rows, which the expanded window's own + * hidden-rows label reports. */ const EXPANDED_BODY_RESERVE_ROWS = 20; diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index e4b41b854bc..19aa46d3f18 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -23,15 +23,16 @@ * Not ported, and recorded as follow-ups: word jumps (ctrl/alt+←/→, alt+b/f), * delete-word-right (alt+d, ctrl/alt+Delete), kill-line (ctrl+k/ctrl+u) and * undo/redo (ctrl+z) — all of which ink's TextInput does bind. ctrl+D is - * deliberately absent: the app's global EXIT binding takes that key before any - * text field sees it. Three rendering differences remain as well: ink windows - * the field at `inputWidth` columns, and its one-line viewport shows only the - * line the caret is on, so a pasted multi-line value hides everything off that - * line, while these rows render the whole value; ink blinks the cursor cell - * every 530 ms, where a steady cell keeps the dialog from repainting on a - * timer; and the cell carries the theme accent, as the composer's cursor does, - * where ink paints a gray read from the terminal background (and falls back to - * an underline where a block would corrupt IME composition). + * deliberately absent: the app's global EXIT binding acts on it, and this + * reducer hands it back unhandled, so no field ever edits on that key. Three + * rendering differences remain as well: ink windows the field at `inputWidth` + * columns, and its one-line viewport shows only the line the caret is on, so a + * pasted multi-line value hides everything off that line, while these rows + * render the whole value; ink blinks the cursor cell every 530 ms, where a + * steady cell keeps the dialog from repainting on a timer; and the cell carries + * the theme accent, as the composer's cursor does, where ink paints a gray read + * from the terminal background (and falls back to an underline where a block + * would corrupt IME composition). */ import { useRef, useState } from 'react'; From b702297100651a9596afbd0403349a60e4bf9d04 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 15:09:56 +0800 Subject: [PATCH 31/51] docs: record the pointer divergence and the pins review measured as loose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 21 now names the two deliberate diverances separately — a defect ink has that this port does not, and the pointer affordance this port lost when the option rows were drawn by hand. Decision 21's residual list drops vertical anchoring, which the retraction at the top of that section already disowns, and Decision 22 stops treating a reconstruction artefact as a frame finding. Decision 28 says which half of its mutation pair pinned the timer, Decision 30 scopes its queue rows to the steering lane and marks the mount pin as containment rather than order, and Decision 32 corrects the ctrl+D claim. Four follow-ups record the unported key families, the refused-keystroke divergence, the exit handler's missing claim, and seven pins that survive a mutation. Co-authored-by: Qwen-Coder --- .../2026-09-10-opentui-parity-defect-sweep.md | 100 +++++++++++++++--- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 84 +++++++++++---- 2 files changed, 148 insertions(+), 36 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 840eec94f70..84548d8f23c 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -565,8 +565,9 @@ one gap per chip, two columns of prefix per header, and two more for each answered mark. It is recomputed on every render, because answering changes the marks. -One divergence is deliberate. ink answers a typed free-text entry twice for one -keystroke — the dialog's own key handler and the input widget it mounts both +Two divergences are deliberate, and they are of different kinds. The first is a +defect ink has and this port does not. ink answers a typed free-text entry twice +for one keystroke — the dialog's own key handler and the input widget it mounts both subscribe, and the key layer broadcasts to every subscriber with no notion of focus — and each answer advances a tab, so the question after the one being answered is skipped without ever being drawn. This was not reasoned from source @@ -578,6 +579,20 @@ whole sweep is measured against, so the divergence is recorded instead. The scenario was rearranged to exercise the free-text row on the last question, where the tab index clamps and both legs arrive at the review tab together. +The second divergence is one this port loses. The option rows are drawn by hand +rather than by the shared select widget, and the widget's pointer wiring did not +cross with them: clicking an option no longer answers it and hovering no longer +highlights it, while the outcome list a few rows below — still that widget — +responds to both. Keys answer every question ink's dialog answers, which is the +bar this sweep set, and the pointer is deferred to its own change rather than +argued away. A click needs a meaning on the chips, on the free-text row and on +the submit and cancel rows, each of which answers a different question; and the +hazard class these rows just went through is a pointer hazard too, since a click +landing in the same read as a keystroke is one burst from another source. No leg +measures it: the harness writes one character per stdin write and emits no mouse +sequence, and the run that reported this could not start this renderer in its own +environment. + The free-text row keeps its value in a mirror written synchronously beside the state update. Key events can arrive inside one React batch, and a handler that builds the next value from the one its render captured appends to a snapshot @@ -607,11 +622,13 @@ indent for indent, including the mark appearing on a chip as soon as its free-text box is checked and before anything is committed, the comma-and-space join of a multi-select answer with the typed entry counted into it, and the review tab's own hint, which drops the cancel clause exactly where ink drops -it. Two residuals stand: where a long path wraps, and vertical anchoring. A third -recorded here originally — which frame the waiting row shows — is closed by -Decision 28, and a fourth, the startup update notice, was falsified: that notice -comes from the runtime rather than this renderer, and the harness suppresses it on -both legs. +it. One residual stands: where a long path wraps. A second listed here +originally — vertical anchoring — was retracted above: the two screens occupy the +same place in the terminal, and what differs is where the harness rebuilds ink's +block. A third recorded here originally — which frame the waiting row shows — is +closed by Decision 28, and a fourth, the startup update notice, was falsified: +that notice comes from the runtime rather than this renderer, and the harness +suppresses it on both legs. ## Decision 22 — the confirmation is drawn where the conversation is @@ -657,7 +674,10 @@ token and cancel suffixes — there is no in-flight request to cancel and no tokens to count. That waiting row renders for a tool confirmation only: the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle, where ink's own indicator -renders nothing, and the captured frames agree on both halves. +renders nothing, and the captured frames agree on both halves. Where the block +sits vertically is not something these frames can settle: the difference recorded +above belongs to the reconstruction, not to either screen, so it is neither +confirmed nor ruled out here. ## Decision 23 — a card's rendering preference is re-derived per event @@ -862,8 +882,11 @@ The gate is ported, and the frame it freezes on is shared rather than copied: it joins the spinner constants both renderers already read from one place, which ink had spelled out as a literal until now. Two tests cover the pair — the frame advances while a turn is in flight, and holds that one frame while a call is -parked. Each is killed by its own mutation and by nothing else: removing the gate -fails the parked test, removing the interval fails the in-flight one. +parked. The frame each leg shows is pinned: taking the parked branch out fails the +parked test, taking the interval out fails the in-flight one. What the first of +those did not pin was the timer itself — a row that went on ticking but kept +printing the same frame passed — so the parked test now asserts that no interval +is left running, which is the cost this decision measured on a machine. Re-running the full matrix afterwards confirms it on the machine rather than in a test: eleven checkpoints taken while a dialog is parked each shed one row on both @@ -948,6 +971,14 @@ that read that list have no production caller, so the keystroke falls through as text. The rows are reachable here only for the length of a turn, which is also why no scenario in the matrix produces a durable queue on either leg. +One scope was set rather than discovered: these rows list the steering queue, and +the shell's own deferred lane is not in it. A submission made while a held command +still runs waits in a second list the shell keeps for itself, and neither the rows +nor the badge — which read the one mirrored queue — says it is waiting, so that +text appears on screen only once it runs. ink has a single queue and shows both. +Listing them together is one change with the key that would make a queue durable: +badge and rows have to move together, or the two counts disagree on the screen. + Coverage is unit-level across three layers: five component tests (nothing when the queue is empty, one row per prompt with whitespace flattened, the three-row cap with its overflow row, the hint that shows on three fills and not the @@ -956,8 +987,10 @@ assertions on the queued texts at each of those sites, and a shell test that fixes the placement — rows in the persistent chrome between the loading indicator and the composer, not in the scroll region. Eleven mutations — one at each component behaviour, one at each mirror site and one at the mount — each -fail at least one test. The five component rows and the mount fail their own -and no other; the mirror sites do not map one to one, because a list never +fail at least one test, with one limitation: the mount is caught as containment +and not as order, because the shell test asserts the rows land in the persistent +chrome rather than which side of the composer. The five component rows fail their +own and no other; the mirror sites do not map one to one, because a list never given its first entry, or never emptied, is still being asserted turns later: draining without mirroring fails one assertion, pushing without it fails four, popping for editing two. @@ -1020,9 +1053,11 @@ field can receive, and every other key goes back to the dialog — which is what ink needs, since its dialog stops handling the arrows once the field owns the row. Word jumps, delete-word-right, kill-line and undo/redo are recorded as not ported, all four of them bound by ink. Two absences are forced: ctrl+D belongs -to the app's global exit binding, which takes the key before any field sees it, -and a field whose value another keystroke replaced wholesale has its caret pulled -back inside the string rather than left pointing past it. +to the app's global exit binding, which acts on the key without swallowing it — +the focused field sees it too and hands it back unhandled, so there is no edit +for that key to port — and a field whose value another keystroke replaced +wholesale has its caret pulled back inside the string rather than left pointing +past it. Three rendering differences stand. ink windows a field to a fixed column count and shows only the line the caret is on, so a pasted multi-line value hides @@ -1357,7 +1392,7 @@ What was verified, and how far the verification reaches: sweep was compared that way. The margin above is therefore the first spacing claim in this document backed by frames, and it is backed by a separate measurement of the transcript region alone — total blank-row counts are not - usable while the two renderers anchor differently. + usable while the reconstruction puts ink's block at rows of its own choosing. - **The blank rows around ink's banner are deliberately not reproduced.** Two facts were read directly. ink's captured stream begins with a carriage return and a newline immediately before the banner's first row. And ink appends @@ -1632,3 +1667,36 @@ What was verified, and how far the verification reaches: row moving, on top of the three each side already loses to the wrap break at the same checkpoint. Decision 22 fixed what the inline confirmation draws; this is where it is mounted, which is a separate change. +- A dialog field ports the one-line slice of ink's buffer, not all of it. Four + key families ink's text input binds are not ported: word jumps + (ctrl/alt+←/→, alt+b/f), delete-word-right (alt+d, ctrl/alt+Delete), kill-line + (ctrl+k/ctrl+u) and undo/redo (ctrl+z). Every operation they need already exists + in this model, so it is one change over the line editor and its tests rather + than a per-dialog one. +- What a refused keystroke leaves on screen diverges, as Decision 32 says. The + context-window field keeps digits only and this renderer prints the accepted + value, so a letter — or the Space that step binds — vanishes without a trace, + while ink's buffer owns its display and goes on showing what its own flow threw + away. The quieter of the two was chosen on purpose; matching ink means moving the + row onto the buffer, which is ink's defect to fix. +- The two app-wide handlers disagree about claiming their key. The thought-toggle + handler marks the keystroke consumed; the exit handler for ctrl+C and ctrl+D + does not, so those keys also reach whatever else is mounted in the same read. + At this head nothing else acts on them — the shared line reducer hands ctrl+D + back unhandled — but that is a fact about the bindings that exist today, not a + guarantee the exit handler keeps. +- Seven further pins were measured by review as not holding, and this pass did not + re-run them. Deleting the shell's mount of the waiting row leaves every unit test + green, because the indicator's own suite renders it directly; moving the queue + rows below the composer passes the test titled for that placement, which asserts + containment rather than order; the pending-card probe retuned from 46 rows to 48 + now equals the reserve sum, so the case no longer reaches the floor it is + titled for; an args-row placement assertion compares against a string its + fixture never renders; the arguments-row wiring is observed only by a case that + mocks both of its consumers to render nothing; the paste handler's guard + against a row that is not the free-text one is reached only by a case that + presses a digit first; and two branches carry no test at all — the model + dialog's custom-ID caret, and the line-join branch of delete-word-left. One + shape runs through them: the assertion reads what was rendered, so a change + that renders the same thing survives. That is one pass over those suites in the + other direction, not seven separate fixes. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 1ab8349a381..dbfefa3badb 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -418,13 +418,23 @@ ink 会画的东西现在这里都画了,而几何是推导出来的,不是 单元格:内边距、当前 tab 标记、submit chip、每个 chip 之间一道间隙、每个标题两列前 缀,以及每个已答标记再多两列。它在每次渲染时重算,因为作答会改变那些标记。 -有一处分叉是有意的。ink 对一次按键把手打的自由文本答案提交两遍 —— 对话框自己的按键处 -理器与它挂载的输入控件都订阅了,而按键层向每个订阅者广播、没有焦点栈来仲裁 —— 每次提 -交都推进一个 tab,于是紧随其后那个问题在从未被画出来的情况下被跳过。这不是仅凭源码推断 -出来的:验收运行的 ink 腿在等一个始终没有出现的问题时超时,而它自己的复核 tab 随后把那 -个问题列为未作答。这里一次按键推进一个 tab。跟随 ink 会复现一个缺陷而不是复现那种体 -验,而修 ink 会移动整轮清扫用来度量的参照物,所以这处分叉被记录下来。场景被重排为在最 -后一个问题上行使自由文本行,那里 tab 下标会被钳住,两腿一起到达复核 tab。 +有两处分叉是有意的,而且性质不同。第一处是 ink 自己有、这个移植没有的缺陷。ink 对一次 +按键把手打的自由文本答案提交两遍 —— 对话框自己的按键处理器与它挂载的输入控件都订阅 +了,而按键层向每个订阅者广播、没有焦点栈来仲裁 —— 每次提交都推进一个 tab,于是紧随 +其后那个问题在从未被画出来的情况下被跳过。这不是仅凭源码推断出来的:验收运行的 ink +腿在等一个始终没有出现的问题时超时,而它自己的复核 tab 随后把那个问题列为未作答。 +这里一次按键推进一个 tab。跟随 ink 会复现一个缺陷而不是复现那种体验,而修 ink 会移 +动整轮清扫用来度量的参照物,所以这处分叉被记录下来。场景被重排为在最后一个问题上使 +行自由文本行,那里 tab 下标会被钳住,两腿一起到达复核 tab。 + +第二处分叉是这个移植失去的东西。选项行是手动画出来的,而不是用共用的 select 控件,那个 +控件的指针接线没有跟着过来:点击一个选项不再作答,悬停也不再高亮,而几行之下的结果列表 +—— 仍然是那个控件 —— 两样都还响应。按键能回答 ink 这个对话框能回答的每一个问题,这正 +是本清扫设定的标准;指针因此被留给它自己的改动,而不是被论证掉。点击需要在 chip、自由文 +本行以及 submit 与 cancel 行上各有各的含义,而它们回答的是不同的问题;并且这些行刚经历过 +的那一类隐患同样是指针隐患 —— 一次落在同一次读取里的点击,只是换了来源的一串连击。这条 +也没有任何一腿在度量:工装每次 stdin 写入一个字符,从不发出鼠标序列;而报告这一条的那次 +运行在它自己的环境里起不动本渲染器。 自由文本行把它的值保存在一个与状态更新并排、同步写入的镜像里。按键事件可能在一个 React 批次内到达,而一个用其渲染所捕获的值去构造下一个值的处理器,是在往一个已经过期 @@ -446,9 +456,10 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 比较了八个检查点。每一行对话框内容都与 ink 逐行逐缩进一致,包括标记在某个 chip 的自由 文本框被勾上时就出现、早于任何东西被提交,包括多选答案那个「逗号加空格」的连接并把手 打条目计入其中,也包括复核 tab 自己的那条提示 —— 它在 ink 丢掉取消子句的地方同样丢 -掉。剩下两处分叉:长路径在哪里折行,以及垂直锚定。这里原先记录的第三条 —— 等待行停在 -哪一帧 —— 已由决定 28 关闭;第四条,启动时的更新提示,已被证伪:那条提示来自运行时而 -非本渲染器,而且工装在两条腿上都把它压掉了。 +掉。剩下一处分叉:长路径在哪里折行。这里原先记录的第二条 —— 垂直锚定 —— 已在开头撤回: +两块屏幕在终端里占据同一个位置,真正不同的是工装把 ink 的那块内容重建在哪里。原先记录 +的第三条 —— 等待行停在哪一帧 —— 已由决定 28 关闭;第四条,启动时的更新提示,已被证 +伪:那条提示来自运行时而非本渲染器,而且工装在两条腿上都把它压掉了。 ## 决定 22 —— 确认框画在会话所在的地方 @@ -480,9 +491,8 @@ React 批次内到达,而一个用其渲染所捕获的值去构造下一个 那句等待中的措辞,并去掉它的耗时、token 与取消后缀 —— 既然没有在飞的请求,就没什么可 取消、也没什么 token 可数。这条等待行只为工具确认渲染:服务器启动审批、目录信任门禁与 动作确认全都到达在会话空闲之时,而 ink 自己的指示器在这种情形下什么都不画,抓取的帧在 -两半上都与此一致。 - -仍然不同的是这一块在纵向上的位置,那是下面记录的锚定分叉,按决策排除在外。 +两半上都与此一致。这一块在纵向上的位置不是这些帧能判定的:上面记录的那处分叉属于重建 +方式,而不是任何一块屏幕,因此在这里既没有证实也没有排除。 ## 决定 23 —— 卡片的渲染偏好按事件重新导出 @@ -625,8 +635,10 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 门禁被移植过来了,而它停下时画的那一帧是共享的、不是抄的:它进了两个渲染器本来就从 同一处读取的转圈常量,而 ink 此前把它写成了字面量。两条测试覆盖这一对 —— 回合在飞行 -中时帧会推进,调用被挂住时帧停在那一格。各自被自己的变异杀掉、且只杀掉自己:移除门禁 -只让「挂住」那条失败,移除 interval 只让「飞行中」那条失败。 +中时帧会推进,调用被挂住时帧停在那一格。它们各自钉住的是显示的那一帧:取出挂住分支只 +让「挂住」那条失败,取出 interval 只让「飞行中」那条失败。前者当初没有钉住的是计时器本 +身 —— 一条继续走时、却每次都打印同一帧的行是能通过的 —— 所以「挂住」那条现在还会断言 +没有 interval 留在运行,那正是本决定在真机上量到的代价。 之后重跑全量矩阵,在真机上而不是在测试里确认了它:十一个在对话框挂住时取到的检查点, 每一处两侧各减掉一行,从四行分叉变成三行,那里剩下的行是决定 21 记录过的长路径折行断 @@ -687,12 +699,21 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 出,但读那张表的解析器没有任何生产调用方,于是这次按键落成普通文本。这些行在这里只在 一个回合的长度内可达,这也正是矩阵里没有任何场景在任一腿上产生持久队列的原因。 +有一处范围是被定下来的,不是被发现出来的:这些行列的是引导队列,shell 自己的延迟通道 +不在其中。一个在被持住的命令仍在运行时提交的提示,会落在 shell 为自己保留的第二个列表 +里,而无论是这些行还是徽标 —— 两者读的都是那份被镜像的队列 —— 都不会说它在等待,于是 +那段文本只在它真正开始运行时才出现在屏幕上。ink 只有一个队列,会把两者都列出来。把它们 +一起列出,与那个本可让队列持久化的键属于同一次改动:徽标与行必须一起动,否则屏幕上的两 +个计数会互相矛盾。 + 覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三 行的上限与它的溢出行、前三次填满时出现而第四次不再出现的提示、按终端宽度减掉 ink 缩进截断的一行),hook 在上述每一处对被排队文本的断言,以及一条钉住位置的 shell 测试 —— 这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动 区域中。十一个变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让 -一条测试失败。五条组件行与挂载处只让属于自己的那条失败;镜像点做不到一一对应,因为 +一条测试失败,其中有一处局限:挂载是被作为「包含」而不是作为「顺序」抓住的,因为那条 +shell 测试断言的是这些行落在常驻的 chrome 里,而不是它在 composer 的哪一边。五条组件 +行只让属于自己的那条失败;镜像点做不到一一对应,因为 一个从未拿到第一条、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条 断言失败,推入时不镜像让四条失败,弹出编辑让两条失败。 @@ -734,8 +755,9 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 函数改成了导出,于是 ctrl+W 擦掉的那一段就是 ink 擦掉的那一段,而 ink 自己的行为没有变 动。按键顺序也是 ink 的,只取一个一行字段收得到的那些键,其余每个键都交还给对话框 —— 这正是 ink 需要的,因为它的对话框一旦让字段拥有这一行就不再处理方向键。词跳转、向右删 -词、整行删除与撤销重做被记录为未移植,这四样 ink 都绑了键。两处缺席是被迫的:ctrl+D 属 -于应用的全局退出绑定,任何字段都轮不到它;而一个值被另一次按键整体换掉的字段,光标会被 +词、整行删除与撤销重做被记录为未移植,这四样 ink 都绑了键。两处缺席是被迫的:ctrl+D 归 +应用的全局退出绑定处理,但它并不吞掉这个键 —— 聚焦的字段同样会看到它,并且原样交回, +所以这个键上没有什么编辑行为可移植;而一个值被另一次按键整体换掉的字段,光标会被 拉回串内,而不是继续指着串外。 三处渲染差异保留下来。ink 把字段按固定列数开窗,并且只画光标所在的那一行,所以一次多行 @@ -972,8 +994,8 @@ M2 的锚点随着它改写的那一行一起挪了。这里没有任何证据 - **纵向间距直到最后一次运行之前都在帧证据之外。** 比对会把每一帧归约为一串非 空行,这让两个条目之间的间距对它不可见,而本文档此前每一次抓取都是这样比对 的。所以上面那条边距是本文档里第一个有帧支撑的间距结论,并且它靠的是一份对 - transcript 区域的单独度量 —— 在两个渲染器锚定方式不同的前提下,空行总数不 - 可用。 + transcript 区域的单独度量 —— 只要重建会把 ink 的那块内容放在它自己选定的行上, + 空行总数就不可用。 - **ink banner 前后的空行刻意不复现。** 有两件事是直接读到的。ink 的抓取流在 banner 首行之前以一个回车加换行开头。并且 ink 会给它写出的每一批永久输出追加 恰好一个换行 —— 它自带的注释说明这个换行是为了让下一帧不覆盖该批的最后一行。 @@ -1165,3 +1187,25 @@ M2 的锚点随着它改写的那一行一起挪了。这里没有任何证据 下,所以画面读起来是卡片、确认框、卡片;这里会话按自己的顺序保留两张卡片,确认框 跟在它们后面。差的就是这一行的挪动,同一检查点上两侧本来就已经因为折行断点各失去 三行。决定 22 改的是行内确认框画什么;这一条是它挂在哪里,需要另一次改动。 +- 对话框字段移植的是 ink 缓冲区里那一行宽的切片,不是全部。ink 文本输入绑定的四族键 + 没有移植:词跳转(ctrl/alt+←/→、alt+b/f)、向右删词(alt+d、ctrl/alt+Delete)、 + 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。它们需要的操作在这个模型里都已经存在, + 所以这是一次落在行编辑器与其测试上的改动,而不是逐个对话框的改动。 +- 一次被拒绝的按键在屏幕上留下什么,两侧是有分歧的,决定 32 已说过。context-window + 字段只保留数字,而本渲染器打印的是被接受的那个值,于是一个字母 —— 或那一步绑定的 + Space —— 会不留痕迹地消失;ink 的缓冲区才是它显示内容的源头,会一直显示自己流程已经 + 丢掉的那个字符。这里刻意选了更安静的一种:要对齐 ink 就得把这行搬到缓冲区上,那是 + ink 该修的缺陷。 +- 两个应用级处理函数在「是否认领自己那个键」上不一致。展开思考的处理函数会把这次按键 + 标记为已消费,ctrl+C 与 ctrl+D 的退出处理函数不会,于是这两个键也会到达同一次读取里 + 挂载着的其他处理函数。在此 head 上没有别处对它们有动作 —— 共用的行归约器把 ctrl+D + 原样交回 —— 但这是今天这些绑定的事实,不是退出处理函数给出的保证。 +- 另有七处钉定被评审以变异量出并不成立,本轮没有重跑它们。删掉 shell 上等待行的挂载, + 所有单元测试仍然全绿,因为指示器自己的套件是直接渲染它的;把排队行移到 composer 下 + 面,那条以这一位置为题的测试仍然通过,因为它断言的是包含而非顺序;待处理卡片的探测 + 值从 46 行调到 48 之后正好等于预留值之和,于是该用例不再触及它标题所指的那个下限; + 参数行位置的断言对比的是它的 fixture 从不渲染的字符串;参数行的接线只被一条把它的两 + 个消费者都 mock 成什么都不画的用例观察到;粘贴处理里「非自由文本行」的守卫,唯一的 + 调用点会先按一个数字;还有两个分支根本没有测试 —— 模型对话框自定义 ID 字段的光标, + 以及向左删词的跨行合并分支。同一形状贯穿这七处:断言读的是渲染出来的结果,所以渲染 + 结果不变的改动能活下来。修它们是朝另一个方向过一遍这些套件,而不是七处各自修一次。 From 80c5614ca602a636f4552e151d88ebc7437d43eb Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 16:38:02 +0800 Subject: [PATCH 32/51] fix(cli): keep a click on the transcript out of the composer's focus The transcript scroll region was focusable by class default, and the renderer focuses the first focusable ancestor under a left mouse-down, so one click anywhere in the conversation blurred the composer's editor for the rest of the session. Printable keys kept arriving through the composer's own global handler, which hid it: caret keys and pastes went where nothing was listening, because a renderable attaches those handlers only while focused. --- .../2026-09-10-opentui-parity-defect-sweep.md | 78 ++++++++++++++++++- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 66 +++++++++++++++- .../src/ui/opentui/opentui-app-shell.test.tsx | 10 +++ .../cli/src/ui/opentui/opentui-app-shell.tsx | 17 +++- 4 files changed, 165 insertions(+), 6 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 84548d8f23c..dd4ccef7622 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1344,6 +1344,74 @@ the line it rewrote. Nothing here was reached on a machine, for the reason recor Decision 33: the acceptance harness writes one character per write and never pastes into these dialogs. +## Decision 38 — the transcript region is not a focus target + +One left click anywhere in the conversation used to end the composer's ability +to edit what it already held. The region that holds the transcript is a +`scrollbox`, and this renderer's `ScrollBoxRenderable` sets `_focusable = true` +over its base class's `false` (`@opentui/core`: `index.node.js:13859` against +`chunk-node-kr27pp2p.js:210`). The renderer walks the ancestors of every left +mouse-down and focuses the first focusable one, unless the click was handled +first and prevented the default (`chunk-node-kr27pp2p.js:9091-9104`). So the +click moved focus onto the scroll region, and `focusRenderable` blurred the +editor it was on (`:7562-7569`). Blur is not cosmetic: a renderable's key and +paste handlers exist only while it is focused, and `blur()` detaches both +(`:325-381`). + +Nothing put them back. The composer passes `focused` as a constant, so the prop +the reconciler compares is unchanged and the write is skipped for the rest of +the session. Typing still looked fine, which is what nearly got this dismissed: +printable characters reach the composer through its own global key handler +rather than the editor's, so the buffer kept growing while the keys only the +focused editor handles — every caret key, and every paste — went where nothing +was listening. `Home` with a character after it, and a bracketed paste, are what +show the difference. ink parses SGR mouse reports as well, but into handlers +subscribed per surface and hit-tested against that surface's own rows — the +wheel over a scrollable list, a click inside the prompt input to place its +caret — and none of them has any route to the composer's focus, which is a +React prop. A +click on the conversation changes nothing there, measured rather than inferred. + +The region is now `focusable={false}`. Two wider options were rejected. Turning +`autoFocus` off is a renderer-wide setting, and the dialogs' own fields rely on +it. Re-focusing the composer when the region steals focus means the transcript +owns a rule about who should be listening, and the composer's focus is already +declared by its own prop — two owners for one bit. One prop on the node that has +no reason to be in the chain is the narrowest statement of the same fact: a +scroll region is not an input target. + +Measured on a real terminal, four arms, one variable — the same bundle and boot +arguments throughout, and `ink` under node against `opentui` under bun would +have confounded the runtime with the renderer, so `ink` under bun is its own +arm. Each arm types four characters, then — except the control — clicks a +transcript row, then measures whether three left-arrows plus a letter move the +caret, whether `Home` plus a letter reaches the front, and whether a bracketed +paste lands in the buffer. + +| arm | caret moves | `Home` works | paste lands | +| ------------------------------------- | ----------- | ------------ | ----------- | +| `ink`, node, clicked | yes | yes | yes | +| `ink`, bun, clicked | yes | yes | yes | +| `opentui`, bun, clicked — before | **no** | **no** | **no** | +| `opentui`, bun, not clicked — control | yes | yes | yes | +| `opentui`, bun, clicked — after | yes | yes | yes | + +The same click on the composer's own row restored the paste on every arm, +including the broken one, which is what ties the loss to the editor's focus +rather than to the keys. + +What was checked to survive the fix: the mouse wheel still scrolls the region — +the dedicated wheel scenario, re-run on this renderer, sticks to the last row, +shows the rows above the fold after twenty wheel-ups and the last row again +after wheeling back. That scenario never clicks first, so the wheel +_after_ a click rests on the routing rather than on a frame: a scroll event is +routed by hit test, with focus only as the fallback (`:9134-9143`). And one unit +test pins the prop, killed by a mutation that removes it. What was not: a +click-drag text selection over the transcript, which the region answers through +the same hit-test path and which this renderer never advertised, and a click +while a dialog is open — the dialog lists are still focusable by default, +recorded under Follow-ups. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -1523,11 +1591,19 @@ What was verified, and how far the verification reaches: ## Follow-ups -- The scroll region answers the mouse wheel and no key. What scrolls ink's +- The scroll region answers the mouse wheel and no key — and stays a non-target for + keys only because Decision 38 leaves it out of the focus chain, since a focused + scroll region does take the keys nothing else was listening for. What scrolls ink's conversation is the terminal's own scrollback, and the alternate screen this renderer takes over has none, so the page keys that work there reach nothing here and a row above the fold comes back only by wheel. Binding those keys to the region's own offset is its own change. +- Every scroll region a dialog draws is still focusable, because that is the class + default and only the transcript's was set aside. Whether a click inside a dialog's + list can cost the open dialog a caret key the same way Decision 38 describes was + not measured; the lists are hit-tested surfaces of their own there, so the exposure + may be benign. Worth one arm per dialog before this renderer's mouse handling is + called done. - The `@` completion here asks only the file index. ink also completes sessions, MCP resources and extensions, and draws a category bar to switch between them. That is a feature gap rather than a parity defect and belongs diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index dbfefa3badb..12064eafd2e 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -963,6 +963,59 @@ Enter 之后处理的按键永远等不到一次「自己那一步还挂载着 M2 的锚点随着它改写的那一行一起挪了。这里没有任何证据走到实机,原因记在决定 33:验收 harness 故意每次写只打一个字符,而且从不向这些对话框粘贴。 +## 决定 38 —— 转录区不在焦点链里 + +在对话区随便左键点一下,composer 就再也编辑不了它已经装着的文本。承载转录的那块区域是 +`scrollbox`,而这个渲染器的 `ScrollBoxRenderable` 把基类的 `_focusable = false` 覆盖 +成了 `true`(`@opentui/core`:`index.node.js:13859` 对 +`chunk-node-kr27pp2p.js:210`)。渲染器在每一次左键按下上沿祖先链往上走,聚焦第一个 +focusable 的节点,除非这次点击先被处理掉并 preventDefault +(`chunk-node-kr27pp2p.js:9091-9104`)。于是点击把焦点挪到了滚动区上, +`focusRenderable` 把原本聚焦的编辑器 blur 掉(`:7562-7569`)。blur 不是外观问题:一个 +renderable 的按键和粘贴处理函数只在它聚焦期间存在,`blur()` 会把两者一起摘掉 +(`:325-381`)。 + +没有任何东西会把它们装回去。composer 的 `focused` 一直传同一个常量,reconciler 要比较 +的 prop 没变,于是这次写入在整个会话里都被跳过。打字看起来仍然正常,这正是差点让它被 +驳回的原因:可打印字符走的是 composer 自己的全局按键处理函数,不是编辑器的那份,所以 +缓冲区一直在变长,而只有聚焦编辑器才处理的键 —— 每一个光标键、每一次粘贴 —— 落到了没 +人听的地方。真正显出差别的是 `Home` 之后跟一个字符,以及一次 bracketed paste。ink 也 +解析 SGR 鼠标上报,但只分发按表面订阅、并对该表面自己的行做命中测试的处理函数 —— 滚动 +列表上的滚轮、提示输入内部用来定位光标的点击 —— 而它们没有任何一条路能写到 composer +的焦点上,那个焦点是 React 的 prop。在对话区点一下什么也不会改变,这是量出来的,不是 +推出来的。 + +现在这个区域是 `focusable={false}`。两个更宽的做法被否掉了。把 `autoFocus` 关掉是渲染 +器全局的设定,而对话框自己的字段依赖它。在区域抢走焦点时把 composer 重新聚焦,等于让 +转录区拥有一条「谁该在听」的规则,而 composer 的焦点已经由它自己的 prop 声明过了 —— +一个比特两个主人。给这个本来没道理进焦点链的节点加一个 prop,是同一个事实最窄的说法: +滚动区不是输入目标。 + +在真实终端上测量,四个臂,只变一个变量 —— 全程同一份 bundle 和同一组启动参数;而 node +下的 `ink` 对着 bun 下的 `opentui` 会把运行时和渲染器混在一起,所以 bun 下的 `ink` 单 +列一臂。每个臂先打四个字符,然后 —— 对照臂除外 —— 在转录行上点一下,再量三件事:三个 +左方向键加一个字母有没有把光标挪走、`Home` 加一个字母有没有落到最前面、一次 bracketed +paste 有没有进缓冲区。 + +| 臂 | 光标会动 | `Home` 生效 | 粘贴落位 | +| -------------------------------- | -------- | ----------- | -------- | +| `ink`,node,点击过 | 是 | 是 | 是 | +| `ink`,bun,点击过 | 是 | 是 | 是 | +| `opentui`,bun,点击过 —— 修之前 | **否** | **否** | **否** | +| `opentui`,bun,没点击 —— 对照 | 是 | 是 | 是 | +| `opentui`,bun,点击过 —— 修之后 | 是 | 是 | 是 | + +同样的点击落在 composer 自己那一行时,每一个臂(包括坏掉的那个)粘贴都恢复了,这才是 +把丢失归到编辑器焦点、而不是归到按键本身的依据。 + +修完之后仍然核过的部分:滚轮照样能滚这个区域 —— 专门测滚轮的那个场景在本渲染器上 +重跑,粘在最后一行、上滚二十格后能看见折叠线以上的那些行、再滚回来又回到最后一行。那 +个场景从头到尾没有点击过,所以「点击之后的滚轮」依靠的是路由而不是帧:滚动事件先做命 +中测试,焦点只是兜底(`:9134-9143`)。还有一条单元测试钉住这个 prop,用一个删掉它的变 +异证明它会失败。没核的部分:在转录上按住拖动选择文本,这条走的是同一个命中测试路径, +而这个渲染器从未对外声明过它;以及对话框开着时点击 —— 对话框的列表仍然默认 +focusable,记在后续工作里。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -1087,10 +1140,15 @@ M2 的锚点随着它改写的那一行一起挪了。这里没有任何证据 ## 后续工作 -- 滚动区响应鼠标滚轮,却不响应任何键。滚动 ink 那份会话靠的是终端自己的回滚缓冲 - 区,而本渲染器接管的备用屏幕没有这样东西,所以在那边管用的翻页键在这里什么都触及 - 不到,折叠线以上的一行只能靠滚轮回来。把这些键绑到区域自己的滚动偏移上是独立的改 - 动。 +- 滚动区响应鼠标滚轮,却不响应任何键 —— 而它之所以对键始终不是目标,只是因为决定 38 + 把它移出了焦点链;一个被聚焦的滚动区确实会拿走那些没人听处理的键。滚动 ink 那份会 + 话靠的是终端自己的回滚缓冲区,而本渲染器接管的备用屏幕没有这样东西,所以在那边管用 + 的翻页键在这里什么都触及不到,折叠线以上的一行只能靠滚轮回来。把这些键绑到区域自己 + 的滚动偏移上是独立的改动。 +- 对话框画的每一个滚动区仍然默认 focusable,因为那是类的默认值,被处理掉的只有转录这 + 一个。对话框开着时点进它的列表,会不会像决定 38 描述的那样让对话框丢掉光标键,没有 + 量过;那里的列表本就是各自做命中测试的表面,所以这个暴露可能是良性的。在这个渲染器 + 的鼠标处理被称作完成之前,值得为每个对话框补一臂。 - 这里的 `@` 补全只问文件索引。ink 还会补全会话、MCP 资源与扩展,并画一个分类 栏用于切换。那是功能缺口而不是一致性缺陷,应放进独立的改动。 - 缺少搜索二进制时打印的 shell crawler 诊断现已有解释。渲染器库把全局 console diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index 9e22fc9c9ea..273a01b0bae 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -1993,6 +1993,16 @@ describe('OpenTuiApp transcript scroll region', () => { expect(layoutOf(chrome)).toMatchObject({ flexShrink: 0 }); }); + it('keeps the transcript region out of the focus chain', async () => { + // A ScrollBox is focusable by default and the renderer auto-focuses the + // first focusable ancestor under a left mouse-down, so one click on the + // conversation moved the focus off the composer's editor for the rest of + // the session: the caret stopped moving and pastes stopped landing there. + await renderWithTranscript(); + const { region } = readLayout(); + expect(layoutOf(region)).toMatchObject({ focusable: false }); + }); + it('keeps the dialog out of the scroll region', async () => { await renderWithTranscript(); mocks.state.handleResult = { diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index bdad4e0a08e..de3bb96e4f7 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -931,13 +931,28 @@ export function OpenTuiApp(props: OpenTuiAppProps) { {/* Everything that flows lives here, and it is the alt-screen stand-in for ink's : the column is bounded by the terminal, so the region keeps only the tail of the conversation, pinned to its last - row until the user scrolls away from the bottom. */} + row until the user scrolls away from the bottom. + + `focusable={false}` because a scroll region is not an input target + here. @opentui auto-focuses the first focusable ancestor under a + left mouse-down, and its ScrollBox is focusable by default, so + clicking the conversation — the most natural gesture on a page of + transcript — moved the focus off the composer's editor and nothing + moved it back: `focused` is unchanged so the reconciler skips it. + The editor then kept the printable keys (the composer's own global + handler feeds them) but lost every key only the focused editor + handles: the caret stopped moving, and a paste no longer reached + the buffer. ink parses mouse reports too, but only into handlers a + surface subscribes to and hit-tests against its own rows, and none + of them can write the composer's focus — that is a React prop — so + a click there changes nothing. */} {renderMain ? renderMain() : null} From 2ccebd812cd466daaaa5af6195902439f011c635 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 17:47:42 +0800 Subject: [PATCH 33/51] fix(cli): stop the bare End key at its own line in dialog fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ink has two distinct "go right" keys and this renderer collapsed them into one. Ctrl+E moves through the reducer and lands past the last code point of the whole value, so a multi-line paste ends after its final line. A bare End falls through to ink's text buffer instead, whose `end` move stops at the end of the visual line the caret sits on — so typing after End on the first row of a two-row value inserts there rather than appending at the bottom. Dispatch the two keys to their own moves and differential-test the pair against ink's reducer, including a bare End then a keystroke on a line break. Separately, the queued-prompt rows and their hint counter mount inside the composer's branch, which this renderer hands to a parked tool confirmation, so those rows leave the screen for a state where ink keeps them. That is recorded as a divergence rather than fixed: hoisting them above the branch would place them over the waiting row where ink draws them under the spinner. --- .../2026-09-10-opentui-parity-defect-sweep.md | 60 ++++++++++++++----- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 31 ++++++++-- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 17 +++++- packages/cli/src/ui/opentui/line-edit.test.ts | 31 +++++++++- packages/cli/src/ui/opentui/line-edit.ts | 24 ++++++-- .../cli/src/ui/opentui/queued-message.tsx | 7 ++- 6 files changed, 138 insertions(+), 32 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index dd4ccef7622..2816b9be4b5 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -979,21 +979,33 @@ text appears on screen only once it runs. ink has a single queue and shows both. Listing them together is one change with the key that would make a queue durable: badge and rows have to move together, or the two counts disagree on the screen. +One asymmetry is recorded rather than fixed. The rows mount inside the +composer's branch, and this renderer gives that branch's slot to a parked tool +confirmation — its first awaiting call opens as a modal dialog — so the branch +swaps whole and the rows leave the screen, the hint's counter with them, until +the call settles. ink keeps its composer mounted through the same state: a +parked call is an inline card in its transcript, and its dialog gate lists the +popups the user is answering, never the tool queue. A dialog does unmount the +composer on both sides, so a completed count resets the same way here and there. +Keeping the rows through a confirmation would mean mounting them one level up, +in the chrome box, at the price of setting them above the waiting row where ink +draws them under the spinner. + Coverage is unit-level across three layers: five component tests (nothing when the queue is empty, one row per prompt with whitespace flattened, the three-row cap with its overflow row, the hint that shows on three fills and not the fourth, a row cut to the terminal width minus ink's indent), the hook's assertions on the queued texts at each of those sites, and a shell test that -fixes the placement — rows in the persistent chrome between the loading -indicator and the composer, not in the scroll region. Eleven mutations — one at -each component behaviour, one at each mirror site and one at the mount — each -fail at least one test, with one limitation: the mount is caught as containment -and not as order, because the shell test asserts the rows land in the persistent -chrome rather than which side of the composer. The five component rows fail their -own and no other; the mirror sites do not map one to one, because a list never -given its first entry, or never emptied, is still being asserted turns later: -draining without mirroring fails one assertion, pushing without it fails four, -popping for editing two. +fixes the placement — the rows share the non-scrolling chrome with the composer +instead of scrolling away with the conversation. Eleven mutations — one at each +component behaviour, one at each mirror site and one at the mount — each fail at +least one test, with one limitation: the mount is caught as containment and not +as order, because the shell test asserts the rows land in the chrome beside the +composer rather than which side of it. The five component rows fail their own and +no other; the mirror sites do not map one to one, because a list never given its +first entry, or never emptied, is still being asserted turns later: draining +without mirroring fails one assertion, pushing without it fails four, popping for +editing two. ## Decision 31 — a model dialog outcome is recorded as well as shown @@ -1059,6 +1071,21 @@ for that key to port — and a field whose value another keystroke replaced wholesale has its caret pulled back inside the string rather than left pointing past it. +Where a key's natural reading and ink's binding disagree, ink's binding is kept, +and one review-round finding came from holding that rule to the letter. The port +sent the bare End key and ctrl+E to the same value-end jump. ink separates +them: only ctrl+E is its END binding, whose handler dispatches the line-end move +and then walks to the end of the whole buffer, while a bare End falls past that +binding into the reducer, which stops at the line. So a caret parked on an +earlier line of a pasted value by ← sent the next character to the end of the +string instead of where the user left it. The fold is now split, with the bare +key taking the line-scoped move Home already used; one caveat travels with it, +because ink's line is a _visual_ row and a value wide enough to wrap splits it, +where this model has no wrapping to consult. Coverage is one differential step +beside the ctrl+E one, the wizard's base-URL case pasting a two-line value and +landing a character inside its first line, and a mutation that returns the bare +key to the value-end jump — it fails both. + Three rendering differences stand. ink windows a field to a fixed column count and shows only the line the caret is on, so a pasted multi-line value hides everything off that line, while these rows render the whole value. ink paints its @@ -1089,15 +1116,16 @@ shape before the caret existed at all — ink showing a value its flow has alrea discarded is recorded as a follow-up rather than reproduced here. Coverage is twenty-two new unit tests in three suites — thirteen of them the -model compared against ink, the rest the fields that use it — plus seven +model compared against ink, the rest the fields that use it — plus eight mutations, each failing the tests that own the behaviour taken away: a left arrow that moves nothing, the context-window field's typing branch, the two end-of-line jumps, forward delete, a modified Delete that erases a character -instead of passing through, ctrl+W, and a row that stops re-mounting. On a -machine, a new scenario answers one question from its free-text row on both -legs, with a single question so that neither the tab clamp nor the double-fire -recorded under Decision 21 can be what produced the frame: six characters -typed, the caret taken back two cells, one character inserted. Both legs show +instead of passing through, ctrl+W, a row that stops re-mounting, and a bare End +folded back into the ctrl+E jump. On a machine, a new scenario answers one +question from its free-text row on both legs, with a single question so that +neither the tab clamp nor the double-fire recorded under Decision 21 can be what +produced the frame: six characters typed, the caret taken back two cells, one +character inserted. Both legs show `> abcdXef` at the same column of the same row and both record `abcdXef` as the answer. The styled capture pins the cell itself. At the two checkpoints where ink draws it, the cell sits over the same character in the same column on diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 12064eafd2e..60042ee7e38 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -706,13 +706,23 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 一起列出,与那个本可让队列持久化的键属于同一次改动:徽标与行必须一起动,否则屏幕上的两 个计数会互相矛盾。 +有一处不对称是记录在案、没有修的。这些行挂在 composer 的那个分支里面,而 +本渲染器把这一分支的位置交给了一个被挂起的工具确认框 —— 第一个待批准的调用在 +那里作为模态对话框打开 —— 于是整个分支被换掉,行连同提示的计数器一起离开画 +面,直到那次调用结算。ink 在同样的状态下继续挂着它的 composer:一个被挂起 +的调用在它那里是转录区里的一张内联卡片,而它的对话框闸门列的是用户正在作答的 +那些弹窗,从来不含工具队列。一个对话框确实会在两边都把 composer 卸掉,所以 +一个已经走完的计数在两处的复位方式一样。要让行挺过确认框,就得把它们挂到上面 +一层的 chrome 盒子里,代价是它们会落在等待行之上,而 ink 把它们画在转圈提示 +之下。 + 覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三 行的上限与它的溢出行、前三次填满时出现而第四次不再出现的提示、按终端宽度减掉 ink 缩进截断的一行),hook 在上述每一处对被排队文本的断言,以及一条钉住位置的 shell -测试 —— 这些行在常驻的 chrome 里、位于 loading 指示器与 composer 之间,而不在滚动 -区域中。十一个变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让 +测试 —— 这些行与 composer 一起落在那块不滚动的 chrome 里,而不是随对话滚走。十一个 +变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让 一条测试失败,其中有一处局限:挂载是被作为「包含」而不是作为「顺序」抓住的,因为那条 -shell 测试断言的是这些行落在常驻的 chrome 里,而不是它在 composer 的哪一边。五条组件 +shell 测试断言的是这些行与 composer 一起落在 chrome 里,而不是在它的哪一边。五条组件 行只让属于自己的那条失败;镜像点做不到一一对应,因为 一个从未拿到第一条、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条 断言失败,推入时不镜像让四条失败,弹出编辑让两条失败。 @@ -760,6 +770,16 @@ shell 测试断言的是这些行落在常驻的 chrome 里,而不是它在 co 所以这个键上没有什么编辑行为可移植;而一个值被另一次按键整体换掉的字段,光标会被 拉回串内,而不是继续指着串外。 +键的直觉读法与 ink 的绑定不一致时,留下的是 ink 的绑定,而第一轮评审的一条发现正是把这条 +规则较了真。本移植把裸 End 键和 ctrl+E 送到同一个「跳到值尾」。ink 把两者分开:它的 END +绑定只有 ctrl+E,处理函数先派发一次行尾移动、再走到整个缓冲区的末尾;裸 End 越过那个绑定 +落进 reducer,那里只认行。于是被 ← 停在粘贴值靠前那一行的光标,下一个字符会落到串的末尾, +而不是用户放下它的地方。这一处现在拆开了,裸键走 Home 本来就在用的按行移动;一个附带的 +前提是 ink 的那一行是「可视」行,宽到需要折行的值会把它再切开,而这个模型没有折行可查。 +覆盖是在 ctrl+E 那一步旁边补上裸键的差分一步,加上向导里 base-URL 字段粘贴一个两行值、把 +字符落回第一行之内的用例,以及一个把裸键送回「跳到值尾」的变异 —— 两个套件各有一条测试为 +它失败。 + 三处渲染差异保留下来。ink 把字段按固定列数开窗,并且只画光标所在的那一行,所以一次多行 粘贴会把它自己看不着的那几行全藏起来,而这里的行渲染整个值。ink 把光标格涂成从终端背景 读出来的灰,并且在实心块会破坏输入法拼字时退到下划线,而这一格带的是主题强调色,和 @@ -782,9 +802,10 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 没有照搬。 覆盖是三个测试套件里新增的二十二条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 -用它的字段 —— 外加七个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 +用它的字段 —— 外加八个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 上下文窗口字段的输入分支、两个跳至行尾的绑定、向前删除、带修饰键的 Delete 擦掉一个字符 -而不是把键交还、ctrl+W,以及一行不再重新挂载。实机侧,新增场景在两条腿上都从自由文本行 +而不是把键交还、ctrl+W、一行不再重新挂载,以及把裸 End 折回 ctrl+E 的那一跳。实机侧, +新增场景在两条腿上都从自由文本行 回答一个问题,而且只问一个问题,这样标签钳制和 决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符,光标往回退两格,再插入一个 字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 `abcdXef` 记成了答案。带样式的 diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 34cff15f69b..de2f99dcd85 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -793,18 +793,31 @@ describe('caret editing in dialog text fields (#107)', () => { expect(screen.getByText(/Step 3\/6 · API Key/)).toBeTruthy(); }); - it('sends End past the line break a paste leaves behind', async () => { + it('sends ctrl+E to the end of a pasted value and bare End to its own line', async () => { await runToBaseUrlStep(); await pasteText('https://one.test\nhttps://two.test'); await press('a', { ctrl: true, sequence: '\x01' }); expect(focusedField().cell).toBe('h'); await press('e', { ctrl: true, sequence: '\x05' }); await typeText('s'); - // ink's End is the end of the value, not of the caret's own line + // ink's ctrl+E binding is the end of the value, not of the caret's line expect(focusedField()).toEqual({ text: 'https://one.test\nhttps://two.tests ', cell: ' ', }); + // The bare key is ink's reducer move, which stops at the line the caret is + // on. Park the caret inside the first line — ctrl+A after the ctrl+E jump + // lands on the second line's start — and End takes it to that line's end, + // so the next character lands before the break rather than at the value's. + await press('a', { ctrl: true, sequence: '\x01' }); + await press('left'); + await press('left'); + await press('end'); + await typeText('X'); + expect(focusedField()).toEqual({ + text: 'https://one.testX\nhttps://two.tests', + cell: '\n', + }); }); it('edits the context-window field in place', async () => { diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 8e73c750095..71c070b811d 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -25,6 +25,7 @@ import { moveCaretEnd, moveCaretHome, moveCaretLeft, + moveCaretLineEnd, moveCaretRight, type LineState, } from './line-edit.js'; @@ -112,6 +113,12 @@ const moveEnd: Op = { }), line: moveCaretEnd, }; +// ink's bare End key is not that binding: it falls through to the reducer's +// own line-end move, so it stops at the caret's line. +const bareEnd: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'end' } }), + line: moveCaretLineEnd, +}; const stepLeft: Op = { ink: (state) => step(state, { type: 'move', payload: { dir: 'left' } }), line: moveCaretLeft, @@ -191,11 +198,15 @@ describe('line-edit', () => { ]); }); - it('follows ink in jumping End past a line break but Home only to its own', () => { + it('follows ink in jumping ctrl+E past a line break, Home and bare End only to their own', () => { expect(moveCaretEnd({ text: 'ab\ncd', cursor: 0 })).toEqual({ text: 'ab\ncd', cursor: 5, }); + expect(moveCaretLineEnd({ text: 'ab\ncd', cursor: 0 })).toEqual({ + text: 'ab\ncd', + cursor: 2, + }); expect(moveCaretHome({ text: 'ab\ncd', cursor: 5 })).toEqual({ text: 'ab\ncd', cursor: 3, @@ -209,6 +220,24 @@ describe('line-edit', () => { eraseBack, eraseBack, ]); + // A paste that leaves the caret on an earlier line is where the two + // readings of End part: the bare key takes the caret to its own line end, + // and the next character lands there rather than at the value's. + agreesWithInk('ab\ncd', [ + moveHome, + stepLeft, + stepLeft, + bareEnd, + typed('z'), + eraseBack, + ]); + expect( + applyLineKey({ text: 'ab\ncd', cursor: 0 }, key('end'))?.cursor, + ).toEqual( + inkOffset( + step(inkState('ab\ncd', 0), { type: 'move', payload: { dir: 'end' } }), + ), + ); }); it('agrees with ink over code points ink weighs as one cell or two', () => { diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index 19aa46d3f18..ad7208eb4cd 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -177,10 +177,23 @@ export function moveCaretHome(state: LineState): LineState { } /** - * Past the last code point of the value. ink's END binding does that rather - * than the line-end jump its reducer would make: TextInput moves to the line - * end and then to `cpLen(text)`, so the two differ only after a paste has left - * the caret on an earlier line. + * Past the last code point of the caret's line. ink's bare End key falls + * through to its reducer, whose `end` move is to the end of the line. + */ +export function moveCaretLineEnd(state: LineState): LineState { + const clamped = clampCaret(state); + return { + text: clamped.text, + cursor: lineBounds(clamped.text, clamped.cursor).end, + }; +} + +/** + * Past the last code point of the value. ink reaches this only through its + * ctrl+E binding: `TextInput` dispatches the line-end move and then jumps to + * `cpLen(text)`, so the two differ once a paste leaves the caret on an earlier + * line. The bare End key stops at the line end instead — see + * {@link moveCaretLineEnd}. */ export function moveCaretEnd(state: LineState): LineState { const clamped = clampCaret(state); @@ -224,7 +237,8 @@ export function applyLineKey( if (name === 'right' && !modified) return moveCaretRight(next); if (ctrl && name === 'f') return moveCaretRight(next); if (name === 'home' || (ctrl && name === 'a')) return moveCaretHome(next); - if (name === 'end' || (ctrl && name === 'e')) return moveCaretEnd(next); + if (name === 'end') return moveCaretLineEnd(next); + if (ctrl && name === 'e') return moveCaretEnd(next); if (ctrl && name === 'w') return deleteWordLeftAtCaret(next); if (modified && (name === 'backspace' || sequence === '\x7f')) { return deleteWordLeftAtCaret(next); diff --git a/packages/cli/src/ui/opentui/queued-message.tsx b/packages/cli/src/ui/opentui/queued-message.tsx index 2a18046e7af..bdb44d1d966 100644 --- a/packages/cli/src/ui/opentui/queued-message.tsx +++ b/packages/cli/src/ui/opentui/queued-message.tsx @@ -32,9 +32,10 @@ export const OpenTuiQueuedMessageDisplay = ({ }: OpenTuiQueuedMessageDisplayProps) => { const { width } = useTerminalDimensions(); // Counts how many times the edit hint has been shown, so it stops after a - // few. ink keys this on the empty → non-empty transition, and its counter - // resets whenever the composer unmounts — which happens for a dialog here - // too, since the composer branch is swapped out the same way. + // few. ink keys this on the empty → non-empty transition and resets the count + // whenever its Composer unmounts — which a dialog does in both renderers. A + // parked tool confirmation unmounts it here and not in ink, and that asymmetry + // is recorded as a divergence under Decision 30. const hintSeenCountRef = useRef(0); const wasEmptyRef = useRef(true); From f87161c3c1597cb5d16676aae5f286c2511d34ee Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 18:50:24 +0800 Subject: [PATCH 34/51] fix(cli): fit the question dialog's chip row to the room it has The header cap copied ink's overhead terms along with the width they are subtracted from, but this row is not drawn in ink's box. It spends two columns of margin here, and the padding it spends is already one of those overheads, so the row was billed for eight columns it does not spend and a header was ellipsized where the row still had room. Re-deriving the base from this row's box was then checked against a laid-out frame rather than the props it came from: headers print whole here where ink reaches for the ellipsis at the same width, and that window is recorded in the design doc. --- .../2026-09-10-opentui-parity-defect-sweep.md | 31 +++++++++++---- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 21 ++++++++-- .../src/ui/opentui/dialogs-confirm.test.tsx | 38 +++++++++++++++++++ .../cli/src/ui/opentui/dialogs-confirm.tsx | 11 +++--- 4 files changed, 85 insertions(+), 16 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 2816b9be4b5..e4c431b72ea 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -557,13 +557,24 @@ renderers printed the same settled card word for word. Everything ink draws is now drawn here, and the geometry was derived rather than guessed. The chip row that names each question and marks the answered ones is capped by a water-filling helper imported from ink's own dialog rather -than reimplemented, fed the width a confirmation actually gets — the terminal -minus the four columns the transcript item spends, minus the two the -confirmation spends inside it — and then reduced by every cell the row spends -outside the header text: the padding, the active-tab marker, the submit chip, -one gap per chip, two columns of prefix per header, and two more for each -answered mark. It is recomputed on every render, because answering changes the -marks. +than reimplemented, and reduced by every cell the row spends outside the header +text: the padding, the active-tab marker, the submit chip, one gap per chip, +two columns of prefix per header, and two more for each answered mark. It is +recomputed on every render, because answering changes the marks. + +ink's overhead terms were taken line for line. The width they are subtracted +from was carried over too, and a review round found that second number wrong: +it belongs to the box ink draws the row in, and this row sits in another one, +which spends two columns of margin and one of padding on each side — the +padding already being one of the overheads above. Charging that padding twice +and ink's transcript indent besides took eight columns off a row that spends +four, so a header was ellipsized where the row still had room. That the +corrected base is the row's real width, and not merely a larger number, was +settled against a laid-out frame rather than the JSX props it came from: three +twelve-cell headers in a fifty-eight-column terminal print whole here across 52 +of the 54 cells the box leaves, and no row reaches the terminal's last column. +ink ellipsizes all three at the same width; that window is recorded below, and +matching it would mean clipping text this row has room for. Two divergences are deliberate, and they are of different kinds. The first is a defect ink has and this port does not. ink answers a typed free-text entry twice @@ -1771,6 +1782,12 @@ What was verified, and how far the verification reaches: row moving, on top of the three each side already loses to the wrap break at the same checkpoint. Decision 22 fixed what the inline confirmation draws; this is where it is mounted, which is a separate change. +- The question dialog's chip row clips two columns later here than in ink. + Decision 21 charges it against the box this row is drawn in, and that box is + two columns wider than ink's, so over a band of terminal widths the headers + print whole here where ink has already reached for the ellipsis — measured at + fifty-eight columns with three twelve-cell headers, on both legs. Matching + ink's number would clip text this row has room for. - A dialog field ports the one-line slice of ink's buffer, not all of it. Four key families ink's text input binds are not ported: word jumps (ctrl/alt+←/→, alt+b/f), delete-word-right (alt+d, ctrl/alt+Delete), kill-line diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 60042ee7e38..6c38ac49f80 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -413,10 +413,19 @@ footer 上。 ink 会画的东西现在这里都画了,而几何是推导出来的,不是猜的。那条命名每个问题并标出已 答问题的 chip 行,其上限由一个从 ink 自己的对话框引用过来、而非重新实现的水位填充助手 -给出;喂给它的宽度是一个确认框真正拿到的宽度 —— 终端宽度减去 transcript 条目花掉的 -四列,再减去确认框在其内部花掉的两列 —— 然后再扣掉这一行在标题文字之外花掉的每一个 -单元格:内边距、当前 tab 标记、submit chip、每个 chip 之间一道间隙、每个标题两列前 -缀,以及每个已答标记再多两列。它在每次渲染时重算,因为作答会改变那些标记。 +给出,然后再扣掉这一行在标题文字之外花掉的每一个单元格:内边距、当前 tab 标记、submit +chip、每个 chip 之间一道间隙、每个标题两列前缀,以及每个已答标记再多两列。它在每次渲 +染时重算,因为作答会改变那些标记。 + +ink 的那些开销项是逐行照搬的,而从中被减去它们的那个宽度也一并搬了过来;一轮评审发现 +那第二个数字是错的:它属于 ink 画这一行所在的那个盒子,而这一行坐在另一个盒子里,后者 +每侧花掉两列外边距和一列内边距 —— 而内边距已经是上面那些开销之一。把那一列内边距算两 +次,再算上 ink 的 transcript 缩进,就从一个只花四列的行上扣掉了八列,于是标题在行里还 +有空间时就被省略号截断了。修正后的基数确实是这一行的真实宽度,而不只是一个更大的数字 +—— 这一点是拿一帧排好版的画面定下来的,而不是它当初所来自的那些 JSX prop:在五十八列 +的终端里放三个十二格的标题,这里会在盒子留下的 54 格里整行打印出 52 格,并且没有任何一 +行到达终端的最后一列。ink 在同样的宽度下把三个都省略号化了;那个区间记录在下面,而照着 +它做意味着裁掉这一行本来就放得下的文字。 有两处分叉是有意的,而且性质不同。第一处是 ink 自己有、这个移植没有的缺陷。ink 对一次 按键把手打的自由文本答案提交两遍 —— 对话框自己的按键处理器与它挂载的输入控件都订阅 @@ -1266,6 +1275,10 @@ focusable,记在后续工作里。 下,所以画面读起来是卡片、确认框、卡片;这里会话按自己的顺序保留两张卡片,确认框 跟在它们后面。差的就是这一行的挪动,同一检查点上两侧本来就已经因为折行断点各失去 三行。决定 22 改的是行内确认框画什么;这一条是它挂在哪里,需要另一次改动。 +- 提问对话框的 chip 行在这里比在 ink 里晚两列才开始裁切。决定 21 按这一行被画在其中的 + 那个盒子来记账,而那个盒子比 ink 的宽两列,于是在一段终端宽度区间里,标题在这里整行 + 打印,而 ink 已经拿起了省略号 —— 在五十八列、三个十二格标题下两条腿都量过。照 ink 的 + 那个数字来做,会裁掉这一行本来就放得下的文字。 - 对话框字段移植的是 ink 缓冲区里那一行宽的切片,不是全部。ink 文本输入绑定的四族键 没有移植:词跳转(ctrl/alt+←/→、alt+b/f)、向右删词(alt+d、ctrl/alt+Delete)、 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。它们需要的操作在这个模型里都已经存在, diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 7658e86a952..2d4e3de2720 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -516,6 +516,44 @@ describe('OpenTuiToolConfirmation', () => { vi.useRealTimers(); }); + it('fits headers that fill the chip row, and still clips ones that cannot fit', () => { + const wide = (tag: string) => tag.padEnd(30, '-'); + const twoWideHeaders = ( + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, + ): ToolCallConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Two questions', + questions: [ + { + question: 'Pick a deploy target?', + header: wide('deploy'), + options: [{ label: 'staging', description: 'the staging target' }], + }, + { + question: 'Pick a region?', + header: wide('region'), + options: [{ label: 'eu', description: 'the eu region' }], + }, + ], + onConfirm, + }); + + // Eighty columns leaves this row room for both headers, and it is this arm + // that fails when ink's transcript indent is charged to it as well. + mocks.state.dimensions = { width: 80, height: 40 }; + const room = mount(twoWideHeaders(async () => {})).textContent ?? ''; + expect(room).toContain(wide('deploy')); + expect(room).toContain(wide('region')); + + // Forty columns less leaves no room, so the cap still bites. + mocks.state.dimensions = { width: 40, height: 40 }; + const tight = mount(twoWideHeaders(async () => {})).textContent ?? ''; + expect(tight).not.toContain(wide('deploy')); + }); + it('numbers each option and renders its description and the free-text row', () => { const text = mount(multiAskDetails(async () => {})).textContent ?? ''; expect(text).toContain('1. staging'); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index c83294028cf..1ccb0fa8c0e 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -794,10 +794,11 @@ function AskUserQuestionFlow(props: { [], ); - // ink sizes the chip row against the width a tool confirmation gets in the - // transcript — the terminal minus 4 for the history item, minus 2 more for - // the confirmation's own padding — and reserves every cell the row spends - // outside the header text. Recomputed per render so answering re-fits it. + // The overheads below are ink's, taken line for line; the width they come off + // is not. ink draws its row in the box a tool confirmation gets inside the + // transcript, while this one spends two columns of margin, plus the padding + // already charged inside `rowOverhead`. Recomputed per render so answering + // re-fits it. const answeredHeaders = questions.filter( (_, idx) => answerFor(idx) !== undefined, ).length; @@ -810,7 +811,7 @@ function AskUserQuestionFlow(props: { 2 * answeredHeaders; // " ✓" after each answered header const headerCap = computeHeaderCap( questions.map((q) => getCachedStringWidth(q.header)), - width - 6 - rowOverhead, + width - 2 - rowOverhead, ); useKeyboard((key) => { From 9ee896be89b3f5f837003a78bb488b5aae1268a9 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 18:51:17 +0800 Subject: [PATCH 35/51] fix(cli): erase a word for the byte a legacy terminal sends for it ink binds ctrl+W, and this port erases a word for that and for a backspace carrying a modifier, which is what a terminal that reports modifiers sends. A terminal that reports none sends the bare 0x1f byte instead, and that byte reached a dialog field and did nothing: not inserted, because a control character is not printable, and not handled, because only the composer had been taught it. Both field paths now ask the composer's own predicate, so the one spelling they had not shared is shared. --- .../2026-09-10-opentui-parity-defect-sweep.md | 20 +++++++++++-- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 28 ++++++++++++------- packages/cli/src/ui/opentui/line-edit.test.ts | 3 +- packages/cli/src/ui/opentui/line-edit.ts | 6 +++- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index e4c431b72ea..9be9a3f0a6a 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1097,6 +1097,19 @@ beside the ctrl+E one, the wizard's base-URL case pasting a two-line value and landing a character inside its first line, and a mutation that returns the bare key to the value-end jump — it fails both. +A second review-round finding was a spelling rather than a behaviour: the key +that erases the word left of the caret. ink binds ctrl+W, and this port erases +a word for that and for a backspace carrying a modifier, which is what a +terminal that reports modifiers sends. A terminal that reports none sends the +bare byte 0x1f instead, and that byte reached a dialog field and did nothing — +not inserted, since a control character is not printable, and not handled, +since only the composer had been taught the byte. Both field paths now ask the +composer's own predicate, so the one spelling they had not shared is shared. +The mutation that takes it back out fails the single assertion that feeds the +byte and leaves the ctrl+W arm of the same case green, which is what makes that +case say the byte travels on its own rather than merely that word deletion +works. + Three rendering differences stand. ink windows a field to a fixed column count and shows only the line the caret is on, so a pasted multi-line value hides everything off that line, while these rows render the whole value. ink paints its @@ -1127,12 +1140,13 @@ shape before the caret existed at all — ink showing a value its flow has alrea discarded is recorded as a follow-up rather than reproduced here. Coverage is twenty-two new unit tests in three suites — thirteen of them the -model compared against ink, the rest the fields that use it — plus eight +model compared against ink, the rest the fields that use it — plus nine mutations, each failing the tests that own the behaviour taken away: a left arrow that moves nothing, the context-window field's typing branch, the two end-of-line jumps, forward delete, a modified Delete that erases a character -instead of passing through, ctrl+W, a row that stops re-mounting, and a bare End -folded back into the ctrl+E jump. On a machine, a new scenario answers one +instead of passing through, ctrl+W, a row that stops re-mounting, a bare End +folded back into the ctrl+E jump, and the legacy word-erase byte handed back to +the dialog. On a machine, a new scenario answers one question from its free-text row on both legs, with a single question so that neither the tab clamp nor the double-fire recorded under Decision 21 can be what produced the frame: six characters typed, the caret taken back two cells, one diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 6c38ac49f80..1a7e85bb62c 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -789,6 +789,14 @@ shell 测试断言的是这些行与 composer 一起落在 chrome 里,而不 字符落回第一行之内的用例,以及一个把裸键送回「跳到值尾」的变异 —— 两个套件各有一条测试为 它失败。 +第二轮评审的另一条发现是一个拼法,而不是一个行为:擦掉光标左边那个词的键。ink 绑定的是 +ctrl+W,而本移植除了它,还会为一个带修饰键的退格擦词 —— 那是一个上报修饰键的终端所发的 +东西。不上报修饰键的终端发的是裸字节 0x1f,而这个字节到了对话框字段上什么也没发生 —— +既没有被插入,因为控制字符不算可打印;也没有被处理,因为只有 composer 被教过这个字节。 +两条字段路径现在都去问 composer 自己的那个谓词,于是它们此前唯一没共享的一种拼法共享了。 +把这个改动取掉的变异,只让同一条用例里喂进这个字节的那一个断言失败,而让 ctrl+W 那一臂 +保持绿灯 —— 正是这一点让那条用例说的是「这个字节自己走得通」,而不只是「删词能用」。 + 三处渲染差异保留下来。ink 把字段按固定列数开窗,并且只画光标所在的那一行,所以一次多行 粘贴会把它自己看不着的那几行全藏起来,而这里的行渲染整个值。ink 把光标格涂成从终端背景 读出来的灰,并且在实心块会破坏输入法拼字时退到下划线,而这一格带的是主题强调色,和 @@ -811,17 +819,17 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 没有照搬。 覆盖是三个测试套件里新增的二十二条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 -用它的字段 —— 外加八个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 +用它的字段 —— 外加九个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 上下文窗口字段的输入分支、两个跳至行尾的绑定、向前删除、带修饰键的 Delete 擦掉一个字符 -而不是把键交还、ctrl+W、一行不再重新挂载,以及把裸 End 折回 ctrl+E 的那一跳。实机侧, -新增场景在两条腿上都从自由文本行 -回答一个问题,而且只问一个问题,这样标签钳制和 -决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符,光标往回退两格,再插入一个 -字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 `abcdXef` 记成了答案。带样式的 -抓取把那一定位钉死了。在 ink 画出那一格的两帧里,它与本渲染器的那一格落在同一个字符的 -同一列上,只有两种颜色不同;在它之前的那一帧,光标停在最后一个字符之后,两条腿各自要画 -的那一格都是一个被抓取丢掉的尾随空格,所以两边都不见格;而在最早那一帧,字段还空着、 -显示的是占位符,ink 的那一格不在而本渲染器的在 —— 那是它的翻转间隔,不是缺了光标。 +而不是把键交还、ctrl+W、一行不再重新挂载、把裸 End 折回 ctrl+E 的那一跳,以及把那个遗留 +的删词字节交还给对话框。实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问 +一个问题,这样标签钳制和决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符, +光标往回退两格,再插入一个字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 +`abcdXef` 记成了答案。带样式的抓取把那一定位钉死了。在 ink 画出那一格的两帧里,它 +与本渲染器的那一格落在同一个字符的同一列上,只有两种颜色不同;在它之前的那一帧,光标停 +在最后一个字符之后,两条腿各自要画的那一格都是一个被抓取丢掉的尾随空格,所以两边都不见 +格;而在最早那一帧,字段还空着、显示的是占位符,ink 的那一格不在而本渲染器的在 —— +那是它的翻转间隔,不是缺了光标。 没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的编辑只有单元测试套件兜着。 还有一条测试运行时的说明值得写在这里,因为它花掉了一轮排障: 字段测试用的那套 DOM 运行时会把重渲染行的旧渲染留在活的那一行旁边,所以读光标格的探针读 diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 71c070b811d..f6a417dea3f 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -389,11 +389,12 @@ describe('line-edit', () => { ); }); - it('deletes a word from ctrl+W and a modified backspace', () => { + it('deletes a word from ctrl+W, a modified backspace, and the byte a legacy terminal sends for it', () => { for (const k of [ key('w', { ctrl: true }), key('backspace', { ctrl: true }), key('backspace', { meta: true }), + key('unknown', { sequence: '\x1f' }), ]) { expect(applyLineKey({ text: 'https://openai', cursor: 14 }, k)).toEqual( { diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index ad7208eb4cd..25483f9d895 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -48,6 +48,7 @@ import { stripUnsafeCharacters, toCodePoints, } from '../utils/textUtils.js'; +import { isDeleteWordBackwardSequence } from './input-prompt-key.js'; export interface LineState { /** The field's whole value, line breaks included. */ @@ -240,7 +241,10 @@ export function applyLineKey( if (name === 'end') return moveCaretLineEnd(next); if (ctrl && name === 'e') return moveCaretEnd(next); if (ctrl && name === 'w') return deleteWordLeftAtCaret(next); - if (modified && (name === 'backspace' || sequence === '\x7f')) { + if ( + isDeleteWordBackwardSequence(sequence) || + (modified && (name === 'backspace' || sequence === '\x7f')) + ) { return deleteWordLeftAtCaret(next); } if (name === 'backspace' || sequence === '\x7f' || (ctrl && name === 'h')) { From e2b325c5ede26d09e8e8f833fb95a745358045b9 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 18:51:35 +0800 Subject: [PATCH 36/51] docs: correct what a resumed session brings back for a slash command The record a model dialog leaves behind was claimed to be missing where ink keeps it on screen. ink keeps a record of each of its outcome sites too, and what this renderer loses on a resume is wider than that one dialog: its transcript adapter replays a slash command's invocation phase only, so no slash result comes back at all. That gap now reads as the app-wide one it is, and is carried into the follow-ups list alongside the chip row's clipping window, which was recorded the same way. --- .../2026-09-10-opentui-parity-defect-sweep.md | 32 +++++++++++++------ ...09-10-opentui-parity-defect-sweep.zh-CN.md | 15 +++++++-- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 9be9a3f0a6a..833ee2effe9 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1021,13 +1021,14 @@ editing two. ## Decision 31 — a model dialog outcome is recorded as well as shown The row a `/model` dialog leaves behind — the model a pick settled on, or the -one an escape kept — reached the transcript but not the session log, so resuming -the session dropped it where ink keeps it. The dispatcher cannot carry it: that -result phase closes while the dialog is still open, with an empty output list, -and ink writes the same empty pair. So ink pairs each of its three outcome sites -with a second result-phase record naming the command and carrying that one row. -This renderer now makes the same pairing, over one object: the row shown and the -row recorded are the same value, built once, so the two cannot drift apart. +one an escape kept — reached the transcript but not the session log, where ink +carries a record of each of those three outcomes. The dispatcher cannot carry +it: that result phase closes while the dialog is still open, with an empty +output list, and ink writes the same empty pair. So ink pairs each of its three +outcome sites with a second result-phase record naming the command and carrying +that one row. This renderer now makes the same pairing, over one object: the row +shown and the row recorded are the same value, built once, so the two cannot +drift apart. Two silences are deliberate. A pick that fails to apply records nothing — ink keeps the dialog open with the error, and a recorded row would replay a switch @@ -1045,9 +1046,13 @@ real machine the slash-dialog scenario opens `/model` and escapes it on both legs: each session log now holds nine `slash_command` records, in the same order and with the same flags, the fifth being the dialog's own result carrying `Kept model as fake-model` on both sides. What is not shown on screen is the -replay: the shared resume loader reads this payload shape and this renderer's -session switch calls it, but no scenario resumed a session to watch the row come -back. +replay. The adapter that rebuilds a transcript from a log emits a row for a +slash command's invocation phase only, and this is a result phase, whereas +ink's replay reads the output rows a result phase carries. So after a resume +ink still draws the kept-model row and this renderer draws neither that row nor +any other slash result — the gap Decision 13 names for info rows in general. +Closing it changes what every slash command leaves behind a resume, so it is +recorded under Follow-ups rather than taken on here. ## Decision 32 — a dialog field's caret moves, as ink's does @@ -1808,6 +1813,13 @@ What was verified, and how far the verification reaches: (ctrl+k/ctrl+u) and undo/redo (ctrl+z). Every operation they need already exists in this model, so it is one change over the line editor and its tests rather than a per-dialog one. +- A resumed session shows fewer rows than its log holds. This renderer's + transcript adapter replays a slash command's invocation phase and nothing + else, so a result-phase row — the model dialog's kept-model line of Decision + 31 among them — is recorded and then never drawn again, where ink's resume + puts each of them back. That is wider than one dialog: closing it changes what + every slash command leaves behind a resume, and no scenario of the acceptance + matrix resumes a session at all. - What a refused keystroke leaves on screen diverges, as Decision 32 says. The context-window field keeps digits only and this renderer prints the accepted value, so a letter — or the Space that step binds — vanishes without a trace, diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 1a7e85bb62c..2eb9ef8fece 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -739,7 +739,7 @@ shell 测试断言的是这些行与 composer 一起落在 chrome 里,而不 ## 决定 31 —— 模型对话框的结果,既显示也记录 `/model` 对话框留下的那一行 —— 一次选取定下的模型,或是一次退出保住的模型 —— 只到了 -转录,没到会话日志,于是恢复会话时它丢了,而 ink 会把它放回来。分发器兜不住:它的那 +转录,没到会话日志,而 ink 在那三处结果点各自都留有一条记录。分发器兜不住:它的那 个 result 阶段在对话框还开着时就结束了,输出列表是空的,而 ink 写的也是同样这一对空 的。所以 ink 把自己三处结果点每一处都再配一条 result 阶段的记录,念出命令名、带上那 一行。本渲染器现在做同样的配对,并且是同一个对象:显示的行与被记录的行是一次构建出 @@ -755,8 +755,12 @@ shell 测试断言的是这些行与 composer 一起落在 chrome 里,而不 令名写错、负载为空、失败也照记 —— 每个至少让一条测试失败。实机侧,slash 对话框场景在 两条腿上都打开 `/model` 再退出:各自的会话日志现在都有九条 `slash_command` 记录,顺序 与标记一致,第五条正是对话框自己的 result,两边都带着 `Kept model as fake-model`。没 -在屏幕上演的是重放:共用的恢复加载器读的就是这个负载形状,本渲染器的会话切换也调用了 -它,但没有任何场景真的恢复过一次会话、看着这一行回来。 +在屏幕上演的是重放。把一个日志重建回 transcript 的那个适配器,只为 slash 命令的 +invocation 阶段产出一行,而这是一条 result 阶段;ink 的重放读的是一个 result 阶段所 +携带的那些输出行。所以恢复之后 ink 仍然画得出那行「保留原模型」,而本渲染器既画不出 +它,也画不出任何其他 slash 结果 —— 正是决定 13 为 info 行整体点出的那条差距。补上它会 +改变每一条 slash 命令在恢复之后留下什么,因此它记在「后续工作」之下,而不是在这一轮 +接下。 ## 决定 32 —— 对话框字段的光标会动了,和 ink 一样 @@ -1291,6 +1295,11 @@ focusable,记在后续工作里。 没有移植:词跳转(ctrl/alt+←/→、alt+b/f)、向右删词(alt+d、ctrl/alt+Delete)、 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。它们需要的操作在这个模型里都已经存在, 所以这是一次落在行编辑器与其测试上的改动,而不是逐个对话框的改动。 +- 恢复出来的会话显示的行比它的日志里装的少。本渲染器的 transcript 适配器只重放 slash + 命令的 invocation 阶段,别的一概不重放,于是一条 result 阶段的行 —— 其中包含决定 31 + 的那行「保留原模型」—— 被记录之后就再也不画出来,而 ink 的恢复会把它们每一行都放回 + 去。这比一个对话框更宽:补上它会改变每一条 slash 命令在恢复之后留下什么,而验收矩阵里 + 没有任何场景恢复过一次会话。 - 一次被拒绝的按键在屏幕上留下什么,两侧是有分歧的,决定 32 已说过。context-window 字段只保留数字,而本渲染器打印的是被接受的那个值,于是一个字母 —— 或那一步绑定的 Space —— 会不留痕迹地消失;ink 的缓冲区才是它显示内容的源头,会一直显示自己流程已经 From 76c07ac7099899d6153c967208d83260806cd54a Mon Sep 17 00:00:00 2001 From: chiga0 Date: Tue, 15 Sep 2026 19:41:36 +0800 Subject: [PATCH 37/51] fix(cli): keep a pasted newline inside the row the caret sits on A bracketed paste is the only way a newline reaches a dialog field, and the rows that draw these fields rendered the whole value. This port's cursor is a cell set beside the text it marks, so one break grew the row past the dialog's height budget and left the highlighted cell on another line than the caret. The rows now draw the caret's line only, as ink's single-line field does, while the value keeps the break so the answer that reaches the model is the same text. --- .../2026-09-10-opentui-parity-defect-sweep.md | 53 ++++++++++++------- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 21 +++++--- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 35 +++++++----- .../src/ui/opentui/dialogs-confirm.test.tsx | 18 +++++++ packages/cli/src/ui/opentui/line-edit.test.ts | 12 +++++ packages/cli/src/ui/opentui/line-edit.ts | 35 +++++++----- 6 files changed, 120 insertions(+), 54 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 833ee2effe9..945c0f0c364 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1115,18 +1115,25 @@ byte and leaves the ctrl+W arm of the same case green, which is what makes that case say the byte travels on its own rather than merely that word deletion works. -Three rendering differences stand. ink windows a field to a fixed column count -and shows only the line the caret is on, so a pasted multi-line value hides -everything off that line, while these rows render the whole value. ink paints its -cell a gray read off the terminal background, falling back to an underline where -a block would corrupt IME composition, while this cell carries the theme accent -as the composer's cursor does. And ink runs a 530 ms interval that toggles the -cell's visibility for as long as a field holds focus, where a steady cell keeps -the dialog off a repaint timer. That interval shows up in a measurement, not just -in the source: over a scenario that parks the caret in the free-text row, the ink -leg wrote 270 KB in each sixty-second window it sat there, four of the scenario's -seven settling waits burned their full timeout, and the leg took 251 s where this -renderer's took 13 s. +Three rendering differences stand, one of them narrower than it was. ink +windows a field to a fixed column count and shows only the line the caret is +on, so a pasted multi-line value hides everything off that line. These rows +hide the same lines now, because a review round found what rendering the whole +value cost: this cursor is a frame beside the text it marks, so a newline a +paste carried in grew the row past the dialog's height budget and left the +highlighted cell on another line than the caret. The value keeps the break, as +ink's buffer does, so what reaches the model is the same text. The column +count is still not windowed, which is the limit the bare End caveat above +already ran into from the other side. ink paints its cell a gray read off the +terminal background, falling back to an underline where a block would corrupt +IME composition, while this cell carries the theme accent as the composer's +cursor does. And ink runs a 530 ms interval that toggles the cell's visibility +for as long as a field holds focus, where a steady cell keeps the dialog off a +repaint timer. That interval shows up in a measurement, not just in the +source: over a scenario that parks the caret in the free-text row, the ink leg +wrote 270 KB in each sixty-second window it sat there, four of the scenario's +seven settling waits burned their full timeout, and the leg took 251 s where +this renderer's took 13 s. Mount semantics differ per field, because ink's do. The question dialog's row is mounted by that dialog only while its own option is the selected one, and mounted @@ -1144,18 +1151,19 @@ The caret lands in the same column both ways, and what is displayed took this shape before the caret existed at all — ink showing a value its flow has already discarded is recorded as a follow-up rather than reproduced here. -Coverage is twenty-two new unit tests in three suites — thirteen of them the -model compared against ink, the rest the fields that use it — plus nine +Coverage is twenty-three new unit tests in three suites — thirteen of them the +model compared against ink, the rest the fields that use it — plus eleven mutations, each failing the tests that own the behaviour taken away: a left arrow that moves nothing, the context-window field's typing branch, the two end-of-line jumps, forward delete, a modified Delete that erases a character instead of passing through, ctrl+W, a row that stops re-mounting, a bare End -folded back into the ctrl+E jump, and the legacy word-erase byte handed back to -the dialog. On a machine, a new scenario answers one -question from its free-text row on both legs, with a single question so that -neither the tab clamp nor the double-fire recorded under Decision 21 can be what -produced the frame: six characters typed, the caret taken back two cells, one -character inserted. Both legs show +folded back into the ctrl+E jump, the legacy word-erase byte handed back to the +dialog, a row that paints the line break a paste carried in, and a caret parked +on that break highlighting the break itself. On a machine, a new scenario +answers one question from its free-text row on both legs, with a single +question so that neither the tab clamp nor the double-fire recorded under +Decision 21 can be what produced the frame: six characters typed, the caret +taken back two cells, one character inserted. Both legs show `> abcdXef` at the same column of the same row and both record `abcdXef` as the answer. The styled capture pins the cell itself. At the two checkpoints where ink draws it, the cell sits over the same character in the same column on @@ -1813,6 +1821,11 @@ What was verified, and how far the verification reaches: (ctrl+k/ctrl+u) and undo/redo (ctrl+z). Every operation they need already exists in this model, so it is one change over the line editor and its tests rather than a per-dialog one. +- A dialog field's row windows its lines and not its columns. ink's field shows + the line under the caret and, along it, the columns around the caret, so a + value wider than the row scrolls sideways. This row hides the lines off the + caret's and still draws all of its own columns, so a value longer than the row + wraps it — whether the characters arrived typed or in one paste. - A resumed session shows fewer rows than its log holds. This renderer's transcript adapter replays a slash command's invocation phase and nothing else, so a result-phase row — the model dialog's kept-model line of Decision diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 2eb9ef8fece..a5b98cd4c4b 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -801,8 +801,12 @@ ctrl+W,而本移植除了它,还会为一个带修饰键的退格擦词 — 把这个改动取掉的变异,只让同一条用例里喂进这个字节的那一个断言失败,而让 ctrl+W 那一臂 保持绿灯 —— 正是这一点让那条用例说的是「这个字节自己走得通」,而不只是「删词能用」。 -三处渲染差异保留下来。ink 把字段按固定列数开窗,并且只画光标所在的那一行,所以一次多行 -粘贴会把它自己看不着的那几行全藏起来,而这里的行渲染整个值。ink 把光标格涂成从终端背景 +三处渲染差异保留下来,其中一处比原先窄了。ink 把字段按固定列数开窗,并且只画光标所在的那 +一行,所以一次多行粘贴会把它自己看不着的那几行全藏起来。这些行现在也藏同样的那几行,因为 +一轮评审找出了整值渲染的代价:这里的光标格是并列在它所标记文本旁边的一帧,于是一个粘贴带 +进来的换行会把这一行撑过对话框自身的高度预算,并把那一格留在与光标不同的行上。值仍然留着 +那个换行,和 ink 的缓冲区一样,所以送到模型那里的文本没有变。列数仍然没有开窗,这正是上面 +裸 End 那一条从另一头撞上的限制。ink 把光标格涂成从终端背景 读出来的灰,并且在实心块会破坏输入法拼字时退到下划线,而这一格带的是主题强调色,和 composer 的光标一致。还有 ink 会跑一个 530 毫秒的定时器,只要字段握着焦点就不断翻转那 一格的可见性,而一格常亮让对话框不必按定时器重绘。这个定时器不只是源码里的一行,它在测量 @@ -822,11 +826,12 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 还不存在时就是这个样子 —— ink 显示一个自己的流程已经作废的值,这一条记为后续工作, 没有照搬。 -覆盖是三个测试套件里新增的二十二条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 -用它的字段 —— 外加九个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 +覆盖是三个测试套件里新增的二十三条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 +用它的字段 —— 外加十一个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 上下文窗口字段的输入分支、两个跳至行尾的绑定、向前删除、带修饰键的 Delete 擦掉一个字符 -而不是把键交还、ctrl+W、一行不再重新挂载、把裸 End 折回 ctrl+E 的那一跳,以及把那个遗留 -的删词字节交还给对话框。实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问 +而不是把键交还、ctrl+W、一行不再重新挂载、把裸 End 折回 ctrl+E 的那一跳、把那个遗留的删词 +字节交还给对话框、一行把粘贴带进来的那个换行画了出来,以及一个停在换行上的光标去高亮那个 +换行本身。实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问 一个问题,这样标签钳制和决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符, 光标往回退两格,再插入一个字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 `abcdXef` 记成了答案。带样式的抓取把那一定位钉死了。在 ink 画出那一格的两帧里,它 @@ -1295,6 +1300,10 @@ focusable,记在后续工作里。 没有移植:词跳转(ctrl/alt+←/→、alt+b/f)、向右删词(alt+d、ctrl/alt+Delete)、 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。它们需要的操作在这个模型里都已经存在, 所以这是一次落在行编辑器与其测试上的改动,而不是逐个对话框的改动。 +- 对话框字段的行开了「行」的窗,没开「列」的窗。ink 的字段画光标所在的那一行,并且在那 + 一行里画光标左右的那些列,所以比这一行更宽的值是横着滚过去的。这一行藏起了光标之外的 + 其他行,却仍然画出自己这一行的全部列,于是一个比这一行更长的值还是会把它折行 —— 无论 + 那些字符是一个个敲进来的,还是一次粘贴进来的。 - 恢复出来的会话显示的行比它的日志里装的少。本渲染器的 transcript 适配器只重放 slash 命令的 invocation 阶段,别的一概不重放,于是一条 result 阶段的行 —— 其中包含决定 31 的那行「保留原模型」—— 被记录之后就再也不画出来,而 ink 的恢复会把它们每一行都放回 diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index de2f99dcd85..90686554e14 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -656,15 +656,18 @@ describe('bracketed-paste into dialog inputs (#57)', () => { expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); }); - it('normalizes CRLF pastes onto LF before inserting', async () => { + it('keeps a pasted line break out of the field row', async () => { await runToApiKeyStep(); + // Line-ending normalization itself is pinned in line-edit's own tests; this + // one covers what the wizard draws: only the line the caret sits on, so the + // other line reaches the screen when the caret crosses the break. await pasteText('key-1\r\nkey-2'); - // testing-library collapses whitespace in getByText, so match on the raw - // textContent where the \r must be gone - const match = screen.getByText( - (_, element) => element?.textContent === 'key-1\nkey-2', - ); - expect(match).toBeTruthy(); + expect(document.body.textContent).toContain('key-2'); + expect(document.body.textContent).not.toContain('key-1'); + await press('a', { ctrl: true, sequence: '\x01' }); + await press('left'); + expect(document.body.textContent).toContain('key-1'); + expect(document.body.textContent).not.toContain('key-2'); }); it('appends a paste after typed text in the models custom-ID input', async () => { @@ -800,11 +803,9 @@ describe('caret editing in dialog text fields (#107)', () => { expect(focusedField().cell).toBe('h'); await press('e', { ctrl: true, sequence: '\x05' }); await typeText('s'); - // ink's ctrl+E binding is the end of the value, not of the caret's line - expect(focusedField()).toEqual({ - text: 'https://one.test\nhttps://two.tests ', - cell: ' ', - }); + // ink's ctrl+E binding is the end of the value, not of the caret's line. + // The jump lands on the second line, so that is the line the row draws. + expect(focusedField()).toEqual({ text: 'https://two.tests ', cell: ' ' }); // The bare key is ink's reducer move, which stops at the line the caret is // on. Park the caret inside the first line — ctrl+A after the ctrl+E jump // lands on the second line's start — and End takes it to that line's end, @@ -814,9 +815,15 @@ describe('caret editing in dialog text fields (#107)', () => { await press('left'); await press('end'); await typeText('X'); + // The row holds the caret's line and nothing else, so a caret parked on the + // break shows a blank cell where ink's own row would. + expect(focusedField()).toEqual({ text: 'https://one.testX ', cell: ' ' }); + // Crossing the break proves the value kept it: the row switches lines, and + // the second line was never on screen until the caret reached it. + await press('right'); expect(focusedField()).toEqual({ - text: 'https://one.testX\nhttps://two.tests', - cell: '\n', + text: 'https://two.tests', + cell: 'h', }); }); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 2d4e3de2720..fc43617a156 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -611,6 +611,24 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('keeps a pasted newline out of the row and inside the answer', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('alpha\nbeta'); + press({ name: 'left' }); + // ink's single-line field draws the row the caret sits in, so the split + // value neither grows this dialog nor detaches its cursor from the caret. + expect(container.textContent ?? '').not.toContain('alpha'); + expect(container.textContent ?? '').toContain('beta'); + expect(cursorCell(container)).toBe('a'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'alpha\nbeta' } }, + ); + }); + it('keeps every character of a burst that shares one batch', () => { const onConfirm = vi.fn(async () => {}); const container = mount(askDetails(twoOptions, onConfirm)); diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index f6a417dea3f..2e5a2a93c26 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -336,6 +336,18 @@ describe('line-edit', () => { at: '', after: '', }); + // A bracketed paste is the only way a newline reaches these values, and the + // rows they feed hold one line: the caret's line is what the spans cover. + expect(caretSpans({ text: 'a\nbc', cursor: 3 })).toEqual({ + before: 'b', + at: 'c', + after: '', + }); + expect(caretSpans({ text: 'a\nbc', cursor: 1 })).toEqual({ + before: 'a', + at: '', + after: '', + }); }); describe('applyLineKey', () => { diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index 25483f9d895..05f58050a9f 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -98,20 +98,6 @@ export function caretForAcceptedValue( return { text: accepted, cursor }; } -/** The three pieces a caret splits the value into, for cursor rendering. */ -export function caretSpans(state: LineState): { - before: string; - at: string; - after: string; -} { - const clamped = clampCaret(state); - return { - before: cpSlice(clamped.text, 0, clamped.cursor), - at: cpSlice(clamped.text, clamped.cursor, clamped.cursor + 1), - after: cpSlice(clamped.text, clamped.cursor + 1), - }; -} - /** `[start, end)` code-point bounds of the line the caret sits on. */ function lineBounds( text: string, @@ -125,6 +111,27 @@ function lineBounds( return { start, end, line: codePoints.slice(start, end).join('') }; } +/** The three pieces a caret splits the value into, for cursor rendering. */ +export function caretSpans(state: LineState): { + before: string; + at: string; + after: string; +} { + const clamped = clampCaret(state); + // Only the line under the caret: these spans feed rows that hold one line, + // and a bracketed paste can carry a newline into a value no keystroke can. + const { start, end } = lineBounds(clamped.text, clamped.cursor); + return { + before: cpSlice(clamped.text, start, clamped.cursor), + at: cpSlice( + clamped.text, + clamped.cursor, + Math.min(clamped.cursor + 1, end), + ), + after: cpSlice(clamped.text, clamped.cursor + 1, end), + }; +} + export function insertAtCaret(state: LineState, inserted: string): LineState { const { text, cursor } = clampCaret(state); const clean = stripUnsafeCharacters( From 5a94b7b330b2286efd0f63dbbd38d05d29dfcc28 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Wed, 16 Sep 2026 18:22:25 +0800 Subject: [PATCH 38/51] fix(cli): hold a settled field closed and follow the tail its owner accepted The latch a submit armed was a per-render binding, so the re-render the submit itself scheduled cleared it and the next key of the same stdin read kept editing a row the wizard had already left. It is a ref now, re-armed only where the field's mirror is re-seeded, and it is checked inside the insert path so a bracketed paste trailing the Enter is stopped the same way a key is. The caret rule beside it capped the caret at the shared prefix of the proposed and accepted values, which strands it on the first code point an owner dropped out of the middle of a paste. Walking the two texts and charging the caret only for the drops ahead of it keeps the caret following the accepted tail, and the old cap remains the fallback for an owner that inserts or substitutes. --- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 30 ++++- packages/cli/src/ui/opentui/line-edit.test.ts | 27 +++++ packages/cli/src/ui/opentui/line-edit.ts | 104 +++++++++++++----- 3 files changed, 129 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 90686554e14..ebeef3db4fa 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -522,8 +522,9 @@ describe('bracketed-paste into dialog inputs (#57)', () => { it('submits the burst that shares one batch with its Enter', async () => { await runToApiKeyStep(); await typeBatchedThenEnter('sk-burst-key'); - // The Enter read the flow's state, which no render had refreshed yet, so the - // step refused the key it was showing and stayed put. + // Before the fix the Enter read the flow's state, which no render had + // refreshed yet, and the step refused the key it was showing; the submit now + // reads the editor's live text, so the burst lands on the models step. expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); }); @@ -609,6 +610,31 @@ describe('bracketed-paste into dialog inputs (#57)', () => { expect(document.body.textContent).not.toContain('"contextWindowSize": 123'); }); + it('ignores the paste that trails the advanced-config step Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('down'); // thinking → modality + await press('down'); // modality → context window + const handler = lastKeyboardHandler(); + const pasteHandler = mocks.state.pasteHandlers.at(-1); + if (!pasteHandler) throw new Error('no paste handler registered'); + await act(async () => { + for (const char of '12') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + // One stdin read dispatches its keys and its pastes in order to the same + // still-registered handlers, with no commit in between, so a paste the + // terminal buffered while the app was busy lands after the Enter. + pasteHandler(makePasteEvent('3')); + }); + expect(document.body.textContent).toContain('"contextWindowSize": 12'); + expect(document.body.textContent).not.toContain('"contextWindowSize": 123'); + }); + it('keeps taking the read after an Enter the step refused', async () => { await runToBaseUrlInput(); const handler = lastKeyboardHandler(); diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 2e5a2a93c26..555971d204e 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -320,6 +320,33 @@ describe('line-edit', () => { }); }); + it('follows the accepted tail of a value the owner filtered in the middle', () => { + // A paste into a field whose setter strips characters out of the middle is + // not a refusal at the caret. Capping the caret at the shared prefix strands + // it on the first code point the owner dropped, so the next digit lands + // ahead of the tail the user pasted and the next Backspace eats a real one. + expect(caretForAcceptedValue({ text: '1,024', cursor: 5 }, '1024')).toEqual( + { + text: '1024', + cursor: 4, + }, + ); + expect( + caretForAcceptedValue( + { text: 'context window: 32768', cursor: 21 }, + '32768', + ), + ).toEqual({ text: '32768', cursor: 5 }); + // A caret ahead of the dropped code point keeps its own offset: nothing was + // removed before it, so there is nothing to charge it for. + expect(caretForAcceptedValue({ text: '1,024', cursor: 1 }, '1024')).toEqual( + { + text: '1024', + cursor: 1, + }, + ); + }); + it('splits the value at the caret for cursor rendering', () => { expect(caretSpans({ text: 'abc', cursor: 1 })).toEqual({ before: 'a', diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index 05f58050a9f..6c23ecbb020 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -22,17 +22,19 @@ * * Not ported, and recorded as follow-ups: word jumps (ctrl/alt+←/→, alt+b/f), * delete-word-right (alt+d, ctrl/alt+Delete), kill-line (ctrl+k/ctrl+u) and - * undo/redo (ctrl+z) — all of which ink's TextInput does bind. ctrl+D is - * deliberately absent: the app's global EXIT binding acts on it, and this - * reducer hands it back unhandled, so no field ever edits on that key. Three - * rendering differences remain as well: ink windows the field at `inputWidth` - * columns, and its one-line viewport shows only the line the caret is on, so a - * pasted multi-line value hides everything off that line, while these rows - * render the whole value; ink blinks the cursor cell every 530 ms, where a + * undo/redo (ctrl+z) — all of which ink's TextInput does bind, as it does + * clear-input (ctrl+C) and open-external-editor (ctrl+X). Neither is ported: + * the app's exit handler acts on ctrl+C wherever a dialog owns the screen, and + * ctrl+X is unbound in this renderer. ctrl+D is deliberately absent: the app's + * global EXIT binding acts on it, and this reducer hands it back unhandled, so + * no field ever edits on that key. Three rendering differences remain as well: + * ink windows the field at `inputWidth` columns, where these rows do no column + * windowing of their own; ink blinks the cursor cell every 530 ms, where a * steady cell keeps the dialog from repainting on a timer; and the cell carries * the theme accent, as the composer's cursor does, where ink paints a gray read * from the terminal background (and falls back to an underline where a block - * would corrupt IME composition). + * would corrupt IME composition). A pasted multi-line value shows only the line + * the caret is on, as ink's one-line viewport does. */ import { useRef, useState } from 'react'; @@ -75,9 +77,16 @@ export function clampCaret(state: LineState): LineState { * The caret indexes the value the owner acknowledged, not the text this module * last proposed: an owner that refuses a character would otherwise leave the * caret one cell right of where the user put it, and the next Backspace would - * delete a character the user never inserted. Capping the caret at the code - * points the two texts agree on makes a refused keystroke leave no trace at all, - * and is just the clamp when they are equal. + * delete a character the user never inserted. + * + * Walking the two texts together and charging the caret only for the code points + * dropped ahead of it covers both shapes an owner refuses in. A single refused + * keystroke drops one code point at the caret and leaves nothing behind it, and + * an owner that filters the middle of a pasted value (`1,024` → `1024`) keeps the + * caret following the accepted tail rather than stranding it on the first + * character it removed. A value the walk cannot align — the owner inserted or + * substituted instead of dropping — falls back to capping the caret at the code + * points the two texts agree on. Both reduce to the clamp when they are equal. */ export function caretForAcceptedValue( state: LineState, @@ -86,16 +95,37 @@ export function caretForAcceptedValue( if (accepted === state.text) return clampCaret(state); const proposed = toCodePoints(state.text); const current = toCodePoints(accepted); - let shared = 0; - while ( - shared < proposed.length && - shared < current.length && - proposed[shared] === current[shared] - ) { - shared++; + const caret0 = Math.min(state.cursor, proposed.length); + let p = 0; + let c = 0; + let dropped = 0; + while (p < proposed.length && c < current.length) { + if (proposed[p] === current[c]) { + p++; + c++; + } else if (p < caret0) { + p++; + dropped++; + } else { + break; + } } - const cursor = Math.min(state.cursor, shared); - return { text: accepted, cursor }; + let cursor = caret0 - dropped; + if (c < current.length) { + let shared = 0; + while ( + shared < proposed.length && + shared < current.length && + proposed[shared] === current[shared] + ) { + shared++; + } + cursor = Math.min(state.cursor, shared); + } + return { + text: accepted, + cursor: Math.max(0, Math.min(cursor, current.length)), + }; } /** `[start, end)` code-point bounds of the line the caret sits on. */ @@ -295,24 +325,33 @@ export function useLineEdit( /** Inserts at the caret: a paste, or a printable key. */ insert: (text: string) => void; /** - * The field submitted and the wizard moved on. A keystroke handled now would - * land in the step this read already left, because the read keeps dispatching - * to the handler of the render that armed it. + * The field submitted and its owner moved on. Keys and pastes handled from here + * would land in the row this read already left, because one stdin read keeps + * dispatching to the handler of the render that armed it — and the render the + * submit itself schedules can leave the row mounted and focused. */ settle: () => void; - /** Whether {@link settle} was called on this field. */ + /** + * Whether {@link settle} was called on this field. Holds across renders until + * `mountKey` re-seeds it, so a submit's own re-render does not disarm the latch. + */ readonly settled: boolean; } { const [, repaint] = useState(0); - // Per render, not per mount: a fresh handler closes over a fresh copy of this, - // so the flag spans exactly one stdin read while the render that follows the - // submit clears it. A field whose submit was rejected stays editable. - let settled = false; + // A ref, not a per-render binding: the render a successful submit schedules + // would otherwise clear the latch, and in the question dialog that render + // leaves the submitted row mounted and focused for the whole 150 ms pause, so + // the next stdin read would keep editing an answer already recorded. Clearing + // it where the mirror is re-seeded re-arms the field exactly when its owner + // moves on to another row. A field whose submit was rejected stays editable, + // because only `settle` arms this and every consumer calls it after success. + const settledRef = useRef(false); const mirror = useRef(endOfLine(value)); const mounted = useRef(mountKey); if (mounted.current !== mountKey) { mounted.current = mountKey; mirror.current = endOfLine(value); + settledRef.current = false; } else { mirror.current = caretForAcceptedValue(mirror.current, value); } @@ -337,14 +376,19 @@ export function useLineEdit( return true; }, insert: (text) => { + // Enforced here rather than at each call site: a bracketed paste arrives as + // its own event on the same still-registered handler, so a latch only the + // keyboard branch checks would let a paste trailing the step-leaving Enter + // edit a field the wizard has already left. + if (settledRef.current) return; const edited = insertAtCaret(mirror.current, text); if (edited !== mirror.current) commit(edited); }, settle: () => { - settled = true; + settledRef.current = true; }, get settled() { - return settled; + return settledRef.current; }, }; } From da4255e3ab8b0f8b4a774c57a4f9e14a365ad5ba Mon Sep 17 00:00:00 2001 From: chiga0 Date: Wed, 16 Sep 2026 18:22:36 +0800 Subject: [PATCH 39/51] fix(cli): mirror the question dialog's answers and lock a settled row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reads of an answer still came from rendered state, so a single stdin read that typed into a multi-select's free-text row and then submitted left that entry out, and one that answered a question and then submitted from the review tab left that answer out. Both now read a mirror written synchronously, with state kept as the fallback for the reads that run during render. The row a pending tab swap settled is still drawn and still owns the keys, so the rest of one read answered the same question again and quietly replaced the answer already recorded — a digit on the option branch, or a Space that a multi-select rebuilds its answer from at submit. Answers are locked while that swap is armed. Navigation is not: a manual arrow still calls the swap off and takes the cursor with it. --- .../src/ui/opentui/dialogs-confirm.test.tsx | 135 +++++++++++++++++- .../cli/src/ui/opentui/dialogs-confirm.tsx | 49 +++++-- 2 files changed, 171 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index fc43617a156..4470f5cad26 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -720,13 +720,21 @@ describe('OpenTuiToolConfirmation', () => { ); }); - it('answers the option an arrow reached in the same batch', () => { + it('answers the free-text row an arrow burst reached in the same read', () => { const onConfirm = vi.fn(async () => {}); mount(askDetails(twoOptions, onConfirm)); - pressBatched([{ name: 'down' }, { name: 'return', sequence: '\r' }]); + // Off the option list and onto the free-text row, then that row's own + // character and its Enter, all out of one stdin read: the submit has to + // follow the cursor the arrows moved, not the one this render drew. + pressBatched([ + { name: 'down' }, + { name: 'down' }, + { name: 'x', sequence: 'x' }, + { name: 'return', sequence: '\r' }, + ]); expect(onConfirm).toHaveBeenCalledWith( ToolConfirmationOutcome.ProceedOnce, - { answers: { '0': 'B' } }, + { answers: { '0': 'x' } }, ); }); @@ -905,6 +913,127 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('carries an answer given earlier in the same read into the submit that ends it', () => { + // The digit only queues the answer, so the Submit tab this same read walks + // to has to read it from the mirror: the render that armed the handler + // predates the burst and still reports the question unanswered. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + pressBatched([ + { name: '1', sequence: '1' }, + { name: 'right' }, + { name: 'right' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'staging' } }, + ); + }); + + it('counts a typed entry the same read checked and submitted', () => { + // Typing into a multi-select row is what ticks its box, and the tick is + // what submitAll asks for. Both land in one read here, so the flag has to + // be readable before the render that carries it. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + pressBatched([ + { name: 's', sequence: 's' }, + { name: 'm', sequence: 'm' }, + { name: 's', sequence: 's' }, + { name: 'return', sequence: '\r' }, + // Off the row before the arrow becomes a tab move, as above. + { name: 'up' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('keeps a submitted row closed to the read that follows the submit', () => { + // The submit's own re-render parks the dialog on the answered tab for the + // whole pause, with the row still mounted and still focused, so a latch + // that re-render clears hands the field back to the next read and lets it + // widen an answer already recorded. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('sms'); + press({ name: 'return', sequence: '\r' }); + act(() => { + vi.advanceTimersByTime(50); + }); + typeChars('Z'); + settleAdvance(); + expect(container.textContent ?? '').not.toContain('smsZ'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('keeps a digit trailing an answer in the same read from replacing it', () => { + // The field's own latch only reaches its insert branch. An arrow in the + // same read steps the cursor off the free-text row, so the digit behind it + // lands on the option branch and answers the question a second time while + // the pause still holds the row it settled on screen. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('xyz'); + pressBatched([ + { name: 'return', sequence: '\r' }, + { name: 'up' }, + { name: '1', sequence: '1' }, + { name: 'right' }, + { name: 'right' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'xyz' } }, + ); + }); + + it('holds a tick the same read trails a multi-select answer with', () => { + // A multi-select answer is rebuilt from its ticked boxes at submit, so a + // Space behind the Enter changes an answer already given: the pause still + // holds the question the Enter settled. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('sms'); + pressBatched([ + { name: 'return', sequence: '\r' }, + { name: 'up' }, + { name: 'space', sequence: ' ' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'mail, sms' } }, + ); + }); + it('drops the letters of a burst that moved to another tab', () => { // The free-text field re-seeds its buffer during render, so the letters // after a tab move in the same read would append to the question that diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 1ccb0fa8c0e..a3d05356a5b 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -651,7 +651,10 @@ function AskUserQuestionFlow(props: { const tabRef = useRef(0); const selectedRef = useRef(0); const checkedRef = useRef>({}); + const pickedRef = useRef>({}); + const typedCheckedRef = useRef>({}); const advanceTimer = useRef | null>(null); + const answeredTabRef = useRef(null); const { width } = useTerminalDimensions(); // Derived from the two indices alone so the keyboard handler can read them @@ -675,19 +678,25 @@ function AskUserQuestionFlow(props: { const typedValue = (idx: number) => typedRef.current[idx] ?? typed[idx] ?? ''; const checkedLabels = (idx: number) => checkedRef.current[idx] ?? checked[idx] ?? []; + // The state stays as the fallback because `answerFor` also runs during render, + // for the chip ✓ and the review list, where a ref-only read would report a + // question unanswered until the first write lands. + const pickedValue = (idx: number) => pickedRef.current[idx] ?? picked[idx]; + const typedCheckedFor = (idx: number) => + typedCheckedRef.current[idx] ?? typedChecked[idx] ?? false; const customValue = typedValue(tab); const isCustomAnswer = question !== undefined && !isMultiSelect && - picked[tab] !== undefined && - !question.options.some((option) => option.label === picked[tab]); + pickedValue(tab) !== undefined && + !question.options.some((option) => option.label === pickedValue(tab)); const answerFor = (idx: number): string | undefined => { const current = questions[idx]; - if (!current?.multiSelect) return picked[idx]; + if (!current?.multiSelect) return pickedValue(idx); const labels = [...checkedLabels(idx)]; const own = typedValue(idx).trim(); - if (typedChecked[idx] && own) labels.push(own); + if (typedCheckedFor(idx) && own) labels.push(own); return labels.length > 0 ? labels.join(', ') : undefined; }; @@ -710,12 +719,31 @@ function AskUserQuestionFlow(props: { setChecked((prev) => ({ ...prev, [idx]: next })); }; + // A multi-select box tracks whether its free-text entry counts, so typing into + // it checks the box and emptying it unchecks it again. + const markTypedChecked = (idx: number, on: boolean) => { + typedCheckedRef.current = { ...typedCheckedRef.current, [idx]: on }; + setTypedChecked((prev) => ({ ...prev, [idx]: on })); + }; + const cancelPendingAdvance = () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); + advanceTimer.current = null; + answeredTabRef.current = null; }; + // The tab the pending swap belongs to. While that swap is armed, the row it + // just settled is still drawn and still owns the keys, so the rest of one + // stdin read would answer the same question again and quietly replace the + // answer already recorded. Only the answer is locked, not the cursor: a manual + // ←/→ cancels the swap and this guard with it. + const answerIsLocked = () => + advanceTimer.current !== null && answeredTabRef.current === tabRef.current; + const selectAndAdvance = (value: string) => { + if (answerIsLocked()) return; const idx = tabRef.current; + pickedRef.current = { ...pickedRef.current, [idx]: value }; setPicked((prev) => ({ ...prev, [idx]: value })); if (!hasMultipleQuestions) { onAnswered({ [idx]: value }); @@ -728,7 +756,10 @@ function AskUserQuestionFlow(props: { // both timers fire and the question between them is skipped without ever // being drawn. cancelPendingAdvance(); + answeredTabRef.current = idx; advanceTimer.current = setTimeout(() => { + advanceTimer.current = null; + answeredTabRef.current = null; moveToTab(Math.min(tabRef.current + 1, totalTabs - 1)); moveToOption(0); }, 150); @@ -754,10 +785,8 @@ function AskUserQuestionFlow(props: { const idx = tabRef.current; typedRef.current = { ...typedRef.current, [idx]: next }; setTyped((prev) => ({ ...prev, [idx]: next })); - // A multi-select box tracks whether its free-text entry counts, so typing - // into it checks the box and emptying it unchecks it again. if (questions[idx]?.multiSelect === true) { - setTypedChecked((prev) => ({ ...prev, [idx]: next.trim().length > 0 })); + markTypedChecked(idx, next.trim().length > 0); } }; @@ -778,7 +807,7 @@ function AskUserQuestionFlow(props: { const value = current.trim(); const isMulti = questions[idx]?.multiSelect === true; if (isMulti) { - setTypedChecked((prev) => ({ ...prev, [idx]: value.length > 0 })); + markTypedChecked(idx, value.length > 0); } if (!value) return; const answer = isMulti ? multiAnswer(true, current) : value; @@ -898,7 +927,7 @@ function AskUserQuestionFlow(props: { return; } - if (original.name === 'space' && multi && asked) { + if (original.name === 'space' && multi && asked && !answerIsLocked()) { const option = asked.options[selectedRef.current]; if (option) toggleChecked(tabRef.current, option.label); return; @@ -912,7 +941,7 @@ function AskUserQuestionFlow(props: { } if (multi) { const idx = tabRef.current; - const answer = multiAnswer(typedChecked[idx] === true, typedValue(idx)); + const answer = multiAnswer(typedCheckedFor(idx), typedValue(idx)); if (answer !== undefined) selectAndAdvance(answer); return; } From e5f471bcd59711cd6060d42eef10a7057dea6279 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Wed, 16 Sep 2026 18:22:46 +0800 Subject: [PATCH 40/51] fix(cli): pause the loading row's timer while a call is parked The row already freezes its spinner and drops its suffix while a tool call waits on a confirmation, but its elapsed counter kept running on the active flag alone. Wiring the parked state out of that flag is the obvious fix and the wrong one: the shared timer zeroes what it has accumulated on a false-to-true edge, so a turn that had been running for half a minute reported itself as four seconds old once the user answered. It is handed the timer's separate paused flag instead, which holds the accumulated time and carries on from it. --- .../src/ui/opentui/opentui-footer.test.tsx | 30 ++++++++++++++++--- .../cli/src/ui/opentui/opentui-footer.tsx | 5 +++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/opentui/opentui-footer.test.tsx b/packages/cli/src/ui/opentui/opentui-footer.test.tsx index 0f375bae01f..29adcd2fa56 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.test.tsx @@ -142,9 +142,9 @@ describe('OpenTuiLoadingIndicator', () => { }); it("holds ink's static frame once a call is parked on a confirmation", () => { - const { container } = render( - , - ); + // Parked, not idle: the shell renders this row with the turn still in + // flight, so every tick source has to be off on `waiting` alone. + const { container } = render(); // The frame stays put because no tick exists to move it, not because the // next tick happens to draw the same glyph. expect(vi.getTimerCount()).toBe(0); @@ -157,11 +157,33 @@ describe('OpenTuiLoadingIndicator', () => { expect(spinnerCell(container)).toBe(WAITING_SPINNER_FRAME); }); + it('resumes the elapsed counter a parked call paused instead of restarting it', () => { + const { container, rerender } = render( + , + ); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(container.textContent).toContain('(3s'); + rerender(); + act(() => { + vi.advanceTimersByTime(30_000); + }); + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + // Four, not one: a pause that read as "inactive" would reset the accumulated + // elapsed on the way back, and the row would report a turn that has been + // running for half a minute as four seconds old. + expect(container.textContent).toContain('(4s'); + }); + it('keeps the waiting row but loses its phrase when loading phrases are off', () => { // ui.accessibility.enableLoadingPhrases: ink's Composer passes no phrase, // and the row stays so the waiting-row sequence is unchanged. const { container } = render( - , + , ); expect(container.textContent).not.toContain( 'Waiting for user confirmation', diff --git a/packages/cli/src/ui/opentui/opentui-footer.tsx b/packages/cli/src/ui/opentui/opentui-footer.tsx index cb27445971f..aed3a5e0197 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.tsx @@ -106,7 +106,10 @@ export function OpenTuiLoadingIndicator({ // The shared cycler resolves the phrase list for all nine locales and owns // the 15s rotation, so this renderer cannot drift from ink's. const phrase = usePhraseCycler(streaming, waiting); - const elapsedTime = useTimer(streaming, 0); + // Paused rather than deactivated while a call is parked: useTimer zeroes its + // accumulated elapsed on a false-to-true edge, so dropping `waiting` out of + // `isActive` would restart the counter when the parked call resumes. + const elapsedTime = useTimer(streaming, 0, waiting); const fallbackRef = useRef(0); const animatedChars = useAnimationFrame( streamingCharsRef ?? fallbackRef, From affcf80c1db5bbb59b77ecfd8d2b8e25936e6569 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Wed, 16 Sep 2026 18:22:54 +0800 Subject: [PATCH 41/51] docs(design): record the answer lock and the parked timer, correct the counts Adds the two behaviours this round landed to the parity sweep design in both languages, drops a follow-up entry the mirrors closed, and replaces coverage totals that had gone stale with counts a reader can re-measure against the suites. --- .../2026-09-10-opentui-parity-defect-sweep.md | 96 +++++++++++-------- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 89 ++++++++++------- 2 files changed, 111 insertions(+), 74 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 945c0f0c364..9e1f0108f95 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -565,8 +565,8 @@ recomputed on every render, because answering changes the marks. ink's overhead terms were taken line for line. The width they are subtracted from was carried over too, and a review round found that second number wrong: it belongs to the box ink draws the row in, and this row sits in another one, -which spends two columns of margin and one of padding on each side — the -padding already being one of the overheads above. Charging that padding twice +which spends two columns of margin on the left and one of padding on each side — +the padding already being one of the overheads above. Charging that padding twice and ink's transcript indent besides took eight columns off a row that spends four, so a header was ellipsized where the row still had room. That the corrected base is the row's real width, and not merely a larger number, was @@ -899,6 +899,18 @@ those did not pin was the timer itself — a row that went on ticking but kept printing the same frame passed — so the parked test now asserts that no interval is left running, which is the cost this decision measured on a machine. +The row's elapsed counter needed the same treatment, and the obvious wiring is +wrong. The shared timer zeroes what it has accumulated on a false-to-true edge of +its active flag, so handing it `streaming && !waiting` — which is what dropping the +suffix suggests — restarts the count when the parked call resumes, and a turn that +has been running for half a minute reports itself as four seconds old. It is handed +the timer's separate paused flag instead, which holds the accumulated time and +carries on from it. A test pins the difference: it parks a call three seconds in, +holds it for thirty, resumes it and reads four seconds one tick later, and the +wiring above fails that test with `(1s` where it asserts `(4s`. The row prints no +elapsed time while parked, as recorded above, so this is what the user sees once +they have answered rather than while the dialog is open. + Re-running the full matrix afterwards confirms it on the machine rather than in a test: eleven checkpoints taken while a dialog is parked each shed one row on both sides, from four divergent rows to three, and the rows left there are the @@ -1151,9 +1163,10 @@ The caret lands in the same column both ways, and what is displayed took this shape before the caret existed at all — ink showing a value its flow has already discarded is recorded as a follow-up rather than reproduced here. -Coverage is twenty-three new unit tests in three suites — thirteen of them the -model compared against ink, the rest the fields that use it — plus eleven -mutations, each failing the tests that own the behaviour taken away: a left +Coverage is the model's own suite — fifteen new tests in a file this branch adds +whole, each compared against ink — plus the tests of the fields that use it, counted +under Decisions 33 and 37, and eleven mutations, each failing the tests that own the +behaviour taken away: a left arrow that moves nothing, the context-window field's typing branch, the two end-of-line jumps, forward delete, a modified Delete that erases a character instead of passing through, ctrl+W, a row that stops re-mounting, a bare End @@ -1236,13 +1249,12 @@ field and tick a list row — the two branches are chosen by a focus the batch i cannot change — and a manual tab move puts the cursor on the first row, which is the free-text row only for a question with no predefined options, which the tool's own validation rejects. They are kept so that a handler has exactly one source for -each fact. Two siblings are recorded instead of fixed, both still read from state: -whether a multi-select's typed entry counts, so a read that types into the -free-text row, steps off it and submits leaves that entry out of the answer; and -the picked answers behind the review tab's submit, so a read that answers a -question, steps to the review tab and submits leaves that answer out. Each needs -three keystrokes in one read to show, which no scenario on either leg produces; -closing them adds one mirror each and joins the follow-ups. +each fact. Two siblings of the same family were closed rather than recorded: whether +a multi-select's typed entry counts, and the picked answers behind the review tab's +submit. Each took one mirror, and each is pinned by a test that puts the three +keystrokes in one read — type into a multi-select's free-text row, commit it, submit +from the review tab; answer a question, walk to the review tab, submit there. No +scenario on either leg produces that ordering, so both rest on the unit suite alone. This change stops at the handlers the round was asked about, and the class is wider than they are. Every other list in this renderer reads its cursor the same @@ -1257,13 +1269,16 @@ arrow moves one row per read instead of one per key. Closing it belongs to those widgets together rather than to either dialog fixed here, and is recorded under Follow-up. -Coverage is fourteen new unit tests, five in the authentication suite and nine -in the confirmation dialog's, and eleven mutations, nine of which fail the tests that -own the behaviour taken away: a line editor back to a render snapshot, an Enter +Coverage is sixteen new unit tests, five in the authentication suite and eleven +in the confirmation dialog's, and thirteen mutations, eleven of which fail the tests +that own the behaviour taken away: a line editor back to a render snapshot, an Enter submitting the prop value, the models step's check set read twice over (the tick and the submission) with its field's live text read at the submission as well, a question handler working from rendered state, the option reads of the Space -and Enter branches, and a burst that moves tab editing the question it left behind. +and Enter branches, a burst that moves tab editing the question it left behind, the +picked-answer mirror behind the review tab's submit — which without it fails the +answer-lock test of Decision 37 as well, since both read an answer the same burst +recorded — and the typed-entry mirror a multi-select's submit asks for. The two surviving mutations are the unreachable reads above. What no evidence reaches is a machine: the acceptance harness types one character per write on purpose, so a keystroke lands in its own read, and it never pastes @@ -1393,7 +1408,17 @@ whose auth failed a step later — is editable again on the next render. What stays un-latched is deliberate. The question dialog's tab arrows keep working inside the pause Decision 34 protects, because there the keystroke is the user's answer -to "which question am I on"; only the field stops taking keys, not the dialog. +to "which question am I on"; the field's latch stops the field, not the dialog around it. + +The dialog takes one stop of its own there, on answers rather than navigation, for as +long as a swap is armed. The field's +own latch reaches only its insert branch, and the row the armed swap settled is still +drawn and still owns the keys, so the rest of one read answered the same question a +second time and quietly replaced the answer already recorded — a digit on the option +branch, or a Space that a multi-select rebuilds its answer from at submit. Navigation is +left alone, and so is every key after the swap runs. The last question tab arms no swap +at all, clamped as it is, so it gets no lock; a trailing key there still lands on the row +it reaches. ink cannot reach this leak. Its buffer notifies the parent from an effect run at commit (`text-buffer.ts`), so a keystroke handled after the Enter never gets a commit where its @@ -1401,14 +1426,17 @@ step is still mounted, and its text never reaches the state the plan is built fr writes through the setter synchronously, which is what makes a mid-read submit read the live text at all, so it needs the explicit stop. -Coverage is five new unit tests, four in the authentication suite and one in the -confirmation dialog's, and five mutations (M15–M19). Each fails exactly the test that +Coverage is seven new unit tests, four in the authentication suite and three in the +confirmation dialog's, and seven mutations (M15–M21). Each fails exactly the test that owns the behaviour taken away: the endpoint and key field's guard, the guard armed on a -refused Enter as well, the model-ID step's guard, the advanced-config step's guard, and -the settled clause of the free-text row's burst-ownership guard. M2's anchor moved with -the line it rewrote. Nothing here was reached on a machine, for the reason recorded in -Decision 33: the acceptance harness writes one character per write and never pastes into -these dialogs. +refused Enter as well, the model-ID step's guard, the advanced-config step's guard, the +settled clause of the free-text row's burst-ownership guard, the answer lock's guard on +the answer path — which without it lets a trailing digit report `"staging"` over the +`"xyz"` the Enter it trailed had just recorded — and the same lock's clause on the Space +branch, which without it lets a trailing tick widen an answer already given. M2's anchor +moved with the line it rewrote. Nothing here was reached on a machine, for the reason +recorded in Decision 33: the acceptance harness writes one character per write and never +pastes into these dialogs. ## Decision 38 — the transcript region is not a focus target @@ -1598,11 +1626,12 @@ What was verified, and how far the verification reaches: at the suite's own five-second wait, all thirteen among the guarded ones. Six runs at this head gave five copies of that same set and one run twelve of its names, so the count moves with the machine while no failure lands outside the guarded - family. A one-variable control says this round's edit to the shared provider-setup - hook is not what does it: the same thirteen names failed, in the same list, with - that file back at the version it has on `origin/main` — which is also the version - this branch's commits leave it at, read off an empty diff against that ref. What - was not observed is a clean upstream checkout in this environment: a separate + family. A one-variable control says this round's edit to the shared + provider-setup hook is not what does it: the same thirteen names failed, in the + same list, with `packages/cli/src/ui/auth/useProviderSetupFlow.ts` — the one + file that edit touches, and the one this suite reaches — restored to its + `origin/main` version for the run, the tree verified byte-identical after it. + What was not observed is a clean upstream checkout in this environment: a separate worktree stops at the workspace build prerequisite before any test runs, so "these fail there too" is inferred from the arms above rather than measured. - **The thought toggle's keystroke is not covered at all.** The entry's own test @@ -1762,15 +1791,6 @@ What was verified, and how far the verification reaches: scope for a sweep that measures itself against ink's behaviour as it stands; it is recorded here so the divergence between the two renderers is not re-reported as a porting gap. -- Two reads of the answer still come from rendered state. A single stdin read that - types into a multi-select's free-text row, steps off it and submits can leave that - entry out of the answer, because the branch deciding whether the text counts - consults what the last render drew rather than a mirror; and one that answers a - question, steps to the review tab and submits can leave that answer out, for the - same reason on the picked set. Decision 33 mirrors the four pieces of state that - handler reads to place a keystroke; these are the fifth and sixth, recorded rather - than fixed because no scenario on either leg can put three keystrokes into one - read, so either change would ship unobservable. - The stale-cursor class is open everywhere except the two handlers Decision 33 touched. Read from the source: the shared select hook, the arena dialog's model list, the composer's completion rows, and the auth wizard's four list steps plus diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index a5b98cd4c4b..4e2067fc28f 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -419,13 +419,13 @@ chip、每个 chip 之间一道间隙、每个标题两列前缀,以及每个 ink 的那些开销项是逐行照搬的,而从中被减去它们的那个宽度也一并搬了过来;一轮评审发现 那第二个数字是错的:它属于 ink 画这一行所在的那个盒子,而这一行坐在另一个盒子里,后者 -每侧花掉两列外边距和一列内边距 —— 而内边距已经是上面那些开销之一。把那一列内边距算两 -次,再算上 ink 的 transcript 缩进,就从一个只花四列的行上扣掉了八列,于是标题在行里还 -有空间时就被省略号截断了。修正后的基数确实是这一行的真实宽度,而不只是一个更大的数字 -—— 这一点是拿一帧排好版的画面定下来的,而不是它当初所来自的那些 JSX prop:在五十八列 -的终端里放三个十二格的标题,这里会在盒子留下的 54 格里整行打印出 52 格,并且没有任何一 -行到达终端的最后一列。ink 在同样的宽度下把三个都省略号化了;那个区间记录在下面,而照着 -它做意味着裁掉这一行本来就放得下的文字。 +花掉两列外边距(只在左侧)与每侧一列内边距 —— 而内边距已经是上面那些开销之一。把那一 +列内边距算两次,再算上 ink 的 transcript 缩进,就从一个只花四列的行上扣掉了八列,于 +是标题在行里还有空间时就被省略号截断了。修正后的基数确实是这一行的真实宽度,而不只是 +一个更大的数字 —— 这一点是拿一帧排好版的画面定下来的,而不是它当初所来自的那些 JSX +prop:在五十八列的终端里放三个十二格的标题,这里会在盒子留下的 54 格里整行打印出 52 +格,并且没有任何一行到达终端的最后一列。ink 在同样的宽度下把三个都省略号化了;那个区 +间记录在下面,而照着它做意味着裁掉这一行本来就放得下的文字。 有两处分叉是有意的,而且性质不同。第一处是 ink 自己有、这个移植没有的缺陷。ink 对一次 按键把手打的自由文本答案提交两遍 —— 对话框自己的按键处理器与它挂载的输入控件都订阅 @@ -649,6 +649,15 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 身 —— 一条继续走时、却每次都打印同一帧的行是能通过的 —— 所以「挂住」那条现在还会断言 没有 interval 留在运行,那正是本决定在真机上量到的代价。 +这一行自己的耗时计数也需要同样的处置,而最顺手的接法是错的。共享的计时器会在自己那个 +「是否在跑」的旗标由假变真时把已累计的耗时清零,所以把它接成 `streaming && !waiting` +—— 那正是「丢掉后缀」所暗示的接法 —— 会在被挂住的调用恢复时从头开始计数,于是一个已经 +跑了半分钟的回合把自己报成四秒。改为把计时器另一个「暂停」旗标交给它,它保住已累计的时 +间并从那里继续。差别由一条测试钉住:让调用跑到第三秒时挂住、停三十秒、再恢复,一个节拍 +之后读到的是四秒;上面那种接法会让这条测试在断言 `(4s` 的地方失败于 `(1s`。挂住期间这一 +行根本不打印耗时,如上文所记,所以这影响的是用户回答之后看到的数字,而不是对话框开着的时 +候。 + 之后重跑全量矩阵,在真机上而不是在测试里确认了它:十一个在对话框挂住时取到的检查点, 每一处两侧各减掉一行,从四行分叉变成三行,那里剩下的行是决定 21 记录过的长路径折行断 点。总分叉行数从 221 行 ink 独有、265 行 opentui 独有,降到 210 与 254。逐字节一致的检 @@ -826,11 +835,12 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 还不存在时就是这个样子 —— ink 显示一个自己的流程已经作废的值,这一条记为后续工作, 没有照搬。 -覆盖是三个测试套件里新增的二十三条用例 —— 其中十三条是拿 ink 做对照的模型本身,其余是 -用它的字段 —— 外加十一个变异,每个都让真正拥有该行为的那几条测试失败:向左键不再移动、 -上下文窗口字段的输入分支、两个跳至行尾的绑定、向前删除、带修饰键的 Delete 擦掉一个字符 -而不是把键交还、ctrl+W、一行不再重新挂载、把裸 End 折回 ctrl+E 的那一跳、把那个遗留的删词 -字节交还给对话框、一行把粘贴带进来的那个换行画了出来,以及一个停在换行上的光标去高亮那个 +覆盖是模型自己那一套 —— 本分支整份新增的文件里十五条新用例,每条都拿 ink 做对照 —— 加 +上用它的字段那些测试,数目记在决定 33 与决定 37 下,外加十一个变异,每个都让真正拥有 +该行为的那几条测试失败:向左键不再移动、上下文窗口字段的输入分支、两个跳至行尾的绑定、 +向前删除、带修饰键的 Delete 擦掉一个字符而不是把键交还、ctrl+W、一行不再重新挂载、把裸 +End 折回 ctrl+E 的那一跳、把那个遗留的删词字节交还给对话框、一行把粘贴带进来的那个换行 +画了出来,以及一个停在换行上的光标去高亮那个 换行本身。实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问 一个问题,这样标签钳制和决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符, 光标往回退两格,再插入一个字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 @@ -885,11 +895,11 @@ getter 而不是快照,因为一个只捕获一次文本的消费者会把这 它所站着的那个标签;但一次突发不可能既编辑字段又勾选列表行 —— 这两个分支由焦点决定, 而焦点是这一批按键自身改变不了的 —— 并且一次手动切标签会把光标放到第一行,而第一行 只有在「没有任何预置选项的问题」上才是自由文本行,这恰恰被工具自己的校验拒绝。保留它 -们,是为了让处理函数对每一个事实只有一个来源。还有两个同胞是记录而不修,两处仍从 -state 取:一是多选里手输内容算不算数,所以一次读取里先在自由文本行输入、再移出该行、 -最后提交,就会把该内容留在答案之外;二是复核标签页提交时所取的那批已选答案,所以一次 -读取里先答一题、再切到复核标签页、最后提交,就会把那个答案留在之外。二者都要三个键落在 -同一次读取里才显出来,而两条腿上的任何场景都做不到;补上是各加一面镜像,已并入后续工作。 +们,是为了让处理函数对每一个事实只有一个来源。同一家族里还有两个同胞,这一轮是关掉而 +不是记录:一是多选里手输内容算不算数,二是复核标签页提交时所取的那批已选答案。两处各 +补一面镜像,并各有一条测试把那三个键放进同一次读取 —— 在多选的自由文本行输入、提交 +它、再从复核标签页提交;答一题、走到复核标签页、在那里提交。两条腿上的任何场景都排不 +出这个顺序,所以二者都只由单元测试套件兜着。 本轮停在被点名的那几个处理函数上,而这一类缺陷比它们宽。本渲染器里其余的列表都是用同 一种方式读自己的光标 —— 另有七个对话框文件挂载的共享选择 hook、竞技场对话框的模型列 @@ -900,11 +910,13 @@ state 取:一是多选里手输内容算不算数,所以一次读取里先 是一个键走一行。收口属于这些控件整体,而不属于本轮修的两个对话框,已记在「后续工作」 里。 -覆盖是十四条新单元测试,认证套件五条、确认对话框套件九条,外加十一个变异,其中九个会让 -真正拥有被拿走行为的那些测试失败:行编辑器退回一次渲染快照、Enter 提交 prop 的值、 -模型那一步的复选集合被读两次(勾选与提交各一次)并在提交处再读一次该字段的实时文本、 -提问处理函数按已渲染的 state 工作、Space 与 Enter 两个分支各自的选项读取、以及一次换了 -标签的突发去编辑它抛在身后的那一题。存活的那两个变异就是上面那些不可达的读取。没有任何 +覆盖是十六条新单元测试,认证套件五条、确认对话框套件十一条,外加十三个变异,其中十一 +个会让真正拥有被拿走行为的那些测试失败:行编辑器退回一次渲染快照、Enter 提交 prop 的 +值、模型那一步的复选集合被读两次(勾选与提交各一次)并在提交处再读一次该字段的实时文 +本、提问处理函数按已渲染的 state 工作、Space 与 Enter 两个分支各自的选项读取、一次换了 +标签的突发去编辑它抛在身后的那一题、复核标签页提交背后那面已选答案的镜像 —— 拿掉它还 +会一并让决定 37 那条答案锁的测试失败,因为二者读的都是同一次突发记下的那个答案 —— 以 +及多选提交所要的那面手输内容镜像。存活的那两个变异就是上面那些不可达的读取。没有任何 证据走到的是实机:验收 harness 故意每次写只打一个字符,于是一次按键落在自己那一次读取里, 而它从不向这些对话框粘贴 —— 这里每一个突发用例都只由单元测试套件兜着。 @@ -997,18 +1009,28 @@ ink 的规则去读了错的列表。ink 的标记指向的是「确认框就在 字段 —— 到下一次渲染就又能编辑了。 有意不闩的部分也写下来。提问对话框的左右切题键在决定 34 保护的那段暂停里照常工作,因为那里 -的按键就是用户自己对「我在答哪一题」的回答;停下来的是字段,不是对话框。 +的按键就是用户自己对「我在答哪一题」的回答;字段那把闩停住的是字段,不是它周围的对话框。 + +对话框在那段暂停里确实自己停了一处 —— 停的是答案而不是导航,时长就是一次换题被武装着的那 +段。字段自己的闩只够到它的插入分支,而被那次已武装的换题安顿下来的那一行仍在屏幕上、仍握着 +按键,于是一次读取里剩下的部分会把同一题再答一遍,并悄悄替换掉已经记下的答案 —— 一个落在 +选项分支上的数字,或者一个多选在提交时据以重拼答案的空格。导航原样不动,换题跑完之后的每个 +键也原样不动。最后一题那个标签根本不武装换题(它被钳住了),所以它也拿不到这把锁;跟在那里 +的一个按键仍旧落在它到达的那一行上。 ink 到不了这个漏。它的缓冲区是在提交时运行的 effect 里通知父级的(`text-buffer.ts`),所以 Enter 之后处理的按键永远等不到一次「自己那一步还挂载着」的提交,它的文本也就永远到不了组装 计划所依据的那份 state。我们这边是同步调用 setter 写回的 —— 这正是「读取中间的提交能读到 实时文本」的原因 —— 所以需要这个显式的停止。 -覆盖是五条新单元测试,认证套件四条、确认对话框套件一条,外加五个变异(M15–M19)。每个变异 +覆盖是七条新单元测试,认证套件四条、确认对话框套件三条,外加七个变异(M15–M21)。每个变异 恰好让真正拥有被拿走行为的那条测试失败:端点与密钥字段的守卫、把闩连被拒绝的 Enter 也武装上、 -模型 id 那一步的守卫、高级配置那一步的守卫、以及自由文本行突发归属守卫里的 settled 那一项。 -M2 的锚点随着它改写的那一行一起挪了。这里没有任何证据走到实机,原因记在决定 33:验收 harness -故意每次写只打一个字符,而且从不向这些对话框粘贴。 +模型 id 那一步的守卫、高级配置那一步的守卫、自由文本行突发归属守卫里的 settled 那一项、答案 +锁在答案路径上的那道守卫 —— 没有它,跟在后面的一个数字就会把刚刚由它所跟随的那个 Enter 记下 +的 `"xyz"` 报成 `"staging"` —— 以及同一把锁在空格分支上的那一项,没有它,跟在后面的一个勾选 +就会把一个早已给出的答案加宽。M2 的锚点随着它改写的那一行一起挪了。这里没有任何证 +据走到实机,原因记在决定 33:验收 harness 故意每次写只打一个字符,而且从不向这些对话 +框粘贴。 ## 决定 38 —— 转录区不在焦点链里 @@ -1146,9 +1168,10 @@ focusable,记在后续工作里。 里失败,每一条都卡在该套件自己的五秒等待上,十三条全都在被守卫的那一家里。本 head 上跑了六次,五次得到同一组名单,一次得到其中的十二条,所以随机器漂移的是条数,而不是 会落到被守卫那一家之外的失败。一个只变一个变量的对照说明,本轮对共享 provider-setup - hook 的改动不是原因:把那个文件放回它在 `origin/main` 上的版本 —— 本分支的提交也正好 - 没有动过它,这是对着那个 ref 的空 diff 直接读出来的 —— 失败的还是同样这十三条,列表 - 一字不差。没有观察到的是这个环境里一次干净的上游签出:独立的 worktree 在任何测试跑起 + hook 的改动不是原因:把 `packages/cli/src/ui/auth/useProviderSetupFlow.ts` —— 那次改动 + 唯一动到的文件,也是这套件会走到的那个 —— 放回它在 `origin/main` 上的版本再跑一次, + 跑完核验工作树逐字节还原,失败的还是同样这十三条,列表一字不差。没有观察到的是这个 + 环境里一次干净的上游签出:独立的 worktree 在任何测试跑起 来之前就会停在 workspace 构建前置条件上,所以「它们在那里也失败」是从上面两个臂推出来 的,不是量出来的。 - **思考开关的那次按键完全没有覆盖。** 入口自己的测试把键盘 hook 换成了空操作,所以 @@ -1259,12 +1282,6 @@ focusable,记在后续工作里。 画出来,而复核标签页把它报告为未回答。这是在真机上观察到的,不是推出来的。 决定 21 不跟随它,而在以 ink 现状行为度量自己的清扫里去修 ink 超出了范围; 记在这里,免得两个渲染器间的这处分歧被再次当成移植缺口上报。 -- 关于答案的两处读取仍然来自已渲染的 state。一次 stdin 读取里先在多选的自由文本 - 行输入、再移出该行、最后提交,就可能把那条内容留在答案之外,因为决定它算不算的那一 - 支看的是上一次渲染画了什么,而不是某面镜像;同样的一次读取里先答一题、再切到复核标签 - 页、最后提交,也可能把那个答案留在之外,原因落在已选集合上是一样的。决定 33 给那个 - 处理函数用来安放按键的四块 state 都配了镜像,这是第五块和第六块;它们被记录而不修, - 因为两条腿上的任何场景都没法把三个键放进同一次读取,改了也无法被观察到。 - 读到过期光标这一类缺陷,只在决定 33 动过的那两个处理函数里被关掉。其余读源码即可确认: 共享选择 hook、竞技场对话框的模型列表、输入框的补全行,以及认证向导的那四个列表步骤 加它的主、子菜单,全都是按上一次渲染画出的那一行作答。镜像的形状已经由提问流程和行 From 32433ec0ad95746749bad5bca879e5d8e0911afc Mon Sep 17 00:00:00 2001 From: chiga0 Date: Wed, 16 Sep 2026 18:33:05 +0800 Subject: [PATCH 42/51] docs(design): correct the ctrl+C actor set and the unported key families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up bullet claimed three handlers act on ctrl+C at once. The shell mounts five exclusive branches, so only the on-screen one joins the app-wide exit handler; the rewind overlay that binds the key has no importer. The key-family bullet also said every operation the four families need already exists, which is not true of undo/redo — it needs a history the line model does not keep. --- .../2026-09-10-opentui-parity-defect-sweep.md | 22 ++++++++++++++----- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 15 +++++++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 9e1f0108f95..df2d5b3fd80 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1838,9 +1838,13 @@ What was verified, and how far the verification reaches: - A dialog field ports the one-line slice of ink's buffer, not all of it. Four key families ink's text input binds are not ported: word jumps (ctrl/alt+←/→, alt+b/f), delete-word-right (alt+d, ctrl/alt+Delete), kill-line - (ctrl+k/ctrl+u) and undo/redo (ctrl+z). Every operation they need already exists - in this model, so it is one change over the line editor and its tests rather - than a per-dialog one. + (ctrl+k/ctrl+u) and undo/redo (ctrl+z). The first three need only operations + this model already holds, so they are one change over the line editor and its + tests rather than a per-dialog one; undo/redo also needs a history the model + does not keep, so it adds a store before it adds a binding. Two further ink + bindings are absent on purpose rather than missing: clear-input (ctrl+C), a key + the app-wide exit handler already acts on wherever a dialog owns the screen, and + open-external-editor (ctrl+X), which nothing in this renderer binds. - A dialog field's row windows its lines and not its columns. ink's field shows the line under the caret and, along it, the columns around the caret, so a value wider than the row scrolls sideways. This row hides the lines off the @@ -1862,9 +1866,15 @@ What was verified, and how far the verification reaches: - The two app-wide handlers disagree about claiming their key. The thought-toggle handler marks the keystroke consumed; the exit handler for ctrl+C and ctrl+D does not, so those keys also reach whatever else is mounted in the same read. - At this head nothing else acts on them — the shared line reducer hands ctrl+D - back unhandled — but that is a fact about the bindings that exist today, not a - guarantee the exit handler keeps. + On ctrl+D nothing else acts — the shared line reducer hands it back unhandled. + On ctrl+C the shell's five branches are exclusive — MCP approval, tool + confirmation, modal, dialog, composer — so only the one on screen joins the exit + handler in that read. The composer clears a non-empty buffer and claims the key + when it does; the settings dialog resets the row it is on and does not claim it, + so there one ctrl+C both resets that row and arms the exit window, and the next + one quits. A rewind overlay binds the same key but no file in the renderer + imports it. Giving the exit handler the claim the thought-toggle handler makes + is its own change. - Seven further pins were measured by review as not holding, and this pass did not re-run them. Deleting the shell's mount of the waiting row leaves every unit test green, because the indicator's own suite renders it directly; moving the queue diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 4e2067fc28f..ad9dad5839a 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -1315,8 +1315,11 @@ focusable,记在后续工作里。 那个数字来做,会裁掉这一行本来就放得下的文字。 - 对话框字段移植的是 ink 缓冲区里那一行宽的切片,不是全部。ink 文本输入绑定的四族键 没有移植:词跳转(ctrl/alt+←/→、alt+b/f)、向右删词(alt+d、ctrl/alt+Delete)、 - 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。它们需要的操作在这个模型里都已经存在, - 所以这是一次落在行编辑器与其测试上的改动,而不是逐个对话框的改动。 + 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。前三族需要的操作在这个模型里都已经存 + 在,所以那是一次落在行编辑器与其测试上的改动,而不是逐个对话框的改动;撤销重做还需要 + 一份这个模型并不保存的历史,所以它要先加一处存储,才谈得上加一个绑定。另有 ink 的两个 + 绑定是有意不在此列,而不是漏了:清空输入(ctrl+C),应用级的退出处理函数已经在任何对话 + 框占着屏幕时都对这个键动作;以及打开外部编辑器(ctrl+X),本渲染器里没有任何东西绑定它。 - 对话框字段的行开了「行」的窗,没开「列」的窗。ink 的字段画光标所在的那一行,并且在那 一行里画光标左右的那些列,所以比这一行更宽的值是横着滚过去的。这一行藏起了光标之外的 其他行,却仍然画出自己这一行的全部列,于是一个比这一行更长的值还是会把它折行 —— 无论 @@ -1333,8 +1336,12 @@ focusable,记在后续工作里。 ink 该修的缺陷。 - 两个应用级处理函数在「是否认领自己那个键」上不一致。展开思考的处理函数会把这次按键 标记为已消费,ctrl+C 与 ctrl+D 的退出处理函数不会,于是这两个键也会到达同一次读取里 - 挂载着的其他处理函数。在此 head 上没有别处对它们有动作 —— 共用的行归约器把 ctrl+D - 原样交回 —— 但这是今天这些绑定的事实,不是退出处理函数给出的保证。 + 挂载着的其他处理函数。ctrl+D 上没有别处动作 —— 共用的行归约器把它原样交回。ctrl+C 上, + shell 的五个分支是互斥的 —— MCP 审批、工具确认、模态、对话框、composer —— 所以只有屏幕上 + 那一个会和退出处理函数一起参与这次读取。composer 清空一个非空缓冲区,并且只在真的清空时 + 才认领这个键;设置对话框把它所在的那一行恢复默认,不认领,于是在那里按一次 ctrl+C 会同时 + 把那一行恢复默认并武装退出窗口,再按一次就退出。回退浮层绑定同一个键,但本渲染器里没有任何 + 文件引入它。让退出处理函数也做出展开思考那个处理函数所做的认领,是另一件独立的事。 - 另有七处钉定被评审以变异量出并不成立,本轮没有重跑它们。删掉 shell 上等待行的挂载, 所有单元测试仍然全绿,因为指示器自己的套件是直接渲染它的;把排队行移到 composer 下 面,那条以这一位置为题的测试仍然通过,因为它断言的是包含而非顺序;待处理卡片的探测 From 912c9d2a5636003a652e95a9c4e18aaa9f3cf4dd Mon Sep 17 00:00:00 2001 From: chiga0 Date: Wed, 16 Sep 2026 19:35:00 +0800 Subject: [PATCH 43/51] test(cli): pin the wiring and placement the review measured as free Every one of the six pins the last review round measured as not holding was an assertion that read what was rendered, so a change that rendered the same thing survived it. Each now asserts the placement, the wiring or the state instead, and each was re-measured by removing the behaviour it names. The round's remaining review suggestions are closed the same way: a digit on a multi-select tab moves the cursor rather than ticking the option it names, the burst that ends on another tab is checked for what it stored as well as what it drew, the loading-phrase setting is pinned at both of its mounts, the queued-call id and the arguments setting are read off the tree the entry point renders, ctrl+E is shown crossing a line break, and the finished-call term of the arrow gate has a witness of its own. The design document's coverage counts and its pin register follow, in both language versions. --- .../2026-09-10-opentui-parity-defect-sweep.md | 163 +++++++++++------- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 115 +++++++----- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 26 ++- .../src/ui/opentui/dialogs-confirm.test.tsx | 117 ++++++++++--- packages/cli/src/ui/opentui/line-edit.test.ts | 17 ++ packages/cli/src/ui/opentui/messages.test.tsx | 4 + .../src/ui/opentui/opentui-app-shell.test.tsx | 55 +++++- .../src/ui/opentui/start-opentui-ui.test.tsx | 50 +++++- .../src/ui/opentui/transcript-view.test.tsx | 33 +++- 9 files changed, 443 insertions(+), 137 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index df2d5b3fd80..19fbb43e728 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -593,8 +593,10 @@ where the tab index clamps and both legs arrive at the review tab together. The second divergence is one this port loses. The option rows are drawn by hand rather than by the shared select widget, and the widget's pointer wiring did not cross with them: clicking an option no longer answers it and hovering no longer -highlights it, while the outcome list a few rows below — still that widget — -responds to both. Keys answer every question ink's dialog answers, which is the +highlights it. The dialog draws no list that keeps it — this branch returns with +the question flow alone — so the comparison is with the sibling confirmation +types, whose outcome rows are still that widget and still answer a click. Keys +answer every question ink's dialog answers, which is the bar this sweep set, and the pointer is deferred to its own change rather than argued away. A click needs a meaning on the chips, on the free-text row and on the submit and cancel rows, each of which answers a different question; and the @@ -899,6 +901,15 @@ those did not pin was the timer itself — a row that went on ticking but kept printing the same frame passed — so the parked test now asserts that no interval is left running, which is the cost this decision measured on a machine. +The row's phrase also obeys the accessibility setting ink reads for its own +composer, and that gate has to reach both of the row's mounts — the composer's, +and the waiting row under a parked confirmation — since a setting that silenced +only one of them would keep rotating words on the other. Three tests pin it: the +phrase dropped at each mount when the setting is explicitly off, and both kept +when the settings say nothing, which is how ink reads an absent value. Inverting +the comparison fails both, and dropping the prop from either mount fails the test +that owns that mount. + The row's elapsed counter needed the same treatment, and the obvious wiring is wrong. The shared timer zeroes what it has accumulated on a false-to-true edge of its active flag, so handing it `streaming && !waiting` — which is what dropping the @@ -975,14 +986,16 @@ composer: three at a time, each collapsed to one line, an overflow row when more are waiting, and a hint naming the keys, shown the first three times the queue fills. -The port is ink's component row for row, hint included. What had blocked it was -the read rather than the render. The queue lives in a ref so that a turn can -drain it and put it back within one tick, and a ref is invisible to a render — -so the ref stays the synchronous source, and the five sites that move it each -copy it into state besides: the push, the drain, the restore for texts still -riding when a turn aborts, the pop for editing, and the transcript reset that -backs `/clear`, resume and branch. That last one is why the rows shed with a -cleared screen instead of outliving it. +The port is ink's component row for row, hint included, with one addition: a +queued row is stripped of terminal escapes before it is measured, which ink's +component leaves to the terminal. What had blocked it was the read rather than +the render. The queue lives in a ref so that a turn can drain it and put it back +within one tick, and a ref is invisible to a render — so the ref stays the +synchronous source, and the five sites that move it each copy it into state +besides: the push, the drain, the restore for texts still riding when a turn +aborts, the pop for editing, and the transcript reset that backs `/clear`, +resume and branch. That last one is why the rows shed with a cleared screen +instead of outliving it. This is the one decision here that no frame can evidence. Both renderers _steer_ a plain-Enter submission made mid-turn: the text is drained at the next sampling @@ -1014,21 +1027,24 @@ Keeping the rows through a confirmation would mean mounting them one level up, in the chrome box, at the price of setting them above the waiting row where ink draws them under the spinner. -Coverage is unit-level across three layers: five component tests (nothing when +Coverage is unit-level across three layers: six component tests (nothing when the queue is empty, one row per prompt with whitespace flattened, the three-row cap with its overflow row, the hint that shows on three fills and not the -fourth, a row cut to the terminal width minus ink's indent), the hook's -assertions on the queued texts at each of those sites, and a shell test that -fixes the placement — the rows share the non-scrolling chrome with the composer -instead of scrolling away with the conversation. Eleven mutations — one at each -component behaviour, one at each mirror site and one at the mount — each fail at -least one test, with one limitation: the mount is caught as containment and not -as order, because the shell test asserts the rows land in the chrome beside the -composer rather than which side of it. The five component rows fail their own and -no other; the mirror sites do not map one to one, because a list never given its -first entry, or never emptied, is still being asserted turns later: draining -without mirroring fails one assertion, pushing without it fails four, popping for -editing two. +fourth, a row cut to the terminal width minus ink's indent, and a row stripped +of terminal escapes before it reaches the screen), the hook's assertions on the +queued texts at each of those sites, and a shell test that fixes the placement — +the rows share the non-scrolling chrome with the composer instead of scrolling +away with the conversation, and sit above it rather than below. Eleven mutations — +one at each of the five behaviours the port copies from ink, one at each mirror +site and one at the mount — each fail at least one test, with one limitation: the +escape-stripping addition carries no mutation of its own. The mount's mutation is +the rows moved under the composer, which the placement test now catches by +ordering the two against each other in the chrome's own text. The five ported +component rows +fail their own and no other; the mirror sites do not map one to one, because a +list never given its first entry, or never emptied, is still being asserted +turns later: draining without mirroring fails one assertion, pushing without it +fails four, popping for editing two. ## Decision 31 — a model dialog outcome is recorded as well as shown @@ -1163,8 +1179,10 @@ The caret lands in the same column both ways, and what is displayed took this shape before the caret existed at all — ink showing a value its flow has already discarded is recorded as a follow-up rather than reproduced here. -Coverage is the model's own suite — fifteen new tests in a file this branch adds -whole, each compared against ink — plus the tests of the fields that use it, counted +Coverage is the model's own suite — sixteen new tests in a file this branch adds +whole, six of them replaying every keystroke through ink's own reducer as the +oracle and the rest asserting the model's states directly — plus the tests of the +fields that use it, counted under Decisions 33 and 37, and eleven mutations, each failing the tests that own the behaviour taken away: a left arrow that moves nothing, the context-window field's typing branch, the two @@ -1372,10 +1390,19 @@ marker rather than a guessed one. The new case asserts the arrow sits on the call the queue names when that is not the first pending row, and that exactly one arrow is drawn — it fails, one test in -a suite of seventeen, if the id is dropped and the transcript's order decides -again. Six -existing renders in that suite gained the id, because a marker that must come from -the queue cannot be asserted by a render that supplies none. On a machine the +a suite of eighteen, if the id is dropped and the transcript's order decides +again. A second case there pins the gate's other term: a card the queue still +names but whose call has finished draws no arrow, and deleting that term fails it, +where the sibling term was already pinned by a settled call's render. Every render +in that suite that asserts the marker supplies the id, because a marker that must +come from the queue cannot be asserted by a render that supplies none. The +wiring itself — the shell handing the queue's first id down — is pinned one level +up, by a case that mounts the tree the entry point renders and reads the props the +transcript receives, since every case in that suite mocks the transcript away; +deleting the wiring fails it. Dropping the queue from the render seam's dependency +array is the one leg no test can see, because a stale id renders identically +within a single render: the exhaustive-deps rule names that array, and CI's lint +lane allows no warnings. On a machine the ordering this needs — a re-armed call behind a waiting one — was not reproduced; the scenario that drove the arrow in the matrix has a single awaiting call, where both rules agree. @@ -1426,15 +1453,17 @@ step is still mounted, and its text never reaches the state the plan is built fr writes through the setter synchronously, which is what makes a mid-read submit read the live text at all, so it needs the explicit stop. -Coverage is seven new unit tests, four in the authentication suite and three in the -confirmation dialog's, and seven mutations (M15–M21). Each fails exactly the test that -owns the behaviour taken away: the endpoint and key field's guard, the guard armed on a -refused Enter as well, the model-ID step's guard, the advanced-config step's guard, the -settled clause of the free-text row's burst-ownership guard, the answer lock's guard on -the answer path — which without it lets a trailing digit report `"staging"` over the -`"xyz"` the Enter it trailed had just recorded — and the same lock's clause on the Space -branch, which without it lets a trailing tick widen an answer already given. M2's anchor -moved with the line it rewrote. Nothing here was reached on a machine, for the reason +Coverage is eight new unit tests, four in the authentication suite and four in the +confirmation dialog's, and eight mutations, one per behaviour named below. Each fails +exactly the test that owns the behaviour taken away: the endpoint and key field's guard, +the guard armed on a refused Enter as well, the model-ID step's guard, the +advanced-config step's guard, the settled clause of the free-text row's burst-ownership +guard, the single-select clause of the digit branch — which without it lets a digit +that only moves the cursor on a multi-select tick the option it names — and +the answer lock's guard on the answer path — which without it lets a trailing +digit report `"staging"` over the `"xyz"` the Enter it trailed had just recorded — and +the same lock's clause on the Space branch, which without it lets a trailing tick widen +an answer already given. Nothing here was reached on a machine, for the reason recorded in Decision 33: the acceptance harness writes one character per write and never pastes into these dialogs. @@ -1617,8 +1646,9 @@ What was verified, and how far the verification reaches: character per pty write on purpose, so a keystroke lands in a stdin read of its own, and it never pastes into a dialog input. Neither leg of the matrix can therefore produce the multi-key reads Decisions 33, 34 and 37 are about; those - rest on the two dialog suites they added tests to, and on the mutation record, - with no screen evidence behind any of them. + rest on the two dialog suites they added tests to, and on the mutations each of + those decisions names in its own coverage paragraph, with no screen evidence + behind any of them. - **ink's own auth-wizard suite fails locally, and this change is not shown to be why.** That file guards nineteen tests behind a check its own comment explains — simulated TUI input is unreliable on slow runners — and skips them on CI and on @@ -1696,9 +1726,18 @@ What was verified, and how far the verification reaches: - Every scroll region a dialog draws is still focusable, because that is the class default and only the transcript's was set aside. Whether a click inside a dialog's list can cost the open dialog a caret key the same way Decision 38 describes was - not measured; the lists are hit-tested surfaces of their own there, so the exposure - may be benign. Worth one arm per dialog before this renderer's mouse handling is - called done. + not measured, and the code gives no reason to expect a gentler answer: the rows + inside those five regions are hand-rolled and subscribe to no mouse event — only + the shared select widget does — so a click there is unhandled, and the focus walk + Decision 38 describes lands on the focusable region by the same route it took in + the transcript. What one arm per dialog would settle is whether a focused dialog + region then eats the dialog's own arrow and escape keys or only scrolls beside + them. Worth that arm before this renderer's mouse handling is called done. +- The question dialog's option rows are hand-drawn, so a click no longer answers + one and a hover no longer highlights it, while the outcome rows of the sibling + confirmation types — still the shared select widget — do both. Giving a click a + meaning on the chips, on the free-text row and on the submit and cancel rows is + its own change, as Decision 21 says; until then the dialog answers keys alone. - The `@` completion here asks only the file index. ink also completes sessions, MCP resources and extensions, and draws a category bar to switch between them. That is a feature gap rather than a parity defect and belongs @@ -1875,18 +1914,26 @@ What was verified, and how far the verification reaches: one quits. A rewind overlay binds the same key but no file in the renderer imports it. Giving the exit handler the claim the thought-toggle handler makes is its own change. -- Seven further pins were measured by review as not holding, and this pass did not - re-run them. Deleting the shell's mount of the waiting row leaves every unit test - green, because the indicator's own suite renders it directly; moving the queue - rows below the composer passes the test titled for that placement, which asserts - containment rather than order; the pending-card probe retuned from 46 rows to 48 - now equals the reserve sum, so the case no longer reaches the floor it is - titled for; an args-row placement assertion compares against a string its - fixture never renders; the arguments-row wiring is observed only by a case that - mocks both of its consumers to render nothing; the paste handler's guard - against a row that is not the free-text one is reached only by a case that - presses a digit first; and two branches carry no test at all — the model - dialog's custom-ID caret, and the line-join branch of delete-word-left. One - shape runs through them: the assertion reads what was rendered, so a change - that renders the same thing survives. That is one pass over those suites in the - other direction, not seven separate fixes. +- Six further pins were measured by review as not holding. This pass re-ran all + six and closed them, each against a mutation of its own. Deleting the shell's + mount of the waiting row now fails two of the phrase tests Decision 28 names, + where the indicator's own suite still renders it directly and saw nothing. + Moving the queue rows below the composer fails the placement test, which orders + the two against each other instead of only containing them together. The + args-row placement assertion no longer compares against a string its fixture + never renders, and moving that row above the header fails it. The + arguments-row wiring, and the queued-call id the shell hands down beside it, + are read off the tree the entry point renders, so deleting either fails the + case that mounts it. A paste arriving while the cursor is on an option row has + a case of its own, and dropping the guard that refuses it fails that one. The + two branches that carried no test at all — the model dialog's custom-ID caret + and the line-join branch of delete-word-left — each have one now, failing when + that caret is pinned to the value's start and when the join is taken out. One + shape ran through all six: the assertion read what was rendered, so a change + that rendered the same thing survived, and each closing was to assert the + placement, the wiring or the state instead of the picture. A seventh item the + same review raised was re-run and closed: the pending-card case had been + retuned to a height where + the viewport reserve and the description floor are the same five rows, so its + probe passed with the floor deleted. It now pins both heights, and deleting the + floor fails it. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index ad9dad5839a..a88c6b56ee0 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -437,13 +437,14 @@ prop:在五十八列的终端里放三个十二格的标题,这里会在盒 行自由文本行,那里 tab 下标会被钳住,两腿一起到达复核 tab。 第二处分叉是这个移植失去的东西。选项行是手动画出来的,而不是用共用的 select 控件,那个 -控件的指针接线没有跟着过来:点击一个选项不再作答,悬停也不再高亮,而几行之下的结果列表 -—— 仍然是那个控件 —— 两样都还响应。按键能回答 ink 这个对话框能回答的每一个问题,这正 -是本清扫设定的标准;指针因此被留给它自己的改动,而不是被论证掉。点击需要在 chip、自由文 -本行以及 submit 与 cancel 行上各有各的含义,而它们回答的是不同的问题;并且这些行刚经历过 -的那一类隐患同样是指针隐患 —— 一次落在同一次读取里的点击,只是换了来源的一串连击。这条 -也没有任何一腿在度量:工装每次 stdin 写入一个字符,从不发出鼠标序列;而报告这一条的那次 -运行在它自己的环境里起不动本渲染器。 +控件的指针接线没有跟着过来:点击一个选项不再作答,悬停也不再高亮。这个对话框里并没有一 +个还留着那个控件的列表 —— 这一分支只带着问题流程返回 —— 所以要比的是它的兄弟确认类型, +那些类型的结果行仍然是那个控件,仍然响应点击。按键能回答 ink 这个对话框能回答的每一个问 +题,这正是本清扫设定的标准;指针因此被留给它自己的改动,而不是被论证掉。点击需要在 +chip、自由文本行以及 submit 与 cancel 行上各有各的含义,而它们回答的是不同的问题;并且 +这些行刚经历过的那一类隐患同样是指针隐患 —— 一次落在同一次读取里的点击,只是换了来源 +的一串连击。这条也没有任何一腿在度量:工装每次 stdin 写入一个字符,从不发出鼠标序列; +而报告这一条的那次运行在它自己的环境里起不动本渲染器。 自由文本行把它的值保存在一个与状态更新并排、同步写入的镜像里。按键事件可能在一个 React 批次内到达,而一个用其渲染所捕获的值去构造下一个值的处理器,是在往一个已经过期 @@ -649,6 +650,12 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 身 —— 一条继续走时、却每次都打印同一帧的行是能通过的 —— 所以「挂住」那条现在还会断言 没有 interval 留在运行,那正是本决定在真机上量到的代价。 +这一行的短语也服从 ink 为自己 composer 读的那个无障碍设置,而这道门禁必须到达这一行的两处 +挂载 —— composer 的那一处,以及挂住的确认框下面等待行的那一处 —— 因为只让其中一处闭嘴的 +设置,会让另一处继续轮换词。三条测试钉住它:设置显式关掉时,两处挂载各自的短语都被丢掉; +设置什么都没说时,两处都保留,那正是 ink 对缺失值的读法。把这个比较反过来会让两条都失败, +从任一处挂载上摘掉这个 prop,会让拥有那一处的那条测试失败。 + 这一行自己的耗时计数也需要同样的处置,而最顺手的接法是错的。共享的计时器会在自己那个 「是否在跑」的旗标由假变真时把已累计的耗时清零,所以把它接成 `streaming && !waiting` —— 那正是「丢掉后缀」所暗示的接法 —— 会在被挂住的调用恢复时从头开始计数,于是一个已经 @@ -703,12 +710,12 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 —— 而排空就会清空队列。ink 改为把它们列在 composer 上方:一次三条、每条压成一行, 更多在等待时给一个溢出行,外加一条念出按键名字的提示,只在队列前几次被填满时显示。 -移植过来的是 ink 的那个组件,逐行照搬,提示也在内。此前挡住的是读取而不是渲染。队列 -住在一个 ref 里,好让一个回合能在一拍之内把它排空再放回去,而 ref 对一次渲染是不可见 +移植过来的是 ink 的那个组件,逐行照搬,提示也在内,只多加一样:排队的那一行在被量宽度 +之前会先被剥掉终端转义,而 ink 的组件把这件事交给终端。此前挡住的是读取而不是渲染。队 +列住在一个 ref 里,好让一个回合能在一拍之内把它排空再放回去,而 ref 对一次渲染是不可见 的 —— 于是 ref 仍是那个同步的真相来源,同时移动它的五处各自再往 state 里复制一份: 压入、排空、回合中止时把仍在搭载的文本放回去的恢复、供编辑的弹出,以及为 `/clear`、 -恢复会话与分支兜底的那次转录重置。最后这一处决定了这些行是随清屏一起脱落,而不是活过 -它。 +恢复会话与分支兜底的那次转录重置。最后这一处决定了这些行是随清屏一起脱落,而不是活过它。 这是本文档里唯一一件没有帧能作证的改动。两个渲染器都会把一个回合中按普通回车提交的文 本 **转向(steer)**:它在下一个采样边界被排空,作为真实的用户内容进入下一次模型请求, @@ -734,16 +741,18 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 一层的 chrome 盒子里,代价是它们会落在等待行之上,而 ink 把它们画在转圈提示 之下。 -覆盖是三层单元测试:组件的五条(队列为空时什么都不画、每条提示一行且空白被压平、三 +覆盖是三层单元测试:组件的六条(队列为空时什么都不画、每条提示一行且空白被压平、三 行的上限与它的溢出行、前三次填满时出现而第四次不再出现的提示、按终端宽度减掉 ink -缩进截断的一行),hook 在上述每一处对被排队文本的断言,以及一条钉住位置的 shell -测试 —— 这些行与 composer 一起落在那块不滚动的 chrome 里,而不是随对话滚走。十一个 -变异 —— 每个组件行为一处、每个镜像点一处、挂载处一处 —— 每个至少让 -一条测试失败,其中有一处局限:挂载是被作为「包含」而不是作为「顺序」抓住的,因为那条 -shell 测试断言的是这些行与 composer 一起落在 chrome 里,而不是在它的哪一边。五条组件 -行只让属于自己的那条失败;镜像点做不到一一对应,因为 -一个从未拿到第一条、或从未被清空的列表,之后好几个回合还在被断言:抽干时不镜像让一条 -断言失败,推入时不镜像让四条失败,弹出编辑让两条失败。 +缩进截断的一行,以及在到达屏幕之前被剥掉终端转义的一行),hook 在上述每一处对被排队 +文本的断言,以及一条钉住位置的 shell 测试 —— 这些行与 composer 一起落在那块不滚动的 +chrome 里,而不是随对话滚走,并且落在它的上面而不是下面。十一个变异 —— 从 ink 照搬的 +那五个行为各一处、每个镜像点一处、挂载处一处 —— 每个至少让一条测试失败,其中有一处局 +限:剥转义这一处加法没有属于它自己的变异。挂载那一处的变异是把排队行移到 composer 下面, +而那条钉住位置的测试现在会把两者在 chrome 自己的文本里排出先后,所以这次移动会让它变红。 +五条照搬来的组件行只让 +属于自己的那条失败;镜像点做不到一一对应,因为一个从未拿到第一条、或从未被清空的列表, +之后好几个回合还在被断言:抽干时不镜像让一条断言失败,推入时不镜像让四条失败,弹出编辑 +让两条失败。 ## 决定 31 —— 模型对话框的结果,既显示也记录 @@ -835,7 +844,8 @@ Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那 还不存在时就是这个样子 —— ink 显示一个自己的流程已经作废的值,这一条记为后续工作, 没有照搬。 -覆盖是模型自己那一套 —— 本分支整份新增的文件里十五条新用例,每条都拿 ink 做对照 —— 加 +覆盖是模型自己那一套 —— 本分支整份新增的文件里十六条新用例,其中六条把每一次按键都放回 +ink 自己的 reducer 做对照,其余直接断言模型的状态 —— 加 上用它的字段那些测试,数目记在决定 33 与决定 37 下,外加十一个变异,每个都让真正拥有 该行为的那几条测试失败:向左键不再移动、上下文窗口字段的输入分支、两个跳至行尾的绑定、 向前删除、带修饰键的 Delete 擦掉一个字符而不是把键交还、ctrl+W、一行不再重新挂载、把裸 @@ -982,9 +992,15 @@ ink 的规则去读了错的列表。ink 的标记指向的是「确认框就在 所以没有队列可查的调用方得到的是「没有标记」,而不是猜一个。 新用例断言:当队列点名的调用不是第一行 pending 时,箭头停在那个调用上,而且画出的箭头 -恰好一个 —— 把 id 丢掉、让转录的顺序重新做主,它就失败,一个十七条测试的套件里的一条。 -该套件里已有的六处渲染补上了这个 id,因为一个必须来自队列的标记,没法由一个不提供队列 -的渲染去断言。实机侧,这里需要的顺序 —— 被重新武装的调用排在等待中的那个之后 —— 没有 +恰好一个 —— 把 id 丢掉、让转录的顺序重新做主,它就失败,一个十八条测试的套件里的一条。 +该套件里另有一条钉住这道门禁的另一项:队列仍然点名、但调用已经完成的那张卡片不画箭头,删 +掉这一项会让它变红,而它的同门那一项早已被一处已结算调用的渲染钉住。该套件里所有断言这个 +标记的渲染都提供这个 id,因为一个必须来自队列的标记,没法由一个不提供队列的渲染去断言。接 +线本身 —— shell 把队列的第一个 id 传下来 —— 钉在上一层:由一条挂载入口点渲染出的那棵树、 +再读转录收到的 props 的用例负责,因为那个套件里每条用例都把转录 mock 掉了;删掉这条接线会 +让它变红。把队列从渲染接缝的依赖数组里摘掉,是唯一一条测试看不见的腿,因为一个过期的 id +在同一次渲染里画出来是一样的:exhaustive-deps 规则会点名那个数组,而 CI 的 lint 通道不允许 +任何 warning。实机侧,这里需要的顺序 —— 被重新武装的调用排在等待中的那个之后 —— 没有 复现;矩阵里驱动过箭头的那个场景只有一个待审批调用,此时两条规则一致。 ## 决定 37 —— 字段在自己那次读取提交之后就停止接键 @@ -1023,12 +1039,13 @@ Enter 之后处理的按键永远等不到一次「自己那一步还挂载着 计划所依据的那份 state。我们这边是同步调用 setter 写回的 —— 这正是「读取中间的提交能读到 实时文本」的原因 —— 所以需要这个显式的停止。 -覆盖是七条新单元测试,认证套件四条、确认对话框套件三条,外加七个变异(M15–M21)。每个变异 -恰好让真正拥有被拿走行为的那条测试失败:端点与密钥字段的守卫、把闩连被拒绝的 Enter 也武装上、 -模型 id 那一步的守卫、高级配置那一步的守卫、自由文本行突发归属守卫里的 settled 那一项、答案 -锁在答案路径上的那道守卫 —— 没有它,跟在后面的一个数字就会把刚刚由它所跟随的那个 Enter 记下 -的 `"xyz"` 报成 `"staging"` —— 以及同一把锁在空格分支上的那一项,没有它,跟在后面的一个勾选 -就会把一个早已给出的答案加宽。M2 的锚点随着它改写的那一行一起挪了。这里没有任何证 +覆盖是八条新单元测试,认证套件四条、确认对话框套件四条,外加八个变异,下面点名的每个行为 +对应一个。每个变异恰好让真正拥有被拿走行为的那条测试失败:端点与密钥字段的守卫、把闩连被 +拒绝的 Enter 也武装上、模型 id 那一步的守卫、高级配置那一步的守卫、自由文本行突发归属守卫 +里的 settled 那一项、数字分支上「单选」那一项 —— 没有它,一个在多选上只移动光标的数字就会 +把它点名的那个选项勾上 —— 以及答案锁在答案路径上的那道守卫 —— 没有它,跟在后面的一个数字就会把刚 +刚由它所跟随的那个 Enter 记下的 `"xyz"` 报成 `"staging"` —— 以及同一把锁在空格分支上的那 +一项,没有它,跟在后面的一个勾选就会把一个早已给出的答案加宽。这里没有任何证 据走到实机,原因记在决定 33:验收 harness 故意每次写只打一个字符,而且从不向这些对话 框粘贴。 @@ -1161,7 +1178,7 @@ focusable,记在后续工作里。 - **每一个突发用例都只有单元证据。** 验收 harness 故意每次写只打一个字符,所以一次 按键落在自己那一次 stdin 读取里,而它从不向对话框输入粘贴。因此矩阵的两条腿都产生 不了决定 33、34、37 所针对的多键读取;这些结论只靠它们新增用例的那两个对话框套件, - 以及变异记录,背后没有任何屏幕证据。 + 以及那几个决定各自在覆盖段落里点名的变异,背后没有任何屏幕证据。 - **ink 自己的认证向导套件在本地失败,而本轮改动没有被证明是原因。** 那个文件把十九条 测试藏在一个由它自己注释解释的检查之后 —— 模拟 TUI 输入在慢速 runner 上不可靠 —— 并在 CI 与 win32 上跳过它们,所以 CI 从不运行它们。在 CI 之外,这一家里有十三条在这 @@ -1216,9 +1233,16 @@ focusable,记在后续工作里。 的翻页键在这里什么都触及不到,折叠线以上的一行只能靠滚轮回来。把这些键绑到区域自己 的滚动偏移上是独立的改动。 - 对话框画的每一个滚动区仍然默认 focusable,因为那是类的默认值,被处理掉的只有转录这 - 一个。对话框开着时点进它的列表,会不会像决定 38 描述的那样让对话框丢掉光标键,没有 - 量过;那里的列表本就是各自做命中测试的表面,所以这个暴露可能是良性的。在这个渲染器 - 的鼠标处理被称作完成之前,值得为每个对话框补一臂。 + 一个。对话框开着时点进它的列表,会不会像决定 38 描述的那样让对话框丢掉光标键,没有量 + 过,而代码也没有给出期待一个更温和答案的理由:那五个区域里的行都是手工搭的,不订阅任何 + 鼠标事件 —— 只有共用的 select 控件订阅 —— 所以在那里点一下是无人处理的,决定 38 描述 + 的那次焦点上溯会沿它在转录里走过的同一条路落到那个 focusable 的区域上。为每个对话框补 + 一臂要定的,是一个被聚焦的对话框区域接着会不会吃掉对话框自己的方向键与 esc,还是只在旁 + 边滚动。在这个渲染器的鼠标处理被称作完成之前,值得补这一臂。 +- 问题对话框的选项行是手动画的,所以点击一个选项不再作答,悬停也不再高亮,而兄弟确认类 + 型的结果行 —— 仍然是那个共用的 select 控件 —— 两样都还响应。给 chip、自由文本行以及 + submit 与 cancel 行上的点击各找一个含义,是独立的改动,正如决定 21 所说;在那之前,这 + 个对话框只靠按键作答。 - 这里的 `@` 补全只问文件索引。ink 还会补全会话、MCP 资源与扩展,并画一个分类 栏用于切换。那是功能缺口而不是一致性缺陷,应放进独立的改动。 - 缺少搜索二进制时打印的 shell crawler 诊断现已有解释。渲染器库把全局 console @@ -1342,12 +1366,17 @@ focusable,记在后续工作里。 才认领这个键;设置对话框把它所在的那一行恢复默认,不认领,于是在那里按一次 ctrl+C 会同时 把那一行恢复默认并武装退出窗口,再按一次就退出。回退浮层绑定同一个键,但本渲染器里没有任何 文件引入它。让退出处理函数也做出展开思考那个处理函数所做的认领,是另一件独立的事。 -- 另有七处钉定被评审以变异量出并不成立,本轮没有重跑它们。删掉 shell 上等待行的挂载, - 所有单元测试仍然全绿,因为指示器自己的套件是直接渲染它的;把排队行移到 composer 下 - 面,那条以这一位置为题的测试仍然通过,因为它断言的是包含而非顺序;待处理卡片的探测 - 值从 46 行调到 48 之后正好等于预留值之和,于是该用例不再触及它标题所指的那个下限; - 参数行位置的断言对比的是它的 fixture 从不渲染的字符串;参数行的接线只被一条把它的两 - 个消费者都 mock 成什么都不画的用例观察到;粘贴处理里「非自由文本行」的守卫,唯一的 - 调用点会先按一个数字;还有两个分支根本没有测试 —— 模型对话框自定义 ID 字段的光标, - 以及向左删词的跨行合并分支。同一形状贯穿这七处:断言读的是渲染出来的结果,所以渲染 - 结果不变的改动能活下来。修它们是朝另一个方向过一遍这些套件,而不是七处各自修一次。 +- 另有六处钉定被评审以变异量出并不成立。本轮把这六处都重跑并闭合了,每一处都对着属于 + 它自己的一个变异。删掉 shell 上等待行的挂载,现在会让决定 28 点名的两条短语测试失败 —— + 指示器自己的套件仍然直接渲染它,什么也看不见。把排队行移到 composer 下面,那条钉住位 + 置的测试会失败,因为它现在把两者排出先后,而不只是断言它们落在一起。参数行位置的断言 + 不再对比它的 fixture 从不渲染的字符串,把那一行移到表头上面会让它变红。参数行的接线, + 以及 shell 一并传下去的那个待答调用 id,现在由挂载入口点渲染出的那棵树读出来,删掉任 + 一条都会让那条用例变红。光标停在选项行上时到达的一次粘贴有了自己的用例,摘掉拒绝它的 + 那道守卫会让那条失败。两个根本没有测试的分支 —— 模型对话框自定义 ID 字段的光标,以及 + 向左删词的跨行合并分支 —— 现在各有一条:把那个光标钉死在值的开头、或把跨行合并取走, + 各自会让它们变红。同一形状贯穿这六处:断言读的是渲染出来的结果,所以渲染结果不变的改 + 动能活下来;每一次闭合都是改为断言位置、接线或状态,而不是断言画面。同一轮评审提出的 + 第七处已经重跑并闭合:待处理卡片那个用例曾被调到一个高度,在那里视口预留值与描述下限 + 是同样的五行,于是删掉那个下限它的探测值仍然通过。它现在同时钉住两个高度,删掉下限会 + 让它变红。 diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index ebeef3db4fa..204bc8d1267 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -789,6 +789,18 @@ describe('caret editing in dialog text fields (#107)', () => { expect(focusedField()).toEqual({ text: 'abcdXef', cell: 'e' }); }); + it('draws the model step’s custom-ID field with its own caret', async () => { + await runToBaseUrlStep(); + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models, custom-ID input focused + await typeText('ab'); + await press('left'); + await typeText('X'); + expect(focusedField()).toEqual({ text: 'aXb', cell: 'b' }); + }); + it('keeps the caret at either end for ctrl+A and ctrl+E', async () => { await runToBaseUrlStep(); await typeText('ab'); @@ -825,12 +837,20 @@ describe('caret editing in dialog text fields (#107)', () => { it('sends ctrl+E to the end of a pasted value and bare End to its own line', async () => { await runToBaseUrlStep(); await pasteText('https://one.test\nhttps://two.test'); + // The paste leaves the caret at the value's end, and ctrl+A is ink's line + // home, so the row drawn is the second line and the caret opens it. await press('a', { ctrl: true, sequence: '\x01' }); - expect(focusedField().cell).toBe('h'); + expect(focusedField()).toEqual({ text: 'https://two.test', cell: 'h' }); + // Walk back over the break: the offset just past it belongs to the first + // line's end, so two lefts park the caret inside that line. + await press('left'); + await press('left'); + expect(focusedField()).toEqual({ text: 'https://one.test', cell: 't' }); await press('e', { ctrl: true, sequence: '\x05' }); await typeText('s'); - // ink's ctrl+E binding is the end of the value, not of the caret's line. - // The jump lands on the second line, so that is the line the row draws. + // ink's ctrl+E binding is the end of the value, not of the caret's line, so + // from the first line it crosses the break: the row switches and the + // character lands on the second line rather than before the break. expect(focusedField()).toEqual({ text: 'https://two.tests ', cell: ' ' }); // The bare key is ink's reducer move, which stops at the line the caret is // on. Park the caret inside the first line — ctrl+A after the ctrl+E jump diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 4470f5cad26..004eb5309cf 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -516,31 +516,31 @@ describe('OpenTuiToolConfirmation', () => { vi.useRealTimers(); }); - it('fits headers that fill the chip row, and still clips ones that cannot fit', () => { - const wide = (tag: string) => tag.padEnd(30, '-'); - const twoWideHeaders = ( - onConfirm: ( - outcome: ToolConfirmationOutcome, - payload?: ToolConfirmationPayload, - ) => Promise, - ): ToolCallConfirmationDetails => ({ - type: 'ask_user_question', - title: 'Two questions', - questions: [ - { - question: 'Pick a deploy target?', - header: wide('deploy'), - options: [{ label: 'staging', description: 'the staging target' }], - }, - { - question: 'Pick a region?', - header: wide('region'), - options: [{ label: 'eu', description: 'the eu region' }], - }, - ], - onConfirm, - }); + const wide = (tag: string) => tag.padEnd(30, '-'); + const twoWideHeaders = ( + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, + ): ToolCallConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Two questions', + questions: [ + { + question: 'Pick a deploy target?', + header: wide('deploy'), + options: [{ label: 'staging', description: 'the staging target' }], + }, + { + question: 'Pick a region?', + header: wide('region'), + options: [{ label: 'eu', description: 'the eu region' }], + }, + ], + onConfirm, + }); + it('fits headers that fill the chip row, and still clips ones that cannot fit', () => { // Eighty columns leaves this row room for both headers, and it is this arm // that fails when ink's transcript indent is charged to it as well. mocks.state.dimensions = { width: 80, height: 40 }; @@ -548,10 +548,36 @@ describe('OpenTuiToolConfirmation', () => { expect(room).toContain(wide('deploy')); expect(room).toContain(wide('region')); - // Forty columns less leaves no room, so the cap still bites. + // Forty columns less leaves no room, so the cap still bites — and what + // survives of the header is still on the row, which is what tells a + // clipped chip apart from a row that drew no header at all. mocks.state.dimensions = { width: 40, height: 40 }; const tight = mount(twoWideHeaders(async () => {})).textContent ?? ''; expect(tight).not.toContain(wide('deploy')); + expect(tight).toContain('deploy'); + }); + + it('re-fits the chip row once its answered headers carry the mark', () => { + // Each answered header gains a " ✓" that the row's own budget pays for, so + // the row that fit both headers whole while they went unanswered has to + // clip once both are answered. Measured at the width the arm above passes + // at, so the only thing that moved is the answer count. + mocks.state.dimensions = { width: 80, height: 40 }; + const container = mount(twoWideHeaders(async () => {})); + expect(container.textContent ?? '').toContain(wide('deploy')); + + press({ name: '1', sequence: '1' }); + settleAdvance(); + press({ name: '1', sequence: '1' }); + settleAdvance(); + + // Answering the second question lands on the Submit tab, whose review + // list prints each header in full, so the row under measurement is the + // text ahead of that list rather than the whole container. + const chipRow = (container.textContent ?? '').split('Your answers:')[0]; + expect(chipRow).toContain('✓'); + expect(chipRow).toContain('deploy'); + expect(chipRow).not.toContain(wide('deploy')); }); it('numbers each option and renders its description and the free-text row', () => { @@ -611,6 +637,18 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('drops a paste that arrives while the cursor was on an option row', () => { + // The composer is unmounted while this dialog owns the screen, so a + // bracketed paste has nowhere else to go — but a row the cursor is not on + // takes no key, and a paste is no different from one. + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + paste('stray'); + press({ name: '3', sequence: '3' }); + expect(container.textContent ?? '').toContain('> '); + expect(container.textContent ?? '').not.toContain('stray'); + }); + it('keeps a pasted newline out of the row and inside the answer', () => { const onConfirm = vi.fn(async () => {}); const container = mount(askDetails(twoOptions, onConfirm)); @@ -1009,6 +1047,24 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('moves the cursor with a digit on a multi-select tab instead of ticking it', () => { + // On a single-select the digit commits the option it names. On a + // multi-select it stays ink's cursor move: an answer there is given by + // Space or by the free-text row, so a digit that also ticked would hand one + // over that no key asked for. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: '2', sequence: '2' }); + expect(container.textContent ?? '').toContain('❯ [ ] 2. chat'); + expect(container.textContent ?? '').not.toContain('[✓]'); + settleAdvance(); + // Nothing was answered, so the pause has no tab to swap to. + expect(container.textContent ?? '').toContain('Pick channels?'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + it('holds a tick the same read trails a multi-select answer with', () => { // A multi-select answer is rebuilt from its ticked boxes at submit, so a // Space behind the Enter changes an answer already given: the pause still @@ -1053,13 +1109,22 @@ describe('OpenTuiToolConfirmation', () => { { name: 'x', sequence: 'x' }, ]); expect(container.textContent ?? '').not.toContain('abx'); + // The row the burst ended on belongs to the tab it reached, and '> ' is + // drawn only by that field, so this is that field and not an option row. + expect(container.textContent ?? '').not.toContain('> x'); press({ name: 'return', sequence: '\r' }); - expect(onConfirm).not.toHaveBeenCalled(); press({ name: 'up' }); press({ name: 'left' }); press({ name: 'down' }); press({ name: 'down' }); expect(container.textContent ?? '').toContain('> ab'); + // The storage half: the burst's Enter answered nothing, so the review tab + // still lists the question the burst landed on as open. + press({ name: 'up' }); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'right' }); + expect(container.textContent ?? '').toContain('Region: (not answered)'); }); it('reviews every answer on the Submit tab and cancels from its second row', () => { diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 555971d204e..9c46db3c8bf 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -198,6 +198,23 @@ describe('line-edit', () => { ]); }); + it('agrees with ink when delete-word-left meets a line break at column 0', () => { + // The caret sits after the break and before its own line's first + // character, so that line holds no word to its left: the key joins the + // lines instead, which is ink's own fallback. At the value's start there is + // no break to join either, and the key deletes nothing. + agreesWithInk('one\ntwo', [stepLeft, stepLeft, stepLeft, eraseWord]); + agreesWithInk('one\ntwo', [moveHome, eraseWord]); + expect(deleteWordLeftAtCaret({ text: 'one\ntwo', cursor: 4 })).toEqual({ + text: 'onetwo', + cursor: 3, + }); + expect(deleteWordLeftAtCaret({ text: 'one\ntwo', cursor: 0 })).toEqual({ + text: 'one\ntwo', + cursor: 0, + }); + }); + it('follows ink in jumping ctrl+E past a line break, Home and bare End only to their own', () => { expect(moveCaretEnd({ text: 'ab\ncd', cursor: 0 })).toEqual({ text: 'ab\ncd', diff --git a/packages/cli/src/ui/opentui/messages.test.tsx b/packages/cli/src/ui/opentui/messages.test.tsx index 6cb286cdd60..b609cd48e38 100644 --- a/packages/cli/src/ui/opentui/messages.test.tsx +++ b/packages/cli/src/ui/opentui/messages.test.tsx @@ -186,7 +186,11 @@ describe('long-content caps (ink MaxSizedBox parity)', () => { it('falls back to the settled cap on short terminals', () => { expect(pendingCardMaxRows(24, 3900, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); + // At 48 the viewport reserve and the floor are the same 5 rows, so this + // probe also passes with the floor deleted. At 46 the inner bound is 3 and + // only the floor lifts it, which is what this one pins. expect(pendingCardMaxRows(48, 0, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); + expect(pendingCardMaxRows(46, 0, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); }); it('keeps everything when the content fits', () => { diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index 273a01b0bae..a522c087778 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -364,6 +364,51 @@ describe('OpenTuiApp shell wiring', () => { expect(mocks.state.loadingProps?.['streaming']).toBe(true); }); + // ink's Composer drops the phrase, not the row, when this setting is off, so + // the gate has to reach both mounts of the indicator: the composer's and the + // waiting row under a parked confirmation. + function phraseConfig(enableLoadingPhrases: boolean): Config { + return { + getContextFilePaths: () => [], + getAccessibility: () => ({ enableLoadingPhrases }), + } as unknown as Config; + } + + const parkedCall = { + callId: 'call-1', + name: 'run_shell_command', + confirmationDetails: { type: 'info', title: 'ok?' }, + } as never; + + it('drops the composer’s loading phrase when the setting is off', async () => { + renderApp({ config: phraseConfig(false) }); + await settle(); + expect(screen.getByText('input-prompt')).toBeTruthy(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(false); + }); + + it('drops the parked call’s loading phrase when the setting is off', async () => { + renderApp({ + config: phraseConfig(false), + waitingToolCalls: [parkedCall], + }); + await settle(); + expect(screen.getByText('tool-confirm')).toBeTruthy(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(false); + }); + + it('keeps both loading phrases when the setting says nothing', async () => { + renderApp({ waitingToolCalls: [parkedCall] }); + await settle(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(true); + + mocks.state.loadingProps = null; + renderApp(); + await settle(); + expect(screen.getByText('input-prompt')).toBeTruthy(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(true); + }); + it('hides the footer while the composer’s completion list is open', async () => { renderApp(); await settle(); @@ -2025,8 +2070,14 @@ describe('OpenTuiApp transcript scroll region', () => { }); await settle(); const { region, chrome } = readLayout(); - expect(chrome?.textContent).toContain('QUEUE_ROW_MARKER'); - expect(chrome?.textContent).toContain('input-prompt'); + const chromeText = chrome?.textContent ?? ''; + expect(chromeText).toContain('QUEUE_ROW_MARKER'); + expect(chromeText).toContain('input-prompt'); + // The title says "above", so order it and not just contain it: text runs in + // document order, so the queue row's offset is its placement. + expect(chromeText.indexOf('QUEUE_ROW_MARKER')).toBeLessThan( + chromeText.indexOf('input-prompt'), + ); expect(region?.textContent).not.toContain('QUEUE_ROW_MARKER'); }); }); diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx index b73d6d4fb8b..110c2aaf1c0 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx @@ -3,6 +3,7 @@ * Copyright 2026 Qwen * SPDX-License-Identifier: Apache-2.0 */ +// @vitest-environment jsdom /** * Fallback-contract tests for the OpenTUI entry (Batch 6): startup must @@ -36,6 +37,8 @@ const mocks = vi.hoisted(() => { * constructor installing `globalThis.window`. */ bootOrder: [] as string[], warmupRejects: false, + waitingCalls: [] as Array<{ callId: string }>, + appProps: null as { renderMain?: () => unknown } | null, }; async function buildJsxRuntime() { const React = await import('react'); @@ -96,7 +99,12 @@ vi.mock('./opentui-runtime.js', () => ({ create: vi.fn(() => mocks.state.runtime), }, })); -vi.mock('./opentui-app-shell.js', () => ({ OpenTuiApp: () => null })); +vi.mock('./opentui-app-shell.js', () => ({ + OpenTuiApp: (props: { renderMain?: () => unknown }) => { + mocks.state.appProps = props; + return null; + }, +})); vi.mock('./transcript-view.js', () => ({ OpenTuiTranscriptView: () => null, })); @@ -106,7 +114,7 @@ vi.mock('./live-turn.js', () => ({ streaming: false, streamingCharsRef: { current: 0 }, isReceivingContent: false, - waitingCalls: [], + waitingCalls: mocks.state.waitingCalls, messageQueue: [], popQueue: () => null, submit: () => {}, @@ -152,6 +160,8 @@ vi.mock('./resume-session.js', () => ({ resumeEventsFromConfig: () => null, })); +import type { ReactElement } from 'react'; +import { render } from '@testing-library/react'; import { startOpenTuiUI } from './start-opentui-ui.js'; import { createCliRenderer } from '@opentui/core'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -163,6 +173,8 @@ function buildConfig(authType: 'qwen-oauth' | 'none' = 'qwen-oauth'): Config { getSessionId: () => 'test-session-id', getTargetDir: () => '/tmp/project', getApprovalMode: () => 'default', + isInteractive: () => false, + getSdkMode: () => false, getAuthType: () => (authType === 'none' ? undefined : authType), getChatRecordingService: () => null, isTelemetryInitializationDeferred: () => false, @@ -185,6 +197,8 @@ describe('startOpenTuiUI fallback contract', () => { mocks.state.stderrLines = []; mocks.state.bootOrder = []; mocks.state.warmupRejects = false; + mocks.state.waitingCalls = []; + mocks.state.appProps = null; mocks.state.renderer.destroy.mockClear(); mocks.state.root.unmount.mockClear(); mocks.state.root.render.mockClear(); @@ -297,6 +311,38 @@ describe('startOpenTuiUI fallback contract', () => { expect(renderedInitialDialog()).toBeNull(); }); + // The transcript itself is mocked away, so the two props Decision 36 adds are + // read off the element the entry's own render seam returns: they are the only + // production wiring of the parked-call marker and of the args setting. Both + // are computed inside the entry component, which is why this case mounts the + // captured tree — and why this file runs in jsdom — while every other case + // here reads that tree's elements without executing them. + it('threads the parked call and the args setting into the transcript', async () => { + mocks.state.waitingCalls = [{ callId: 'q1' }, { callId: 'q2' }]; + const started = await startOpenTuiUI( + buildConfig(), + { + merged: { ui: { hideWindowTitle: true, showToolCallArgs: true } }, + } as unknown as LoadedSettings, + [], + '/tmp/project', + {} as InitializationResult, + ); + expect(started).toBe(true); + + const calls = mocks.state.root.render.mock.calls; + render(calls[calls.length - 1]?.[0] as ReactElement); + // renderMain draws the transcript inside a box, and the mocked JSX + // runtime turns that box into a plain element, so the transcript's own + // props sit one level below the returned element. + const main = mocks.state.appProps?.renderMain?.() as + | { props?: { children?: { props?: Record } } } + | undefined; + const props = main?.props?.children?.props; + expect(props?.['awaitingCallId']).toBe('q1'); + expect(props?.['showToolCallArgs']).toBe(true); + }); + it('warms the shell AST parser before the renderer is created', async () => { expect( await startOpenTuiUI( diff --git a/packages/cli/src/ui/opentui/transcript-view.test.tsx b/packages/cli/src/ui/opentui/transcript-view.test.tsx index c228dcf8322..2184c7242ca 100644 --- a/packages/cli/src/ui/opentui/transcript-view.test.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.test.tsx @@ -254,13 +254,19 @@ describe('OpenTuiTranscriptView', () => { it('draws the raw JSON on its own line under the header', () => { const { container } = render( - , + , ); const text = container.textContent ?? ''; expect(text).toContain('{"file_path":"/x","content":"ARGS_ROW_MARKER"}'); - // The row follows the header it belongs to, not the card body. + // The row follows the header it belongs to, not the card body. The header + // is named by its own marker: the tool name this fixture passes is mapped + // to a display name before it is drawn, so ordering against it would + // compare two absent strings. expect(text.indexOf('ARGS_ROW_MARKER')).toBeGreaterThan( - text.indexOf('write_file'), + text.indexOf('HEADER_ROW_MARKER'), ); }); @@ -380,6 +386,27 @@ describe('OpenTuiTranscriptView', () => { expect(text.indexOf('←')).toBeGreaterThan(text.indexOf('echo two')); }); + it('draws no arrow on a card whose call has finished', () => { + // The marker needs the card to be a pending, unfinished tool row, not only + // the one the queue names: a call that has already run draws no arrow even + // while its card still reads pending. Every other case here leaves `done` + // at the helper's false, so this is that term's only witness. + const { container } = render( + , + ); + expect(container.textContent).not.toContain('←'); + expect(container.textContent).toContain('echo done'); + }); + it('renders the ! shell row with the ink $ prefix', () => { const { container } = render( Date: Wed, 16 Sep 2026 19:51:05 +0800 Subject: [PATCH 44/51] test(cli): witness the ctrl+E routing the review measured as free Every fixture that pressed ctrl+E held a value with no line break, where the buffer-end jump and the line-end jump land in the same column, so routing the key to the line-scoped move survived. Press both keys through the routing on a value that holds a break with the caret off its last line. --- .../design/2026-09-10-opentui-parity-defect-sweep.md | 7 ++++++- .../2026-09-10-opentui-parity-defect-sweep.zh-CN.md | 5 ++++- packages/cli/src/ui/opentui/line-edit.test.ts | 12 ++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 19fbb43e728..e98beab0b0e 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1128,7 +1128,12 @@ because ink's line is a _visual_ row and a value wide enough to wrap splits it, where this model has no wrapping to consult. Coverage is one differential step beside the ctrl+E one, the wizard's base-URL case pasting a two-line value and landing a character inside its first line, and a mutation that returns the bare -key to the value-end jump — it fails both. +key to the value-end jump — it fails both. The split's other direction went +unwitnessed for a round: routing ctrl+E to the line-scoped move survived, +because every fixture pressing that key held a value with no break in it, where +the two keys land in the same column and the routing cannot be told from its +neighbour. A case now presses both keys through the routing on a value that +holds a break with the caret off its last line, and that mutation fails it. A second review-round finding was a spelling rather than a behaviour: the key that erases the word left of the caret. ink binds ctrl+W, and this port erases diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index a88c6b56ee0..fee39ef769c 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -809,7 +809,10 @@ invocation 阶段产出一行,而这是一条 result 阶段;ink 的重放读 前提是 ink 的那一行是「可视」行,宽到需要折行的值会把它再切开,而这个模型没有折行可查。 覆盖是在 ctrl+E 那一步旁边补上裸键的差分一步,加上向导里 base-URL 字段粘贴一个两行值、把 字符落回第一行之内的用例,以及一个把裸键送回「跳到值尾」的变异 —— 两个套件各有一条测试为 -它失败。 +它失败。这一处拆分的另一个方向有一轮是没有证人的:把 ctrl+E 改走按行移动能活下来,因为按 +这个键的每一个夹具拿到的值里都没有换行,而在那种值上两个键落在同一列,路由和它的邻居无从 +区分。现在有一条用例在一个带换行、光标不在最后一行的值上把两个键都经过路由按一遍,那个变 +异会让它失败。 第二轮评审的另一条发现是一个拼法,而不是一个行为:擦掉光标左边那个词的键。ink 绑定的是 ctrl+W,而本移植除了它,还会为一个带修饰键的退格擦词 —— 那是一个上报修饰键的终端所发的 diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 9c46db3c8bf..9df00c8dac5 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -421,6 +421,18 @@ describe('line-edit', () => { ...mid, cursor: 5, }); + // A value with no break in it cannot tell the two end keys apart — both + // land at 5 above — so routing ctrl+E to the line-end move would leave + // this case green. The pair below is where they part. + const broken: LineState = { text: 'ab\ncd', cursor: 0 }; + expect(applyLineKey(broken, key('end'))).toEqual({ + text: 'ab\ncd', + cursor: 2, + }); + expect(applyLineKey(broken, key('e', { ctrl: true }))).toEqual({ + text: 'ab\ncd', + cursor: 5, + }); }); it('erases relative to the caret, not the end of the value', () => { From 5508926cbdd582228cd4e51796d67b56ef300ab9 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 02:03:00 +0800 Subject: [PATCH 45/51] fix(cli): bound the ask-user answer row and re-arm a wizard field after a failed submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question dialog's free-text answer is the one row a paste can make arbitrarily long, and the confirmation it lives in has no body window of its own, so a single long paste grew the dialog past the terminal height and pushed the options still to be picked off screen. The row now draws inside the same fixed window ink's TextInput gives that field, caret cell included, while the submitted answer keeps every character. The collapsed echo of the same value is bounded the same way. A bracketed paste is one event with no keypress per character, so it was read against the render that drew the row rather than the cursor the keys of the same stdin read had already moved, and it did not ask whether the answer it would widen had been recorded. Both are now asked of the live position. Every text field of the provider wizard latches itself shut on the Enter that submits it, and only a mount-key change clears that latch. On the last step the submit is async, so a rejected install — or one that saved service models without a conversation model — left the very same step on screen with every key dead, and backing out of the wizard was the only way left. A retry counter now hands the field back on both paths. --- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 70 ++++++++++++++ packages/cli/src/ui/opentui/dialogs-auth.tsx | 53 +++++++++-- .../src/ui/opentui/dialogs-confirm.test.tsx | 80 ++++++++++++++++ .../cli/src/ui/opentui/dialogs-confirm.tsx | 93 +++++++++++++++---- 4 files changed, 270 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 204bc8d1267..31ddc93763d 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -275,6 +275,21 @@ async function runCustomProviderFlow(): Promise<{ return { onClose, notify }; } +/** + * Drive main → Third-party → DeepSeek to its model-IDs step, and type into the + * custom-ID field. Models is the terminal step of every preset flow, so the + * Enter that leaves it fires the install rather than another step. + */ +async function runPresetFlowToTerminalModels(): Promise { + renderDialog(); + await press('down'); // main: THIRD_PARTY_PROVIDERS + await press('return'); // → thirdparty-select, DeepSeek on top + await press('return'); // DeepSeek → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models (custom-ID input focused) + await typeText('mod'); +} + describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { beforeEach(() => { mocks.state.inputHandlers.length = 0; @@ -453,6 +468,61 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { expect.objectContaining({ status: 'error' }), ); }); + + it('takes the read again after a preset flow’s terminal submit fails', async () => { + // Models is the last step of every preset flow, so its Enter only fires the + // install and reports nothing about how it went. A rejected install leaves + // this very step on screen, where the latch that Enter armed would swallow + // every key — the user could neither correct the field nor retry, and Esc + // out of the wizard was the only way left. + core.applyProviderInstallPlan.mockRejectedValueOnce(new Error('401')); + await runPresetFlowToTerminalModels(); + await press('return'); // fires the install → rejects + + await vi.waitFor(() => { + expect(screen.getByText(/Failed to authenticate/)).toBeTruthy(); + }); + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(1); + await press('return'); + await vi.waitFor(() => { + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(2); + }); + }); + + it('takes the read again after a submit that saved no conversation model', async () => { + // The other path that keeps the dialog open on the step that submitted: the + // install succeeded but saved only service models, so the wizard stays where + // it was and the field has to take keys again just the same. + const servicePlan = coreRuntime.buildInstallPlan( + coreRuntime.minimaxProvider, + { + baseUrl: coreRuntime.resolveBaseUrl(coreRuntime.minimaxProvider), + apiKey: 'test-image', + modelIds: ['image-01'], + }, + ); + const build = vi + .spyOn(coreRuntime, 'buildInstallPlan') + .mockReturnValue(servicePlan); + try { + await runPresetFlowToTerminalModels(); + await press('return'); + await vi.waitFor(() => { + expect( + screen.getByText( + 'Service models saved. Configure a conversation model to start chatting.', + ), + ).toBeTruthy(); + }); + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(1); + await press('return'); + await vi.waitFor(() => { + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(2); + }); + } finally { + build.mockRestore(); + } + }); }); describe('bracketed-paste into dialog inputs (#57)', () => { diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index 83dce745c9c..14033caf271 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -283,14 +283,17 @@ function InputLine({ * Shared single-line text-input key handling (backend ask-user parity). Returns * the caret offset so the row can put its cursor cell where ink's would be. The * submit's own verdict is what settles the field, so a step that reports nothing - * cannot leave the latch unarmed. + * cannot leave the latch unarmed. `retrySeq` re-arms it: on the last step a + * `true` only means the install was fired, and when that install fails the same + * step stays mounted for the rest of the dialog's life. */ function useLineInputKeys( value: string, onChange: (next: string) => void, onSubmit: (text: string) => boolean, + retrySeq: number, ): number { - const line = useLineEdit(value, onChange); + const line = useLineEdit(value, onChange, retrySeq); useKeyboard((key) => { if (line.settled) return; const o = toOriginalKey(key); @@ -395,14 +398,17 @@ function BaseUrlSelectStep({ function BaseUrlInputStep({ flow, documentationUrl, + retrySeq, }: { flow: ProviderSetupFlow; documentationUrl?: string; + retrySeq: number; }) { const caret = useLineInputKeys( flow.state.baseUrl, flow.changeBaseUrl, (text) => flow.submitBaseUrl(text), + retrySeq, ); return ( @@ -437,13 +443,18 @@ function BaseUrlInputStep({ function ApiKeyStep({ provider, flow, + retrySeq, }: { provider: ProviderConfig; flow: ProviderSetupFlow; + retrySeq: number; }) { const docUrl = resolveDocumentationUrl(provider, flow.state.baseUrl); - const caret = useLineInputKeys(flow.state.apiKey, flow.changeApiKey, (text) => - flow.submitApiKey(text), + const caret = useLineInputKeys( + flow.state.apiKey, + flow.changeApiKey, + (text) => flow.submitApiKey(text), + retrySeq, ); return ( @@ -484,9 +495,11 @@ function uniqueIds(ids: string[]): string[] { function ModelsStep({ provider, flow, + retrySeq, }: { provider: ProviderConfig; flow: ProviderSetupFlow; + retrySeq: number; }) { const modelOptions = useMemo( () => provider.models?.map((m) => m.id) ?? [], @@ -529,8 +542,11 @@ function ModelsStep({ ); // ink keeps this field in a TextInput whose buffer survives the list taking - // focus, so the caret is still where it was left when Tab comes back. - const custom = useLineEdit(customText, updateCustom); + // focus, so the caret is still where it was left when Tab comes back. The + // retry counter is the mount key: models is the last step of every preset + // flow, so its Enter only fires the install, and a rejected install leaves + // this very step mounted with the latch that Enter armed. + const custom = useLineEdit(customText, updateCustom, retrySeq); const toggleRecommended = useCallback( (id: string) => { @@ -803,7 +819,13 @@ function ReviewStep({ flow }: { flow: ProviderSetupFlow }) { ); } -function SetupSteps({ flow }: { flow: ProviderSetupFlow }) { +function SetupSteps({ + flow, + retrySeq, +}: { + flow: ProviderSetupFlow; + retrySeq: number; +}) { const { provider, step } = flow.state; if (!provider || !step) return null; switch (step) { @@ -819,12 +841,13 @@ function SetupSteps({ flow }: { flow: ProviderSetupFlow }) { provider, flow.state.baseUrl, )} + retrySeq={retrySeq} /> ); case 'apiKey': - return ; + return ; case 'models': - return ; + return ; case 'advancedConfig': return ; case 'review': @@ -876,6 +899,12 @@ function AuthDialogFlow({ const [errorMessage, setErrorMessage] = useState( initialError ?? null, ); + // Every text field latches itself shut on the Enter that submits it, and only + // a mount-key change clears that latch. On the last step the submit is async, + // so a rejected install — or one that saved service models without a + // conversation model — leaves the very same step on screen with every key + // dead. Bumping this hands the field back. + const [retrySeq, setRetrySeq] = useState(0); const [viewLevel, setViewLevel] = useState('main'); const [_viewStack, setViewStack] = useState([]); const [mainIndex, setMainIndex] = useState(null); @@ -910,6 +939,7 @@ function AuthDialogFlow({ 'Service models saved. Configure a conversation model to start chatting.', ), ); + setRetrySeq((n) => n + 1); return; } notify?.( @@ -930,6 +960,7 @@ function AuthDialogFlow({ message: getErrorMessage(error), }); setErrorMessage(msg); + setRetrySeq((n) => n + 1); logAuth(config, new AuthEvent(protocol, 'manual', 'error', msg)); } }, @@ -1182,7 +1213,9 @@ function AuthDialogFlow({ )} - {viewLevel === 'provider-setup' && } + {viewLevel === 'provider-setup' && ( + + )} {errorMessage && ( diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 004eb5309cf..3982d3ad2d5 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -660,6 +660,12 @@ describe('OpenTuiToolConfirmation', () => { expect(container.textContent ?? '').not.toContain('alpha'); expect(container.textContent ?? '').toContain('beta'); expect(cursorCell(container)).toBe('a'); + press({ name: 'up' }); + // The collapsed row echoes the same window: it stands in for the row the + // cursor left, so the hidden half of the answer cannot grow the dialog + // from there either. + expect(container.textContent ?? '').not.toContain('alpha'); + press({ name: 'down' }); press({ name: 'return', sequence: '\r' }); expect(onConfirm).toHaveBeenCalledWith( ToolConfirmationOutcome.ProceedOnce, @@ -667,6 +673,25 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('keeps a long answer inside the width the row has', () => { + // ink holds this exact field in a TextInput 50 cells wide and one row + // tall. Without that window a single long paste grows the dialog past the + // terminal height and pushes the options still to be picked off screen. + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + const long = 'x'.repeat(120); + paste(long); + expect(container.textContent ?? '').not.toContain('x'.repeat(50)); + expect(container.textContent ?? '').toContain('…'); + // The bound is on the drawing only: the answer keeps every character. + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': long } }, + ); + }); + it('keeps every character of a burst that shares one batch', () => { const onConfirm = vi.fn(async () => {}); const container = mount(askDetails(twoOptions, onConfirm)); @@ -1127,6 +1152,61 @@ describe('OpenTuiToolConfirmation', () => { expect(container.textContent ?? '').toContain('Region: (not answered)'); }); + it('drops a paste that trails the keys of one read onto another tab', () => { + // One stdin read carries both: the arrows move the live cursor onto + // another question's free-text row while the paste handler still sees the + // render that drew this one, and the write path files under the live tab. + // Left unguarded, one question's half-typed answer is concatenated with + // the paste and submitted as another question's. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('ab'); + act(() => { + // The row owns ←/→ for in-field caret movement, so the ↑ that leaves it + // has to be part of the same read for the arrows to become tab moves. + const keys = [ + { name: 'up' }, + { name: 'right' }, + { name: 'down' }, + { name: 'down' }, + ]; + for (const key of keys) { + for (const handler of mocks.state.keyboardHandlers) handler(key); + } + for (const handler of mocks.state.pasteHandlers) { + handler({ + bytes: new TextEncoder().encode('xyz'), + preventDefault: () => {}, + }); + } + }); + expect(container.textContent ?? '').not.toContain('abxyz'); + press({ name: 'up' }); + press({ name: 'right' }); + press({ name: 'right' }); + expect(container.textContent ?? '').toContain('Region: (not answered)'); + }); + + it('drops a paste that trails an answer the pause is still holding', () => { + // The row the answer came from stays drawn and focused for ink's 150 ms + // pause, so a paste in that window would widen an answer already + // recorded — here a multi-select box whose ticks made the answer. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'return', sequence: '\r' }); + press({ name: 'down' }); + press({ name: 'down' }); + paste('xyz'); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: mail'); + expect(container.textContent ?? '').not.toContain('mail, xyz'); + }); + it('reviews every answer on the Submit tab and cancels from its second row', () => { const onConfirm = vi.fn(async () => {}); const container = mount(multiAskDetails(onConfirm)); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index a3d05356a5b..583b07a4b00 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -72,6 +72,7 @@ import { tailWindowPhysical, } from './messages.js'; import { + cpLen, getCachedStringWidth, sanitizeTerminalText, truncateToWidth, @@ -95,6 +96,39 @@ export interface PendingToolConfirmation { /** Max body rows before the tail window truncates (keeps dialogs bounded). */ const MAX_BODY_ROWS = 20; +/** + * Cells the free-text answer row may draw. ink holds this exact field in a + * TextInput with `inputWidth={50}` and no height — one physical row inside a + * fixed window — and the confirmation this dialog lives in has no body window + * of its own, so an unbounded row lets a single long paste grow the dialog past + * the terminal height and push the options the user still has to pick off + * screen. Submission is untouched: it reads the stored value, not the drawing. + */ +const CUSTOM_FIELD_WIDTH = 50; + +/** + * The caret's logical line inside that window, and whether the caret cell still + * falls inside it. One cell is held back for the cursor the selected row draws. + */ +function customFieldWindow( + line: string, + caret: number, + cap: number, +): { + line: string; + before: string; + at: string; + after: string; + caretInside: boolean; +} { + const drawn = truncateToWidth(line, Math.max(1, cap - 1)); + return { + line: drawn, + ...caretSpans({ text: drawn, cursor: caret }), + caretInside: caret <= cpLen(drawn), + }; +} + /** * Rows reserved around an EXPANDED body: the inline confirmation's chrome * (padding, question, outcome list, waiting row) plus the transcript region @@ -843,6 +877,14 @@ function AskUserQuestionFlow(props: { width - 2 - rowOverhead, ); + // The field re-seeds its buffer during render, so a tab move inside one burst + // leaves it holding the question that render drew. A keystroke handled now + // would append to that text and store it under the new tab, so the row keeps + // its keys only while the burst still stands on the tab it drew — and none at + // all once its answer has been given. One stdin read carries pastes beside + // keys, so both paths ask this rather than only the one that dispatches keys. + const fieldIsMine = () => tabRef.current === tab && !custom.settled; + useKeyboard((key) => { const original = toOriginalKey(key); // One stdin burst runs every key it carries against this one closure, so @@ -856,13 +898,6 @@ function AskUserQuestionFlow(props: { isCustomRow: onCustomRow, } = viewOf(tabRef.current, selectedRef.current); - // The field re-seeds its buffer during render, so a tab move inside this - // burst leaves it holding the question that render drew. A keystroke handled - // now would append to that text and store it under the new tab, so the row - // keeps its keys only while the burst still stands on the tab it drew — and - // none at all once its answer has been given. - const fieldIsMine = tabRef.current === tab && !custom.settled; - if (onCustomRow) { // Bare letters belong to the input and ←/→ must not switch tabs while it // owns the cursor, so only unambiguous shortcuts are honoured here. @@ -876,7 +911,7 @@ function AskUserQuestionFlow(props: { } else if (original.name === 'return') { submitCustomRow(); } else if ( - fieldIsMine && + fieldIsMine() && !custom.handleKey(original) && isPrintableKeyInput(key) ) { @@ -953,9 +988,15 @@ function AskUserQuestionFlow(props: { // Bracketed pastes arrive as one event with no keypress per character, and // the composer that would otherwise consume them is unmounted while a - // confirmation owns the screen. + // confirmation owns the screen. The write path stores under the live tab, so + // this asks for the live position too: one read can carry the keys that moved + // the cursor and the paste that trails them. usePaste((event: PasteEvent) => { - if (!isCustomRow) return; + const { isCustomRow: onCustomRow } = viewOf( + tabRef.current, + selectedRef.current, + ); + if (!onCustomRow || !fieldIsMine() || answerIsLocked()) return; const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); @@ -1049,7 +1090,19 @@ function AskUserQuestionFlow(props: { const customEmphasis = isCustomAnswer || typedChecked[tab] === true; const customLabel = `${question.options.length + 1}. `; const placeholder = t('Type something...'); - const customSpans = caretSpans({ text: customValue, cursor: custom.caret }); + const caretLine = caretSpans({ text: customValue, cursor: custom.caret }); + const fieldCap = Math.max( + 1, + Math.min( + CUSTOM_FIELD_WIDTH, + width - 2 - getCachedStringWidth(`❯ ${customMark}${customLabel}> `), + ), + ); + const customField = customFieldWindow( + caretLine.before + caretLine.at + caretLine.after, + cpLen(caretLine.before), + fieldCap, + ); return ( <> @@ -1105,13 +1158,17 @@ function AskUserQuestionFlow(props: { {customValue ? ( <> - {sanitizeTerminalText(customSpans.before)} + {sanitizeTerminalText(customField.before)} {/* ink's software cursor: a background-filled cell at the - caret, which a text frame cannot show. */} - {customSpans.at || ' '} + caret, which a text frame cannot show. Past the window the + cell has nowhere to go, exactly as in ink's fixed-width + TextInput. */} + {customField.caretInside ? ( + {customField.at || ' '} + ) : null} - {sanitizeTerminalText(customSpans.after)} + {sanitizeTerminalText(customField.after)} ) : ( @@ -1126,10 +1183,14 @@ function AskUserQuestionFlow(props: { fg={customEmphasis ? C.accent : customValue ? C.text : C.dim} attributes={customEmphasis ? 1 : 0} > + {/* The echo is bounded like the row it stands in for: the value it + prints is the one a paste can make arbitrarily long. */} {' ' + customMark + customLabel + - (customValue ? sanitizeTerminalText(customValue) : placeholder) + + (customValue + ? sanitizeTerminalText(customField.line) + : placeholder) + (isCustomAnswer ? ' ✓' : '')} )} From f3373353f612da02baa862f35fda2aade821195c Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 02:25:03 +0800 Subject: [PATCH 46/51] fix(cli): settle the answer row on the verdict that records it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A submit the answer lock rejects used to settle the field before the call that could refuse it, against the contract the shared line model states for its own latch. The letters trailing that Enter in the same read then reached a row that had closed, so the answer the user came back to give was the shorter one. The call now reports whether the answer was recorded and the field settles on that verdict. The tab swap cancelled on the answer path goes with it. A lock added in an earlier round drops a second answer inside the pause before it reaches that cancel, and a live timer always belongs to the tab the cursor is still on, so putting the call back fails no test — which is the measurement that showed it unreachable. The design doc is corrected in both languages for this round's findings, with the mutation counts measured rather than restated. --- .../2026-09-10-opentui-parity-defect-sweep.md | 189 ++++++++++++++---- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 139 ++++++++++--- .../src/ui/opentui/dialogs-confirm.test.tsx | 36 ++++ .../cli/src/ui/opentui/dialogs-confirm.tsx | 28 ++- 4 files changed, 307 insertions(+), 85 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index e98beab0b0e..7219f0b3ece 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -922,6 +922,15 @@ wiring above fails that test with `(1s` where it asserts `(4s`. The row prints n elapsed time while parked, as recorded above, so this is what the user sees once they have answered rather than while the dialog is open. +That wiring is only as good as the instance it lives on, and one review round +measured the limit: the test rerenders a single row, while the shell mounts the row +twice — once under a parked confirmation, once above the composer — in two mutually +exclusive branches. A park therefore unmounts the instance that accumulated the time +and mounts a fresh one, whose flag never changes value, so the counter still restarts +after an answer in the shell even though the paused flag is wired correctly and the +unit test passes. Carrying the accumulated time across the swap means owning it above +both mounts, which is recorded as a follow-up rather than done here. + Re-running the full matrix afterwards confirms it on the machine rather than in a test: eleven checkpoints taken while a dialog is parked each shed one row on both sides, from four divergent rows to three, and the rows left there are the @@ -993,9 +1002,11 @@ the render. The queue lives in a ref so that a turn can drain it and put it back within one tick, and a ref is invisible to a render — so the ref stays the synchronous source, and the five sites that move it each copy it into state besides: the push, the drain, the restore for texts still riding when a turn -aborts, the pop for editing, and the transcript reset that backs `/clear`, -resume and branch. That last one is why the rows shed with a cleared screen -instead of outliving it. +aborts, the mid-turn steering path that re-queues the remainder it did not +consume, and the transcript reset that backs `/clear`, resume and branch. The +composer's pop for editing moves the ref only by draining it, so the drain's +copy is what mirrors that path. The transcript reset is why the rows shed with a +cleared screen instead of outliving it. This is the one decision here that no frame can evidence. Both renderers _steer_ a plain-Enter submission made mid-turn: the text is drained at the next sampling @@ -1040,11 +1051,11 @@ site and one at the mount — each fail at least one test, with one limitation: escape-stripping addition carries no mutation of its own. The mount's mutation is the rows moved under the composer, which the placement test now catches by ordering the two against each other in the chrome's own text. The five ported -component rows -fail their own and no other; the mirror sites do not map one to one, because a -list never given its first entry, or never emptied, is still being asserted -turns later: draining without mirroring fails one assertion, pushing without it -fails four, popping for editing two. +component rows fail their own and no other; the mirror sites do not map one to +one, because a list never given its first entry, or never emptied, is still +being asserted turns later. Measured by dropping each copy in turn: pushing +without it fails four tests, the mid-turn re-queue without it two, and the +drain, the abort restore and the transcript reset one each. ## Decision 31 — a model dialog outcome is recorded as well as shown @@ -1317,22 +1328,26 @@ is never drawn. The port inherited that, and the review round caught it on the arrow case — the user had navigated to a question and the dialog took it away again a tenth of a second later. -Both paths now cancel a pending swap first: the arrow because the keystroke is the -user's own answer to "which question am I on", the second answer because it -supersedes the swap the first one scheduled. This is one of the places where the -port deliberately does not match ink; the same 150 ms pause, the same clamping at -the last tab, and the same tick-before-swap are kept. ink's behaviour is recorded -as a defect of its own rather than reproduced, and the double-fire the acceptance -matrix had already watched on ink's leg is the same defect seen from outside. - -Both paths are pinned in the confirmation dialog's suite, each by a mutation of -its own: a right arrow that lands during a pending pause keeps the question it -moved to, and removing that cancel fails this test alone; two answers inside one -pause leave exactly the swap the second one scheduled, in a test that predates -this round, and removing the cancel in the answer path fails that one alone. On a -machine the pause and the arrow were already covered by the matrix scenarios that -answer several questions; the cancellation itself was not observed there, since it -needs two keystrokes inside 150 ms. +The arrow now cancels a pending swap first, because the keystroke is the user's own +answer to "which question am I on". The second answer used to cancel it as well and +supersede the swap the first one scheduled; a later round put a lock in front of that +path instead, which drops an answer landing inside the pause outright (Decision 37). +Nothing is left for the cancel to see there: a live timer always belongs to the tab the +cursor is still on, since the only moves are the swap's own and the two arrows, and both +arrows cancel first. This is one of the places where the port deliberately does not match +ink; the same 150 ms pause, the same clamping at the last tab, and the same +tick-before-swap are kept. ink's behaviour is recorded as a defect of its own rather than +reproduced, and the double-fire the acceptance matrix had already watched on ink's leg is +the same defect seen from outside. + +The arrow is pinned by a mutation of its own: a right arrow that lands during a pending +pause keeps the question it moved to, and removing that cancel fails this test alone. The +answer path's cancel was taken out rather than pinned — putting it back fails nothing, +which is the measurement that showed it unreachable. What the path keeps is the drop: the +test that predates this round still asserts one advance for two answers inside a pause, +and the lock's own mutation is recorded under Decision 37. On a machine the pause and the +arrow were already covered by the matrix scenarios that answer several questions; the +cancellation itself was not observed there, since it needs two keystrokes inside 150 ms. ## Decision 35 — the cursor cell prints the character that owns the focus @@ -1346,13 +1361,17 @@ as it does in ink, whose text input decides whether to draw the cursor's highlight while always drawing the text under it. The same row's caret now follows a rule the previous pass had inverted. The caret -indexes the value the field's owner acknowledged, not the one the keys typed: when -a keystroke reaches a field whose owner refuses it — a letter into the -context-window field, which keeps digits only — the accepted value has no character -at that position to hold a caret, so the cap at the shared prefix between the two -texts leaves the caret exactly where it was. Before that, each refused key moved -the caret one code point right, and the first backspace after a burst of them ate a -real character. Where the two texts are equal the rule reduces to the plain clamp, +indexes the value the field's owner acknowledged, not the one the keys typed. The two +texts are walked together and the caret is charged only for the code points the owner +dropped ahead of it, which covers both shapes a refusal takes: a single refused +keystroke — a letter into the context-window field, which keeps digits only — drops one +code point at the caret and leaves the caret exactly where it was, and an owner that +filters the middle of a pasted value (`1,024` → `1024`) keeps the caret following the +accepted tail instead of stranding it on the first character removed. A value the walk +cannot align, because the owner inserted or substituted rather than dropped, falls back +to capping the caret at the code points the two texts agree on. Before that, each refused +key moved the caret one code point right, and the first backspace after a burst of them +ate a real character. Where the two texts are equal the rule reduces to the plain clamp, so every accepted edit still behaves as it did. The row prints only what the flow accepted, which is the divergence Decision 32 @@ -1365,14 +1384,15 @@ had pinned the drift as the expected behaviour, since the caret it asserted was drift; it pins the absence of one now, and the difference from the shape the review asked for is stated in that thread. -Two tests and three mutations. The cell that dropped its character when the field -lost focus fails the new row test in the authentication suite; the resync that -calls the plain clamp where the shared-prefix cap belongs, and that cap reduced to -a length clamp, fail the context-window field's edit test and the new unit test of -the rule itself. Each mutation fails exactly the tests that own the behaviour it -takes away. The machine leg is unchanged here — a caret in a field the wizard -renders is still not reachable by any scenario, so the row's rendering rests on the -unit suites. +Four mutations, each failing exactly the test that owns the behaviour it takes away: +the cell that dropped its character when the field lost focus fails the authentication +suite's row test for a field that lost the cursor; the resync that calls the plain clamp +where this rule belongs fails that suite's context-window edit test; the fallback cap +reduced to a length clamp fails the unit test of a single refused keystroke; and the walk +reduced to that cap alone — the shape this decision used to describe — fails the unit test +of a value the owner filtered in the middle. The machine leg is unchanged here — a caret +in a field the wizard renders is still not reachable by any scenario, so the row's +rendering rests on the unit suites. ## Decision 36 — the arrow names the call the queue can answer @@ -1433,10 +1453,16 @@ already given. The latch arms only on an Enter that reports the step moved. An Enter refused by validation keeps the step mounted and the read belongs to it, so `abc`, Enter, `def` in -one read still leaves `abcdef` in the endpoint field. It is a per-render flag rather than -a per-mount one: the renderer installs a fresh handler per commit and a read cannot span -a commit, so the flag spans exactly one read, and a field whose submit was rejected — or -whose auth failed a step later — is editable again on the next render. +one read still leaves `abcdef` in the endpoint field. It is a ref that holds across +renders rather than a per-render flag: the render a successful submit schedules would +otherwise clear it, and in the question dialog that render leaves the submitted row +mounted and focused for the whole 150 ms pause, so the next read would keep editing a +field that had already answered. Only a changed mount key clears it, which is the key each +field is mounted under and also what re-seeds the caret past the value the way ink's text +input does on mount. A field whose submit was refused stays editable because nothing +armed the latch. A field whose submit was taken and then failed a step later does not — +the step is still on screen with the latch armed — and the wizard's last step needs the +retry counter Decision 39 records. What stays un-latched is deliberate. The question dialog's tab arrows keep working inside the pause Decision 34 protects, because there the keystroke is the user's answer @@ -1540,6 +1566,77 @@ the same hit-test path and which this renderer never advertised, and a click while a dialog is open — the dialog lists are still focusable by default, recorded under Follow-ups. +## Decision 39 — the answer row is bounded, and a submit that was not taken leaves the field open + +One review round found four ways this port still closed a door on the user, and one +piece of machinery that had stopped being reachable. All five are recorded here rather +than folded into the decisions they qualify, because each was measured this round. + +The question dialog's free-text answer was the one payload in that dialog with no width +and no row bound. ink holds the same field in a text input fifty cells wide and one row +tall, and the confirmation this dialog lives in has no body window of its own, so a +single long paste grew the dialog past the terminal height and pushed the options the +user still had to pick off the screen. The row now draws inside a window of its own — +fifty cells, less the label the row prints in front of it, on the width the dialog has — +and the caret cell is part of that: past the window it has nowhere to go, exactly as in +ink's fixed-width input. The collapsed echo of the same value is bounded the same way, +since what it prints is the value a paste can make arbitrarily long. The bound is on the +drawing alone. Submission reads the stored value, so the answer keeps every character, +including the line breaks a pasted paragraph carries. + +A bracketed paste is one event with no keypress per character, and the handler that took +it asked the render which drew the row whether the cursor was on it, while writing under +the live tab. Two questions it did not ask, both of which the key path already asked for +its own hazard: whether the burst still stands on the tab the row was drawn for, and +whether an answer the pause is still holding has locked this one out. One stdin read +carries pastes beside keys, so the arrows that move the cursor and the paste that trails +them arrive together — filed unguarded, one question's half-typed answer was concatenated +with the paste and submitted as another question's, or an answer already recorded was +widened behind the user's back. Both are now asked of the live position. + +The wizard's fields latch on the Enter that submits them, and Decision 37 records the +latch as the ref it is: only a changed mount key clears it. On the last step of a flow the +submit is asynchronous, so the `true` that arms the latch means only that the install was +fired. When that install is rejected — or when it saves service models and no conversation +model, which also leaves the dialog open — the same step stays mounted for the rest of the +dialog's life with every key dead: the user could neither correct the field nor retry, and +backing out of the wizard was the only way left. The dialog now counts its retries and +hands that counter to the fields as their mount key, bumped on both paths, which re-arms +the latch and re-seeds the caret the way a fresh mount does. + +A submit the answer lock rejects was still closing the row it came from: the field settled +before the call that could refuse it, against the contract the line model states for its +own latch. The call now reports whether the answer was recorded, and the field settles on +that verdict. What the user sees is the rest of the same read — the letters trailing a +rejected Enter still reach the field, so the answer they come back to give is the whole of +what they typed rather than the part that preceded the refusal. + +The fifth is a removal. Decision 34 cancelled a pending tab swap on two paths; the answer +path's cancel could no longer observe a timer, because the lock Decision 37 added drops a +second answer inside the pause before it reaches the cancel, and a live timer always +belongs to the tab the cursor is still on — the only moves are the swap's own and the two +arrows, which cancel first. Putting the call back fails no test, which is the measurement +that showed it unreachable; Decision 34 is corrected to say so. + +Six new tests, four in the confirmation dialog's suite and two in the authentication +one, plus the collapsed-echo assertions added to the pasted-newline test the round +before it, and nine mutations measured this round. Each mutation fails exactly the tests +that own the behaviour it takes away: + +| mutation | fails | +| ---------------------------------------------------- | ------------------------------ | +| the collapsed echo prints the raw value again | the pasted-newline test | +| the row's window taken out | the long-answer test | +| the paste stops asking whose field it is | the cross-tab paste test | +| the paste stops asking whether the answer is locked | the paste-lock test | +| the paste handler reverted whole | both paste tests | +| the retry counter not bumped on a rejected install | the auth retry test | +| the retry counter not bumped on service models alone | the service-model retry test | +| the field settled before the verdict again | the rejected-submit test | +| the unreachable cancel restored | nothing — which is the finding | + +The machine leg is recorded with the matrix re-run below. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -1767,7 +1864,11 @@ What was verified, and how far the verification reaches: to the confirmation's geometry will therefore not fail on its own; re-deriving them belongs with that change. - The loading indicator has no subagent token rollup and no tokens-per-second - segment, both of which ink shows. + segment, both of which ink shows. Its elapsed counter also restarts after a + parked call in the shell, for the reason Decision 28 records: the row is + mounted twice in mutually exclusive branches, so the instance that accumulated + the time is not the one that carries on. Carrying it across the swap means + owning the accumulator above both mounts. - The theme mode helpers have no production caller, so this renderer always paints its dark palette. - Two dialog list widgets remain where one would do; consolidating them touches diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index fee39ef769c..984372c74a2 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -665,6 +665,13 @@ ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过 行根本不打印耗时,如上文所记,所以这影响的是用户回答之后看到的数字,而不是对话框开着的时 候。 +这套接法的好坏,取决于承载它的那个实例,而有一轮评审量到了它的边界:那条测试重渲染的是同 +一行,而外壳把这一行挂了两次 —— 一次在挂住的确认框下面,一次在 composer 之上 —— 分处两个 +互斥的分支。于是「挂住」这件事本身就会卸载那个累计了耗时的实例、再挂上一个全新的实例,而 +新实例上那个旗标的值永远不会变;所以即便暂停旗标接得没错、单元测试也过得去,在外壳里回答 +之后耗时计数仍然会从头开始。要让已累计的时间跨过这次交换,就得把它交到两处挂载之上去持有, +这件事记进了后续工作,没有在这里做。 + 之后重跑全量矩阵,在真机上而不是在测试里确认了它:十一个在对话框挂住时取到的检查点, 每一处两侧各减掉一行,从四行分叉变成三行,那里剩下的行是决定 21 记录过的长路径折行断 点。总分叉行数从 221 行 ink 独有、265 行 opentui 独有,降到 210 与 254。逐字节一致的检 @@ -714,8 +721,10 @@ composer 的上键在顶边也能把一条文本弹回来供编辑,但这些 之前会先被剥掉终端转义,而 ink 的组件把这件事交给终端。此前挡住的是读取而不是渲染。队 列住在一个 ref 里,好让一个回合能在一拍之内把它排空再放回去,而 ref 对一次渲染是不可见 的 —— 于是 ref 仍是那个同步的真相来源,同时移动它的五处各自再往 state 里复制一份: -压入、排空、回合中止时把仍在搭载的文本放回去的恢复、供编辑的弹出,以及为 `/clear`、 -恢复会话与分支兜底的那次转录重置。最后这一处决定了这些行是随清屏一起脱落,而不是活过它。 +压入、排空、回合中止时把仍在搭载的文本放回去的恢复、回合中把没有消耗完的余下文本重新排回 +队列的那条引导路径,以及为 `/clear`、恢复会话与分支兜底的那次转录重置。composer 那个 +「弹出供编辑」自己并不复制一份 —— 它是靠排空来移动 ref 的,所以镜像这条路径的是排空那一 +处。转录重置这一处决定了这些行是随清屏一起脱落,而不是活过它。 这是本文档里唯一一件没有帧能作证的改动。两个渲染器都会把一个回合中按普通回车提交的文 本 **转向(steer)**:它在下一个采样边界被排空,作为真实的用户内容进入下一次模型请求, @@ -751,8 +760,8 @@ chrome 里,而不是随对话滚走,并且落在它的上面而不是下面 而那条钉住位置的测试现在会把两者在 chrome 自己的文本里排出先后,所以这次移动会让它变红。 五条照搬来的组件行只让 属于自己的那条失败;镜像点做不到一一对应,因为一个从未拿到第一条、或从未被清空的列表, -之后好几个回合还在被断言:抽干时不镜像让一条断言失败,推入时不镜像让四条失败,弹出编辑 -让两条失败。 +之后好几个回合还在被断言。逐个抽掉那份复制量出来的结果是:推入时不镜像让四条测试失败, +回合中的重新排队不镜像让两条失败,排空、中止恢复与转录重置各让一条失败。 ## 决定 31 —— 模型对话框的结果,既显示也记录 @@ -940,17 +949,20 @@ getter 而不是快照,因为一个只捕获一次文本的消费者会把这 器,于是两次换题都会跑,中间那一题根本没被画出来。本移植继承了这个行为,而评审轮在 方向键这条路上抓到了它 —— 用户已经翻到某一题,对话框在零点一秒之后又把它带走了。 -两条路径现在都会先取消排定的换题:方向键那一条,是因为这次按键就是用户对「我在答哪一 -题」给出的答案;第二次回答那一条,是因为它取代了第一次排定的换题。这是本移植有意与 -ink 不一致的几处之一;同样的 150 毫秒暂停、同样的在最后一个标签处钳制、同样的先勾选 -后换题都保留了。ink 的行为作为它自己的缺陷记录在案,而不是照搬,而验收矩阵已经在 ink -那条腿上看到的双触发,就是同一个缺陷从外面看的样子。 - -两条路径都在确认对话框的套件里被钉住,各自对应一个自己的变异:一次落在暂停之中的右 -方向键会保住它翻到的那一题,把那次取消拿掉就只有这一条测试失败;一次暂停之内的两次 -回答只会留下第二次排定的换题,这条测试早于本轮,把回答路径里的取消拿掉就只有它失败。 -实机侧,暂停与方向键已经被矩阵里那些回答多个问题的场景覆盖了;取消本身在那里没有被 -观察到,因为它需要 150 毫秒之内的两次按键。 +现在方向键会先取消排定的换题,因为这次按键就是用户对「我在答哪一题」给出的答案。第二次 +回答原本也会取消它、并取代第一次排定的那次换题;后来一轮在这条路前面放了一把锁,落在暂停 +之内的回答会被整个丢掉(决定 37)。这里已经没有东西留给那次取消去看了:一个还活着的定时器 +永远属于光标仍停在上面的那个标签,因为移动只有换题自己那一次和两个方向键,而两个方向键都 +先取消。这是本移植有意与 ink 不一致的几处之一;同样的 150 毫秒暂停、同样的在最后一个标签 +处钳制、同样的先勾选后换题都保留了。ink 的行为作为它自己的缺陷记录在案,而不是照搬,而验 +收矩阵已经在 ink 那条腿上看到的双触发,就是同一个缺陷从外面看的样子。 + +方向键由它自己的一个变异钉住:一次落在暂停之中的右方向键会保住它翻到的那一题,把那次取消 +拿掉就只有这一条测试失败。回答路径上的那次取消是被摘掉的,而不是被钉住的 —— 把它放回去什 +么都不失败,正是这个量度说明它不可达。那条路留下来的是「丢掉」本身:早于本轮的那条测试仍 +然断言暂停之内的两次回答只走一步,而锁自己的变异记在决定 37 下面。实机侧,暂停与方向键已 +经被矩阵里那些回答多个问题的场景覆盖了;取消本身在那里没有被观察到,因为它需要 150 毫秒 +之内的两次按键。 ## 决定 35 —— 光标格印出握着焦点的那个字符 @@ -961,11 +973,13 @@ ink 不一致的几处之一;同样的 150 毫秒暂停、同样的在最后 的高亮,而其下的文字始终要画。 同一行的光标现在遵循一条被上一版弄反了的规则。光标索引的是字段的主人认下的那个值, -而不是按键敲出来的那个:当一次按键到达一个主人会拒绝它的字段 —— 往上下文窗口字段里敲 -一个字母,而它只留数字 —— 被接受的值在那个位置上并没有可以承载光标的字符,于是以两段 -文本共同前缀为界的钳制会把光标留在原地。在那之前,每个被拒绝的键都把光标向右推一个码 -点,一连串之后第一个 backspace 就吃掉一个真实字符。两段文本相等时,这条规则退化回普通 -的钳制,所以每一次被接受的编辑行为如旧。 +而不是按键敲出来的那个:两段文本被一起走,光标只为被主人在它前面丢掉的码点计费,这 +覆盖了一次拒绝的两种形状 —— 单独一次被拒绝的按键(往只留数字的上下文窗口字段里敲一个 +字母)在光标处丢一个码点,于是光标停在原地不动;而一个会过滤粘贴值中间部分的主人 +(`1,024` → `1024`)让光标继续跟着被接受的那段尾巴,而不是把它搁在第一个被删掉的字符 +上。走不动的值 —— 主人是插入或替换,而不是删除 —— 退回以两段文本共同前缀为界的钳制。 +在那之前,每个被拒绝的键都把光标向右推一个码点,一连串之后第一个 backspace 就吃掉一个 +真实字符。两段文本相等时,这条规则退化回普通的钳制,所以每一次被接受的编辑行为如旧。 这一行只打印流程接受过的内容,而这正是决定 32 记下的那条对 ink 的分歧,不是新的:ink 的缓冲区才是事实来源,会一直显示它的流程自己丢掉的那个字符,所以同一次按键在那里留下 @@ -974,11 +988,12 @@ ink 不一致的几处之一;同样的 150 毫秒暂停、同样的在最后 测试把这个漂移钉成了期望行为,因为它断言的那个光标就是漂移本身;现在它钉的是没有漂移, 而与评审要求的那个形状之间的差别已经写在那条 thread 里。 -两条测试、三个变异。字段失焦时丢掉字符的那一格,让认证套件里新增的那条行测试失败;在 -应当放共同前缀钳制的地方改用普通钳制的重新同步,以及把那条钳制退化成按长度钳制,分别 -让上下文窗口字段的编辑测试、和这条规则本身的新单元测试失败。每个变异都恰好让拥有被 -拿走行为的那些测试失败。实机腿在这里没有变化 —— 向导渲染的字段里的光标仍然没有任何 -场景能够到达,所以这一行的渲染只由单元测试套件兜着。 +四个变异,每一个都恰好让拥有被它拿走的那个行为的那条测试失败:字段失焦时丢掉字符的那一 +格,让认证套件里那条「光标被让给兄弟字段」的行测试失败;在应当放这条规则的地方改调普通钳 +制的重新同步,让同一个套件里上下文窗口字段的编辑测试失败;退化成按长度钳制的那个兜底钳 +制,让单独一次被拒绝按键的单元测试失败;而退化成只剩那个钳制的走法 —— 也就是这条决定从前 +描述的那个形状 —— 让主人过滤值中间部分的那条单元测试失败。实机腿在这里没有变化 —— 向导渲 +染的字段里的光标仍然没有任何场景能够到达,所以这一行的渲染只由单元测试套件兜着。 ## 决定 36 —— 箭头指向队列答得了的那个调用 @@ -1022,10 +1037,14 @@ ink 的规则去读了错的列表。ink 的标记指向的是「确认框就在 给出的那个答案加宽。 闩只在接受步「真的动了」的 Enter 上武装。被校验拒绝的 Enter 会把这一步留在屏幕上,这次读取 -仍然属于它,所以同一次读取里的 `abc`、Enter、`def` 依旧会在端点字段里留下 `abcdef`。它是 -每次渲染一个旗标,而不是每次挂载一个:渲染器每次提交安装一个新的处理函数,而一次读取不可能 -跨越一次提交,于是这个旗标恰好覆盖一次读取;一个提交被拒绝的字段 —— 或者认证在晚一步才失败的 -字段 —— 到下一次渲染就又能编辑了。 +仍然属于它,所以同一次读取里的 `abc`、Enter、`def` 依旧会在端点字段里留下 `abcdef`。它是一 +个跨渲染持有的 ref,而不是每次渲染一个旗标:一次成功提交自己排定的那次渲染原本会把它清掉, +而在提问对话框里,那次渲染会让已提交的那一行继续挂载、继续握着焦点,整整一段 150 毫秒的暂 +停,于是下一次读取还会接着编辑一个早已作答的字段。只有挂载键变化才清它 —— 那既是每个字段被 +挂载时所用的键,也是让光标重新落到值末尾的那个东西,和 ink 的文本输入在挂载时做的一样。一 +个提交被拒绝的字段仍可编辑,因为没有任何东西武装过这把闩;而一个提交被接受、却在晚一步才 +失败的字段不再可编辑 —— 那一步还在屏幕上,闩还武装着 —— 所以向导的最后一步需要决定 39 记 +下的那个重试计数器。 有意不闩的部分也写下来。提问对话框的左右切题键在决定 34 保护的那段暂停里照常工作,因为那里 的按键就是用户自己对「我在答哪一题」的回答;字段那把闩停住的是字段,不是它周围的对话框。 @@ -1105,6 +1124,64 @@ paste 有没有进缓冲区。 而这个渲染器从未对外声明过它;以及对话框开着时点击 —— 对话框的列表仍然默认 focusable,记在后续工作里。 +## 决定 39 —— 作答那一行是有界的,而一次没被接受的提交要让字段开着 + +有一轮评审翻出四处本移植仍然把门在用户面前关上的地方,外加一处已经不再可达的机构。五 +处都记在这里,而不是折进它们各自修饰的那条决定,因为每一处都是本轮量出来的。 + +提问对话框的自由文本答案,是那个对话框里唯一一处既没有宽度也没有行界的载荷。ink 把同 +一个字段放在一个五十格宽、一行高的文本输入里,而这个对话框所在的确认框自己也没有正文 +窗口,于是一次长粘贴会把对话框撑到超过终端高度,把用户还要去选的那些选项推出屏幕。那 +一行现在画在自己的窗口里 —— 五十格,减去这一行在它前面印出的标签,落在对话框现有的宽 +度上 —— 而光标那一格也在其中:超出窗口它就无处可去,和 ink 那个定宽输入完全一样。同一 +个值折叠后的回显以同样的方式设界,因为它印出的正是粘贴可以任意拉长的那个值。界只设在 +画上。提交读的是存下来的那个值,所以答案保住每一个字符,包括一段粘贴文字自带的那些换 +行。 + +一次 bracketed paste 是一个事件,不会每字符一个按键,而接手它的处理函数是问画出那一行 +的那次渲染「光标在它上面吗」,写的却是当前那个标签。它没问的两件事,按键路径为自己的 +那个危险早就都问过了:这波突发是否仍然站在这一行为其而画的那个标签上,以及一个暂停仍 +在握着的回答是否已经把这一个锁在外面。一次 stdin 读取会把粘贴和按键一起带进来,所以移 +动光标的方向键和跟在它们后面的粘贴是一起到达的 —— 在没有守卫时,一题打了一半的答案会 +被和粘贴拼在一起,当成另一题的答案提交出去;或者一个早已记下的答案会在用户背后被加 +宽。现在两件事都问的是实时位置。 + +向导的字段在提交自己的那次 Enter 上闩住,而决定 37 把这把闩记成它实际的样子:只有挂载 +键变化才清它。在一个流程的最后一步,提交是异步的,所以那个武装闩的 `true` 只意味着安装 +被发出去了。当那次安装被拒绝 —— 或者它只存下服务模型而没有会话模型,那也会把对话框留 +着 —— 同一步会在对话框余下的整个生命里继续挂载,每个键都是死的:用户既改不了字段也重 +试不了,剩下的唯一出路是退出向导。对话框现在数自己的重试次数,把那个计数器作为挂载键 +交给字段,两条路径上都会加一,这会像一次全新挂载那样重新武装闩、重新播种光标。 + +一次被答案锁拒绝的提交,仍然会把它来自的那一行关上:字段在那个可以拒绝它的调用之前就 +结算了,与行模型为自己的闩写下的契约相悖。那个调用现在会报告答案有没有被记下,字段则 +按这个结论结算。用户看到的是同一次读取余下的部分 —— 跟在一个被拒绝的 Enter 后面的那些 +字母仍会到达字段,于是他们回头给出的是自己打下的全部内容,而不是拒绝之前的那一截。 + +第五处是一次删除。决定 34 曾在两条路径上取消排定的换题;回答路径上的那次取消已经看不 +到任何定时器了,因为决定 37 加的那把锁会在暂停之内的第二次回答到达取消之前就把它丢掉, +而一个还活着的定时器永远属于光标仍停在上面的那个标签 —— 移动只有换题自己那一次和两个 +方向键,而方向键先取消。把那个调用放回去不让任何测试失败,正是这个量度说明它不可达; +决定 34 已被改正为这么说。 + +六条新测试,确认对话框套件四条、认证套件两条,外加上一轮那条粘贴换行测试里新增的折叠 +回显断言,以及本轮量出的九个变异。每个变异都恰好让拥有被它拿走的那个行为的那些测试失 +败: + +| 变异 | 失败 | +| ---------------------------- | ------------------------------ | +| 折叠回显重新印出原始值 | 粘贴换行那条测试 | +| 那一行的窗口被拿掉 | 长答案那条测试 | +| 粘贴不再问「这是谁的字段」 | 跨标签粘贴那条测试 | +| 粘贴不再问「答案有没有被锁」 | 粘贴锁那条测试 | +| 粘贴处理函数整体还原 | 两条粘贴测试都失败 | +| 安装被拒时不加那个重试计数器 | 认证重试那条测试 | +| 只存下服务模型时不加计数器 | 服务模型重试那条测试 | +| 字段重新在结论之前结算 | 被拒提交那条测试 | +| 把不可达的那次取消还原 | 什么都不失败 —— 这就是那项发现 | + +实机腿与下面那次矩阵重跑一并记录。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -1262,7 +1339,9 @@ focusable,记在后续工作里。 因此确认框的几何将来发生变化时,这两个数字不会自己报错;重新推导它们应当与那 次改动一起做。 - loading 指示器没有子代理 token 汇总,也没有 tokens-per-second 段,两者 ink - 都有。 + 都有。它的耗时计数器在一个调用被停到 shell 里之后也会重新起算,原因正是决定 28 记下 + 的那个:那一行在两个互斥分支里各挂载一次,于是累积过时间的那个实例并不是接着走的那 + 个。要把这段时间带过那次交换,就得在两处挂载之上持有这个累积器。 - 主题模式 helper 没有生产调用方,所以本渲染器永远画它的暗色调色板。 - 仍有两个对话框列表组件,其实一个就够;合并它们会同时牵动编号、颜色与滚动箭 头。 diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 3982d3ad2d5..1920884a7a3 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -1207,6 +1207,42 @@ describe('OpenTuiToolConfirmation', () => { expect(container.textContent ?? '').not.toContain('mail, xyz'); }); + it('keeps a free-text row editable whose submit the pause rejected', () => { + // Nothing was recorded, so the row has not submitted and the letters + // trailing that Enter in the same read still belong to it. Latched + // anyway, the row would swallow them and the answer the user comes back + // to give is the shorter one. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + pressBatched([ + { name: 'return', sequence: '\r' }, + { name: 'down' }, + { name: 'down' }, + { name: 'f', sequence: 'f' }, + { name: 'o', sequence: 'o' }, + { name: 'o', sequence: 'o' }, + { name: 'return', sequence: '\r' }, + { name: 'b', sequence: 'b' }, + { name: 'a', sequence: 'a' }, + { name: 'r', sequence: 'r' }, + ]); + settleAdvance(); + press({ name: 'left' }); + press({ name: 'down' }); + press({ name: 'down' }); + expect(container.textContent ?? '').toContain('foobar'); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'foobar' } }, + ); + }); + it('reviews every answer on the Submit tab and cancels from its second row', () => { const onConfirm = vi.fn(async () => {}); const container = mount(multiAskDetails(onConfirm)); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 583b07a4b00..f4ade57d173 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -774,22 +774,26 @@ function AskUserQuestionFlow(props: { const answerIsLocked = () => advanceTimer.current !== null && answeredTabRef.current === tabRef.current; - const selectAndAdvance = (value: string) => { - if (answerIsLocked()) return; + /** + * Whether the answer was recorded. The pause of the answer before it can + * reject this one, and a caller that latches a field on success has to hear + * about that rather than assume it. + */ + const selectAndAdvance = (value: string): boolean => { + if (answerIsLocked()) return false; const idx = tabRef.current; pickedRef.current = { ...pickedRef.current, [idx]: value }; setPicked((prev) => ({ ...prev, [idx]: value })); if (!hasMultipleQuestions) { onAnswered({ [idx]: value }); - return; + return true; } - if (idx >= totalTabs - 1) return; + if (idx >= totalTabs - 1) return true; // ink's pause, so the ✓ on the row just answered is visible before the tab - // swap carries it up into the chip row. An answer landing inside that pause - // supersedes the swap scheduled by the previous keystroke: left running, - // both timers fire and the question between them is skipped without ever - // being drawn. - cancelPendingAdvance(); + // swap carries it up into the chip row. A second answer inside that pause is + // dropped by the lock above instead of arming a second timer: left running, + // both fire and the question between them is skipped without ever being + // drawn. A manual ←/→ cancels the swap outright. answeredTabRef.current = idx; advanceTimer.current = setTimeout(() => { advanceTimer.current = null; @@ -797,6 +801,7 @@ function AskUserQuestionFlow(props: { moveToTab(Math.min(tabRef.current + 1, totalTabs - 1)); moveToOption(0); }, 150); + return true; }; const submitAll = () => { @@ -846,8 +851,9 @@ function AskUserQuestionFlow(props: { if (!value) return; const answer = isMulti ? multiAnswer(true, current) : value; if (answer === undefined) return; - custom.settle(); - selectAndAdvance(answer); + // Settle on the verdict, not before it: a submit the pause rejects has to + // leave the row editable, or the answer the user retypes is swallowed too. + if (selectAndAdvance(answer)) custom.settle(); }; useEffect( From 0d0abf2d8f39bafd22e66963fc1ac19adf8f8800 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 02:32:42 +0800 Subject: [PATCH 47/51] refactor(cli): collapse the answer lock to the armed timer alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock that drops a second answer inside the dialog's 150 ms pause also asked which tab the pending swap belonged to, and that clause could never be false while a timer was armed: the only moves of the cursor's tab are the swap's own callback, which nulls the timer first, and the two arrows, which cancel the swap first. The ref modelling it goes with the dead cancel it accompanied, and the whole suite is green with the clause removed — which is the measurement that showed it unobservable. The design doc records the collapse in both languages, as this round's tenth mutation. --- .../2026-09-10-opentui-parity-defect-sweep.md | 12 +++++++++--- ...-09-10-opentui-parity-defect-sweep.zh-CN.md | 11 ++++++++--- .../cli/src/ui/opentui/dialogs-confirm.tsx | 18 +++++++----------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 7219f0b3ece..9681c34cb05 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1334,7 +1334,9 @@ supersede the swap the first one scheduled; a later round put a lock in front of path instead, which drops an answer landing inside the pause outright (Decision 37). Nothing is left for the cancel to see there: a live timer always belongs to the tab the cursor is still on, since the only moves are the swap's own and the two arrows, and both -arrows cancel first. This is one of the places where the port deliberately does not match +arrows cancel first. The lock is therefore the armed timer alone — the ref that also held +which tab the swap belonged to modelled a distinction no path could observe, and it went +with the dead cancel. This is one of the places where the port deliberately does not match ink; the same 150 ms pause, the same clamping at the last tab, and the same tick-before-swap are kept. ink's behaviour is recorded as a defect of its own rather than reproduced, and the double-fire the acceptance matrix had already watched on ink's leg is @@ -1616,11 +1618,14 @@ path's cancel could no longer observe a timer, because the lock Decision 37 adde second answer inside the pause before it reaches the cancel, and a live timer always belongs to the tab the cursor is still on — the only moves are the swap's own and the two arrows, which cancel first. Putting the call back fails no test, which is the measurement -that showed it unreachable; Decision 34 is corrected to say so. +that showed it unreachable; Decision 34 is corrected to say so. The same argument took the +lock's second clause with it: the ref holding which tab the swap belonged to could never +differ from the tab the cursor is on, so the lock is now the armed timer alone and the +whole suite is green with the clause gone. Six new tests, four in the confirmation dialog's suite and two in the authentication one, plus the collapsed-echo assertions added to the pasted-newline test the round -before it, and nine mutations measured this round. Each mutation fails exactly the tests +before it, and ten mutations measured this round. Each mutation fails exactly the tests that own the behaviour it takes away: | mutation | fails | @@ -1634,6 +1639,7 @@ that own the behaviour it takes away: | the retry counter not bumped on service models alone | the service-model retry test | | the field settled before the verdict again | the rejected-submit test | | the unreachable cancel restored | nothing — which is the finding | +| the lock's tab clause restored | nothing — which is the finding | The machine leg is recorded with the matrix re-run below. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 984372c74a2..675f27f3a34 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -953,7 +953,9 @@ getter 而不是快照,因为一个只捕获一次文本的消费者会把这 回答原本也会取消它、并取代第一次排定的那次换题;后来一轮在这条路前面放了一把锁,落在暂停 之内的回答会被整个丢掉(决定 37)。这里已经没有东西留给那次取消去看了:一个还活着的定时器 永远属于光标仍停在上面的那个标签,因为移动只有换题自己那一次和两个方向键,而两个方向键都 -先取消。这是本移植有意与 ink 不一致的几处之一;同样的 150 毫秒暂停、同样的在最后一个标签 +先取消。所以这把锁就是那个武装着的定时器本身 —— 还记着换题属于哪个标签的那个 ref,建模的 +是一条任何路径都观察不到的区别,它跟着那次死掉的取消一起走了。这是本移植有意与 ink 不一致 +的几处之一;同样的 150 毫秒暂停、同样的在最后一个标签 处钳制、同样的先勾选后换题都保留了。ink 的行为作为它自己的缺陷记录在案,而不是照搬,而验 收矩阵已经在 ink 那条腿上看到的双触发,就是同一个缺陷从外面看的样子。 @@ -1162,10 +1164,12 @@ focusable,记在后续工作里。 到任何定时器了,因为决定 37 加的那把锁会在暂停之内的第二次回答到达取消之前就把它丢掉, 而一个还活着的定时器永远属于光标仍停在上面的那个标签 —— 移动只有换题自己那一次和两个 方向键,而方向键先取消。把那个调用放回去不让任何测试失败,正是这个量度说明它不可达; -决定 34 已被改正为这么说。 +决定 34 已被改正为这么说。同一个论证把那把锁的第二个条件也一起带走了:记着换题属于哪个 +标签的那个 ref,永远不可能与光标所在的那个标签不同,所以这把锁现在就是那个武装着的定时 +器本身,而整个套件在这一条件被拿掉之后仍然是绿的。 六条新测试,确认对话框套件四条、认证套件两条,外加上一轮那条粘贴换行测试里新增的折叠 -回显断言,以及本轮量出的九个变异。每个变异都恰好让拥有被它拿走的那个行为的那些测试失 +回显断言,以及本轮量出的十个变异。每个变异都恰好让拥有被它拿走的那个行为的那些测试失 败: | 变异 | 失败 | @@ -1179,6 +1183,7 @@ focusable,记在后续工作里。 | 只存下服务模型时不加计数器 | 服务模型重试那条测试 | | 字段重新在结论之前结算 | 被拒提交那条测试 | | 把不可达的那次取消还原 | 什么都不失败 —— 这就是那项发现 | +| 把那把锁的标签条件还原 | 什么都不失败 —— 这就是那项发现 | 实机腿与下面那次矩阵重跑一并记录。 diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index f4ade57d173..61ec73da6c5 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -688,7 +688,6 @@ function AskUserQuestionFlow(props: { const pickedRef = useRef>({}); const typedCheckedRef = useRef>({}); const advanceTimer = useRef | null>(null); - const answeredTabRef = useRef(null); const { width } = useTerminalDimensions(); // Derived from the two indices alone so the keyboard handler can read them @@ -763,16 +762,15 @@ function AskUserQuestionFlow(props: { const cancelPendingAdvance = () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); advanceTimer.current = null; - answeredTabRef.current = null; }; - // The tab the pending swap belongs to. While that swap is armed, the row it - // just settled is still drawn and still owns the keys, so the rest of one - // stdin read would answer the same question again and quietly replace the - // answer already recorded. Only the answer is locked, not the cursor: a manual - // ←/→ cancels the swap and this guard with it. - const answerIsLocked = () => - advanceTimer.current !== null && answeredTabRef.current === tabRef.current; + // While a swap is armed, the row it just settled is still drawn and still owns + // the keys, so the rest of one stdin read would answer the same question again + // and quietly replace the answer already recorded. Only the answer is locked, + // not the cursor: a manual ←/→ cancels the swap and this guard with it, and + // those two are the only moves besides the swap's own, so an armed timer always + // belongs to the tab the cursor is still on. + const answerIsLocked = () => advanceTimer.current !== null; /** * Whether the answer was recorded. The pause of the answer before it can @@ -794,10 +792,8 @@ function AskUserQuestionFlow(props: { // dropped by the lock above instead of arming a second timer: left running, // both fire and the question between them is skipped without ever being // drawn. A manual ←/→ cancels the swap outright. - answeredTabRef.current = idx; advanceTimer.current = setTimeout(() => { advanceTimer.current = null; - answeredTabRef.current = null; moveToTab(Math.min(tabRef.current + 1, totalTabs - 1)); moveToOption(0); }, 150); From ae91c5fe795ea13f123bd0daedaa2efad587d2f7 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 03:02:46 +0800 Subject: [PATCH 48/51] docs: record the machine leg of the round-three fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix was re-run at this head, so the placeholder that stood in for its result is replaced with the measurement: fifty-four runs over twenty-seven scenarios with no error, the checkpoint split and the divergent-row counts, and how many captures are unchanged against the previous head. The same pass states that none of the round's five items moves a frame and why, so the unit suites are named as what carries them. Two counts in the coverage boundary had gone stale against the matrix as it runs — one scenario and one checkpoint were missing from both — and the scenario enumeration in the method section was missing the chip-row arm. Both languages are corrected together. --- .../2026-09-10-opentui-parity-defect-sweep.md | 35 ++++++++++++++----- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 33 +++++++++++------ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 9681c34cb05..9d168c35e9e 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,14 +57,15 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Twenty-six scenarios cover boot, a +rather than at the same wall-clock moment. Twenty-seven scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear and exit, a long hold that cycles the loading phrases, a to-do card, the question dialog on its own, the same dialog across three questions with a multi-select and a typed answer, that dialog's free-text row corrected in the -middle of a typed value, the release of a parked confirmation when the +middle of a typed value, the same dialog's chip row on a terminal narrow enough +to ellipsize a header, the release of a parked confirmation when the approval mode changes, the approval of a gated server at startup, a tool call whose arguments are long enough to be capped, a control arm that repeats that call with the arguments row switched off, an answer long enough to overflow @@ -1641,20 +1642,38 @@ that own the behaviour it takes away: | the unreachable cancel restored | nothing — which is the finding | | the lock's tab clause restored | nothing — which is the finding | -The machine leg is recorded with the matrix re-run below. +The machine leg was re-run at this head: twenty-seven scenarios on both legs, +fifty-four runs, none of them erroring. Of the seventy-eight checkpoints, +twenty-two have identical non-blank content rows and fifty-four diverge, +carrying 241 rows only ink draws and 282 only this port draws; the two that +remain are the sampled checkpoints — the spinner's phrase rotation and the +mid-stream indicator — which are counted apart, since what they hold is elapsed +time and a phrase chosen per tick. Against the previous head, 146 of the 152 +plain captures are byte-identical, and the six that are not are three +checkpoints differing on both legs at once: the mid-stream indicator, `/stats`, +and the held-phrase row — the content that moves between two runs of the same +binary. + +Nothing recorded in this decision moves a frame, and that is the expected result +rather than a missing one. The bound on the answer row is the identity until a +value exceeds the window, and no scenario types one that does; the paste guards, +the retry counter and the settle verdict all turn on what a single stdin read +carries beside the key that submitted it, which is a property of the tty rather +than of any script. Like the caret rule in Decision 35, these five rest on the +unit suites. ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for twenty-six scenarios at 100×40 and, for the narrow - and resize scenarios, at 60×24 — seventy-seven checkpoints in all, of which - twenty-two match byte for byte. Both legs from one bundle and one set of - boot arguments. + reconstructed screen, for twenty-seven scenarios — twenty-two at 100×40, the + three transcript arms at 100×30, the narrow boot at 60×24 and the chip row at + 58×40 — seventy-eight checkpoints in all, of which twenty-two match byte for + byte. Both legs from one bundle and one set of boot arguments. - **Colour was not verified, apart from one row family.** The reconstruction is text. Several rows are known to differ only in which theme token they use. - A styled capture backs all but three of the seventy-seven checkpoints and was + A styled capture backs all but three of the seventy-eight checkpoints and was read for one family only — Decision 32's cursor cell — leaving the rest unread. - **The scrollbar costs the transcript no column.** Measured at a hundred diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 675f27f3a34..acc5ef5ec2c 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,15 +41,15 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。二十六个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。二十七个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、把那条手打答案改到 -值的中间、审批模式变化时被拦下的确认框的释放、启动时对受门禁服务器的审批、一次 -参数长到会被截断的工具调用、把同一次调用在关掉参数行的情况下重跑的对照臂、一份 -长到溢出终端的回答、在溢出部分上滚下去再滚回来、两行宽到足以展示折行发生在哪一 -列的回答,以及一个把两个调用同时停在审批上的回合 —— 于是第一个要排在第二个的 -对话框后面。 +值的中间、同一个对话框在窄到会把标题截断的终端上的标签行、审批模式变化时被拦下 +的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用、把 +同一次调用在关掉参数行的情况下重跑的对照臂、一份长到溢出终端的回答、在溢出部分 +上滚下去再滚回来、两行宽到足以展示折行发生在哪一列的回答,以及一个把两个调用同 +时停在审批上的回合 —— 于是第一个要排在第二个的对话框后面。 比较方式有三个性质决定了结果该怎么读。 @@ -1185,17 +1185,28 @@ focusable,记在后续工作里。 | 把不可达的那次取消还原 | 什么都不失败 —— 这就是那项发现 | | 把那把锁的标签条件还原 | 什么都不失败 —— 这就是那项发现 | -实机腿与下面那次矩阵重跑一并记录。 +实机腿已在这个 head 上重跑:二十七个场景各跑两腿,共五十四次运行,没有一次报错。 +七十八个检查点里,二十二个的非空内容行完全一致,五十四个有分歧,带着 241 行只有 +ink 画得出、282 行只有本移植画得出;余下两个是采样型检查点 —— spinner 的短语轮 +转与流中指示器 —— 另行计数,因为里面装的是已耗时间与每拍挑一次的短语。与上一个 +head 相比,152 个普通抓取里有 146 个逐字节一致,而不一致的那六个是三个检查点在两 +腿上同时不同:流中指示器、`/stats`、以及按住短语那一行 —— 都是同一个二进制两次运 +行之间就会变动的内容。 + +这条决定里记下的东西没有一个会移动一帧,而这是预期的结果,不是缺漏。答案行那个边界 +在值超出窗口之前是恒等变换,而没有哪个场景会打出超出窗口的值;粘贴守卫、重试计数与 +提交判定,全都要看单次 stdin 读取在那次提交的按键旁边还带了什么,那是 tty 的性质, +不是任何脚本的性质。与决定 35 的光标规则一样,这五项都只由单元测试套件兜着。 ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十六个场景, - 100×40;窄终端与 resize 场景另加 60×24,总计七十七个检查点,其中二十二个 - 逐字节一致。两腿出自同一个 bundle 与同一组启动参数。 +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十七个场景 —— + 二十二个 100×40,三条转录臂 100×30,窄终端启动 60×24,标签行 58×40 —— 总计 + 七十八个检查点,其中二十二个逐字节一致。两腿出自同一个 bundle 与同一组启动参数。 - **颜色除一类行之外未验证。** 重建结果是文本。已知有若干行只在所用主题 token - 上不同。七十七个检查点里除三个外都有带样式的抓取兜着,而读过的只有一类行 —— + 上不同。七十八个检查点里除三个外都有带样式的抓取兜着,而读过的只有一类行 —— 决定 32 的光标格 —— 其余没有读。 - **滚动条没有从转录里拿走一列。** 在一百列下用两条分别为 95 与 96 字符的回答行 测量:本渲染器折满 99 列才断行,ink 折满 98 列,所以这一列的差与清单上那条既存的 From f4a8f7d88b93f8d362147f44615b4972d5b513d1 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 03:40:28 +0800 Subject: [PATCH 49/51] docs: record the matrix re-run after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge moved one thing on a frame — the version the banner prints — so the counts Decision 39 quotes are re-measured rather than carried over, and the byte-identity comparison states the normalisation it needed. --- docs/design/2026-09-10-opentui-parity-defect-sweep.md | 8 ++++++++ .../2026-09-10-opentui-parity-defect-sweep.zh-CN.md | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 9d168c35e9e..f23ac674c03 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1654,6 +1654,14 @@ checkpoints differing on both legs at once: the mid-stream indicator, `/stats`, and the held-phrase row — the content that moves between two runs of the same binary. +The whole matrix was run again after main was merged into the branch, and every +count above came back the same. The merge moved one thing on a frame: the +version the banner prints, which main had bumped, and which is inside the +transcript region on both legs and so appears in almost every capture. With +that string normalised away, the same 146 of the 152 plain captures are +byte-identical to the run before the merge and the same three checkpoints are +not. + Nothing recorded in this decision moves a frame, and that is the expected result rather than a missing one. The bound on the answer row is the identity until a value exceeds the window, and no scenario types one that does; the paste guards, diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index acc5ef5ec2c..fd7201bc96d 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -1193,6 +1193,11 @@ head 相比,152 个普通抓取里有 146 个逐字节一致,而不一致的 腿上同时不同:流中指示器、`/stats`、以及按住短语那一行 —— 都是同一个二进制两次运 行之间就会变动的内容。 +把 main 合并进本分支之后又跑了一遍全量矩阵,上面每一个数字都一样。合并在帧上只 +动了一处:banner 印出的版本号 —— main 把它升了,而它在两腿上都位于转录区里, +于是几乎每一个抓取都会带上它。把这一串归一掉之后,152 个普通抓取里仍是 146 +个与合并前那次运行逐字节一致,不一致的仍是同样三个检查点。 + 这条决定里记下的东西没有一个会移动一帧,而这是预期的结果,不是缺漏。答案行那个边界 在值超出窗口之前是恒等变换,而没有哪个场景会打出超出窗口的值;粘贴守卫、重试计数与 提交判定,全都要看单次 stdin 读取在那次提交的按键旁边还带了什么,那是 tty 的性质, From fef97a320ba164ae7200bb29c73a44ec4146ad19 Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 10:37:35 +0800 Subject: [PATCH 50/51] fix(cli): scope the answer row's re-arm, lock, width and caret cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retry counter bumped by an asynchronous verdict re-seeded whichever field happened to be mounted when the verdict landed, so a rejection arriving after the user had backed out discarded a caret parked in a field that never submitted. Only the step that fired the install may bump it now. The free-text row's editing branch was the one mutation of a multi-select answer that never asked whether a pause was still holding it, so letters typed inside that pause widened an answer Enter had already recorded. The branch asks now, and only on a multi-select, so a single-select field whose submit the pause rejected stays editable. The caret cell was the one span of either dialog's field drawn without sanitising, while both spans beside it went through it, so a bidi override a paste parked in a value was emitted raw from the cell the user is looking at and rewrote the direction of the row. Both fields sanitise all three spans; the submitted value keeps every code point. The row's width budget charged the confirmation box's margin and not its padding, so on a terminal of sixty columns or narrower the last two cells it drew — the caret cell among them — fell outside the box that contains them. The design document scopes Decision 39's premise to the payload the bound actually covers and records the four fixes, their mutations and the re-run machine leg as Decision 40. --- .../2026-09-10-opentui-parity-defect-sweep.md | 124 ++++++++++++++++-- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 91 ++++++++++++- .../cli/src/ui/opentui/dialogs-auth.test.tsx | 43 ++++++ packages/cli/src/ui/opentui/dialogs-auth.tsx | 24 +++- .../src/ui/opentui/dialogs-confirm.test.tsx | 64 +++++++++ .../cli/src/ui/opentui/dialogs-confirm.tsx | 12 +- 6 files changed, 338 insertions(+), 20 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index f23ac674c03..1ff7911cd36 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1575,14 +1575,19 @@ One review round found four ways this port still closed a door on the user, and piece of machinery that had stopped being reachable. All five are recorded here rather than folded into the decisions they qualify, because each was measured this round. -The question dialog's free-text answer was the one payload in that dialog with no width -and no row bound. ink holds the same field in a text input fifty cells wide and one row -tall, and the confirmation this dialog lives in has no body window of its own, so a -single long paste grew the dialog past the terminal height and pushed the options the -user still had to pick off the screen. The row now draws inside a window of its own — -fifty cells, less the label the row prints in front of it, on the width the dialog has — -and the caret cell is part of that: past the window it has nowhere to go, exactly as in -ink's fixed-width input. The collapsed echo of the same value is bounded the same way, +The question dialog's free-text answer was the one user-supplied payload in that dialog +with no width and no row bound. ink holds the same field in a text input fifty cells wide +and one row tall, and the confirmation this dialog lives in has no body window of its own, +so a single long paste grew the dialog past the terminal height and pushed the options the +user still had to pick off the screen. The bound therefore covers what the user types and +nothing else: the question itself, each option's label and each option's description are +model-authored and still render with neither a width nor a row bound, so a model returning +a long question or long descriptions grows the same dialog and pushes the same options off +the screen with no paste anywhere in it. That remainder is recorded under Follow-ups. + +The answer row now draws inside a window of its own — fifty cells, less the label the +row prints in front of it, on the width the dialog has — and the caret cell is part of +that: past the window it has nowhere to go, exactly as in ink's fixed-width input. The collapsed echo of the same value is bounded the same way, since what it prints is the value a paste can make arbitrarily long. The bound is on the drawing alone. Submission reads the stored value, so the answer keeps every character, including the line breaks a pasted paragraph carries. @@ -1670,6 +1675,94 @@ carries beside the key that submitted it, which is a property of the tty rather than of any script. Like the caret rule in Decision 35, these five rest on the unit suites. +## Decision 40 — the re-arm belongs to the step that submitted, and every cell of a field is sanitised + +A fourth review round found three ways the machinery the rounds before it added were still +wrong, and one column of arithmetic that had never been charged. All four are recorded here +rather than folded into the decisions they qualify, because each was measured this round. + +The retry counter Decision 39 added is bumped by an asynchronous verdict. Held in flight, +that verdict lands whenever the install finishes, which can be after the user has backed out +of the step that fired it and parked the caret mid-value in an earlier field; the bump then +re-seeds whichever field happens to be mounted, the caret the user parked jumps to the end of +the line, and the next Backspace deletes a character they did not mean to. The counter is now +bumped only while the dialog still stands on the step that fired the install: the step is +mirrored into a ref during render and read when the submit starts. What the guard scopes is +when the counter moves, never whether it moves on the step that failed — a rejected install +and an install that saved service models and no conversation model both still hand their own +step's field back, which is what the two tests Decision 39 pins. + +Decision 37 stopped the free-text row's insert branch on an answer the pause was holding, and +Decision 39 asked the same of the paste path. The row's editing branch as a whole was still +open, and it is not only an insert: the shared line model writes the value on a Backspace, a +Delete and a word-erase as well. On a multi-select the answer is re-assembled from the checked +set and the typed value when the review tab submits, so letters typed inside the pause widened +an answer Enter had already recorded. The branch now asks the lock, and asks it only on a +multi-select: a single-select field whose submit the pause rejected stays editable, which is +Decision 39's fourth item and what its own test pins. + +The caret cell was the one span of either dialog's field drawn without sanitising, while both +spans beside it went through it. The filter the line model applies on insert drops the C0 and +C1 controls and keeps a bidi override, so a bracketed paste can park one in a value, and the +cell the user is looking at then rewrote the direction of the rest of the row: the dialog +showed an answer that was not the one being submitted. Both fields now sanitise all three +spans. The bound is on the drawing, as it is for the width bound beside it: the submitted +value keeps every code point. + +The row's width budget charged the confirmation box's two columns of margin and not the two +columns of padding the same box spends, so on a terminal sixty columns wide or narrower the +last two cells it drew — the caret cell among them — fell outside the box that contains them, +and the renderer either clipped the caret or wrapped and grew the dialog by the row the bound +exists to save. The chip row's budget twenty lines above charges both. The row now charges +four, and fifty cells remains the binding cap wherever the terminal is wide enough for it, +mirroring ink's own field width. + +What this round did not change is the window's anchor. Once a value passes the window the +caret cell is dropped and further keystrokes move nothing on screen, and the truncation marker +is counted as a cell the caret can sit on, so the highlighted cell can draw the marker instead +of the character being edited. ink's field scrolls to the caret on the width axis, as +Decision 32's rule already has this port doing on the newline axis. Making the window follow +the caret is its own change with its own tests, recorded under Follow-ups. + +Five new unit tests — three in the confirmation dialog's suite and two in the authentication +one — and five mutations, one per behaviour named above. The two suites are a hundred tests +together and green with the fixes in place; each mutation fails exactly the test that owns the +behaviour it takes away and nothing else: + +| mutation | fails | +| -------------------------------------------------------- | ------------------------------------- | +| the lock's clause taken off the keystroke branch | the keystroke-trailing-an-answer test | +| the caret cell sanitised no longer (question dialog) | the bidi caret-cell test | +| the width budget back to the margin alone | the narrow-terminal test | +| the step guard taken off the re-arm | the late-verdict caret test | +| the field spans sanitised no longer (authentication one) | the bidi field test | + +The machine leg was re-run at this head: twenty-seven scenarios on both legs, fifty-four +runs, none of them erroring. Of the seventy-eight checkpoints, nineteen have identical +non-blank content rows and fifty-nine diverge, carrying 241 rows only ink draws and 285 +only this port draws once the two sampled checkpoints — the spinner's phrase rotation and +the mid-stream indicator, which hold elapsed time and a phrase chosen per tick — are +counted apart. Against the previous head, 143 of the 156 plain captures are +byte-identical with the version the banner prints normalised away. The thirteen that are +not divide into ten in three families whose content moves between two runs of the same +binary — the mid-stream indicator, `/stats` and the held-phrase row, on both legs, four of +the ten being the sample files — and three on the ink leg of the `@`-completion scenario, +which in this run did not print the extension-refresh notice the other leg printed. That +notice is the transient already recorded as a follow-up, and this is the first run in +which it landed on ink's side of the comparison rather than this port's. + +The identical-checkpoint count therefore reads nineteen where the previous head's read +twenty-two, and the whole of that movement is those three checkpoints. + +Nothing recorded in this decision moves a frame, and that is measured rather than argued: +no capture in any question-dialog or authentication scenario differs from the previous +head. The width budget binds only on a terminal of sixty columns or narrower holding a +value long enough to reach the cap, and the one fifty-eight-column scenario captures the +chip row before any free-text row is opened. The step guard, the lock's new term and the +two sanitising spans all turn on what arrives beside a key, or on which code point the +caret happens to sit on, and no scenario produces either. Like the five items of Decision +39, these four rest on the unit suites. + ## Coverage boundary What was verified, and how far the verification reaches: @@ -2076,3 +2169,18 @@ What was verified, and how far the verification reaches: the viewport reserve and the description floor are the same five rows, so its probe passed with the floor deleted. It now pins both heights, and deleting the floor fails it. +- The question dialog's answer window is anchored at the head of the value, so + past the window the caret cell is not drawn and keystrokes move nothing on + screen while Enter still submits the whole value, and the truncation marker + counts as a cell the caret can sit on. ink's field scrolls to the caret on the + width axis, as Decision 32's rule has this port doing on the newline axis. + Following the caret here means deriving the caret from the pre-truncation line + and a window offset rather than from the truncated string, which is its own + change with its own tests. +- The question dialog bounds what the user types and nothing else. The question + text, each option label and each option description are model-authored and + render with neither a width nor a row bound, so a long question or long + descriptions grow the dialog past the terminal height and push the options + still to be picked off the screen with no user input involved — the outcome + Decision 39's bound exists to prevent, reachable from a payload it does not + cover. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index fd7201bc96d..874a28ca808 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -1131,10 +1131,15 @@ focusable,记在后续工作里。 有一轮评审翻出四处本移植仍然把门在用户面前关上的地方,外加一处已经不再可达的机构。五 处都记在这里,而不是折进它们各自修饰的那条决定,因为每一处都是本轮量出来的。 -提问对话框的自由文本答案,是那个对话框里唯一一处既没有宽度也没有行界的载荷。ink 把同 -一个字段放在一个五十格宽、一行高的文本输入里,而这个对话框所在的确认框自己也没有正文 -窗口,于是一次长粘贴会把对话框撑到超过终端高度,把用户还要去选的那些选项推出屏幕。那 -一行现在画在自己的窗口里 —— 五十格,减去这一行在它前面印出的标签,落在对话框现有的宽 +提问对话框的自由文本答案,是那个对话框里唯一一处由用户提供、却既没有宽度也没有行界的载 +荷。ink 把同一个字段放在一个五十格宽、一行高的文本输入里,而这个对话框所在的确认框自己 +也没有正文窗口,于是一次长粘贴会把对话框撑到超过终端高度,把用户还要去选的那些选项推出 +屏幕。因此这道界只覆盖用户打出来的内容,别的都不覆盖:问题本身、每个选项的标签、每个选 +项的描述都是模型生成的,至今既没有宽度也没有行界,所以模型返回一个较长的问题或较长的描 +述,同样会把这个对话框撑高、把同样那些选项推出屏幕,而其中没有任何用户输入。剩下的这部 +分记在「后续工作」里。 + +那一行现在画在自己的窗口里 —— 五十格,减去这一行在它前面印出的标签,落在对话框现有的宽 度上 —— 而光标那一格也在其中:超出窗口它就无处可去,和 ink 那个定宽输入完全一样。同一 个值折叠后的回显以同样的方式设界,因为它印出的正是粘贴可以任意拉长的那个值。界只设在 画上。提交读的是存下来的那个值,所以答案保住每一个字符,包括一段粘贴文字自带的那些换 @@ -1203,6 +1208,75 @@ head 相比,152 个普通抓取里有 146 个逐字节一致,而不一致的 提交判定,全都要看单次 stdin 读取在那次提交的按键旁边还带了什么,那是 tty 的性质, 不是任何脚本的性质。与决定 35 的光标规则一样,这五项都只由单元测试套件兜着。 +## 决定 40 —— 重新武装属于提交它的那一步,而字段的每一格都要净化 + +第四轮评审翻出三处此前几轮加进来的机构仍然不对的地方,外加一列从来没被计入的算术。四 +处都记在这里,而不是折进它们各自修饰的那条决定,因为每一处都是本轮量出来的。 + +决定 39 加进来的重试计数器,是由一个异步判定触发加一的。把那次安装挂在飞行中,判定就在 +安装结束的那一刻落地,而那可能已经是用户退出触发它的那一步、把光标停在更早某个字段中间 +之后了;这一次加一会重新播种当时恰好挂载的那个字段,用户停下的光标跳到行尾,接下来的 +一次 Backspace 就删掉一个他并非有意删的字符。现在只有当对话框仍然站在发出那次安装的那 +一步上时,计数器才会加一:那一步在渲染期间被镜像进一个 ref,提交开始时读它。这道守卫限 +定的是计数器何时移动,绝不是失败的那一步是否移动它 —— 一次被拒绝的安装,与一次只存下服 +务模型而没有会话模型的安装,都仍然把它们自己那一步的字段交还回去,这正是决定 39 钉住 +的那两条测试。 + +决定 37 让自由文本行的插入分支在一个暂停仍在握着的回答前停下,决定 39 又向粘贴路径要了 +同一件事。整条编辑分支当时仍然是开的,而它不只是插入:共享的行模型在 Backspace、Delete +与删词上同样会写值。在多选上,答案是复核标签提交时由勾选集与打出来的值重新拼出来的, +所以暂停里打进去的字母会加宽一个 Enter 早已记下的答案。现在这条分支会去问那把锁,并且 +只在多选上去问:一次被暂停拒绝提交的单选字段仍然可编辑,那是决定 39 的第四项,也有它自 +己的测试钉着。 + +光标那一格,是两个对话框字段里唯一一处不经过净化就画出来的片段,而它两边的片段都经过 +了。行模型在插入时施加的过滤会丢掉 C0 与 C1 控制符,却留下双向控制符,所以一次 +bracketed paste 就能把它停在一个值里;用户正在注视的那一格于是改写了这一行余下部分的 +方向:对话框显示出来的答案,不是即将提交的那个答案。现在两个字段的全部三段都净化。界只 +设在画上,与它旁边那道宽度界一样:提交的值保住每一个码点。 + +那一行的宽度预算计入了确认框的两列外边距,却没计入同一个框花掉的两列内边距,于是在六 +十列或更窄的终端上,它画出的最后两格 —— 光标那一格正在其中 —— 落在装着它们的框体之 +外,渲染器要么把光标裁掉,要么折行、把对话框撑高一行,而这正是那道界存在要省下的那一 +行。上方二十行处 chip 行的预算两者都计入了。这一行现在计入四列,而在终端足够宽时五十 +格仍然是生效上限,与 ink 自己的字段宽度一致。 + +本轮没有改动的是窗口的锚点。值一旦超出窗口,光标那一格就不再画出,后续按键在屏幕上不 +再移动任何东西,而截断标记被算作光标可以停的一格,于是高亮格可能画出那个标记,而不是 +正在被编辑的字符。ink 的字段在宽度轴上滚动到光标处,正如决定 32 的规则已经让本移植在 +换行轴上做的那样。让窗口跟着光标走,是它自己的一次改动、带它自己的测试,记在「后续工 +作」里。 + +五条新单元测试 —— 确认对话框套件里三条,认证对话框套件里两条 —— 以及五个变异,上面每 +一项行为一个。两个套件合起来一百条测试,修复就位时全绿;每个变异恰好让拥有它所拿走行 +为的那条测试变红,其余不动: + +| 变异 | 变红 | +| ------------------------------ | -------------------------- | +| 按键分支上摘掉锁的那一条 | 按键跟在答案之后的那条测试 | +| 光标格不再净化(提问对话框) | 双向控制符光标格测试 | +| 宽度预算退回只计外边距 | 窄终端测试 | +| 重新武装上摘掉步骤守卫 | 迟到判定的光标测试 | +| 字段三段不再净化(认证对话框) | 双向控制符字段测试 | + +机器腿在这个 head 上重跑了一遍:二十七个场景、两条腿、五十四次运行,没有一次报错。七十八 +个检查点里,十九个的非空内容行完全一致,五十九个分歧;把两个抽样检查点 —— spinner 的短语轮 +换与流中指示符,它们装的是流逝的时间和每次 tick 挑出的短语 —— 单独计开之后,分歧带着 241 +行只有 ink 画、285 行只有本移植画。与上一个 head 相比,把 banner 印出的版本号归一化掉之后, +156 个普通抓取里有 143 个逐字节一致。不一致的十三个分成两类:十个属于三族在同一个二进制的 +两次运行之间会变的内容 —— 流中指示符、`/stats` 与按住短语那一行,两条腿各一份,其中四个是 +抽样文件;另外三个在 `@` 补全场景的 ink 腿上,这一次运行它没有印出另一条腿印出的那条扩展刷 +新提示。那条提示是已经记在后续工作里的瞬时项,而这是它第一次落在对比的 ink 一侧,而不是本 +移植一侧。 + +于是完全一致的检查点数读作十九,而上一个 head 读作二十二,而这整段移动就是那三个检查点。 + +本决定记下的内容没有一处移动了帧,而这一点是量出来的,不是推出来的:提问对话框与认证场景 +里没有任何一个抓取与上一个 head 不同。宽度预算只在六十列或更窄、且值长到够得着上限的终端 +上才生效,而唯一一个五十八列的场景在打开任何自由文本行之前就抓取了 chip 行。步骤守卫、锁的 +新条件与两处净化片段,全都要看一个按键旁边还到了什么、或者光标恰好停在哪个码点上,而没有任何 +场景造出这两件事中的任一件。与决定 39 的五项一样,这四项都只由单元测试套件兜着。 + ## 覆盖边界 验证了什么,以及验证到哪一层: @@ -1483,3 +1557,12 @@ head 相比,152 个普通抓取里有 146 个逐字节一致,而不一致的 第七处已经重跑并闭合:待处理卡片那个用例曾被调到一个高度,在那里视口预留值与描述下限 是同样的五行,于是删掉那个下限它的探测值仍然通过。它现在同时钉住两个高度,删掉下限会 让它变红。 +- 提问对话框的答案窗口锚在值的开头,所以超出窗口之后光标那一格不再画出、按键在屏幕上不再 + 移动任何东西,而 Enter 仍然提交整个值;截断标记还被算作光标可以停的一格。ink 的字段在宽 + 度轴上滚动到光标处,正如决定 32 的规则让本移植在换行轴上做的那样。在这里跟着光标走,意 + 味着由截断前的那一行加一个窗口偏移推出光标,而不是由截断后的字符串推出,那是它自己的一 + 次改动、带它自己的测试。 +- 提问对话框只给用户打出来的内容设界。问题文本、每个选项标签与每个选项描述都是模型生成 + 的,既没有宽度也没有行界,所以一个较长的问题或较长的描述会把对话框撑过终端高度、把还要 + 去选的选项推出屏幕,其中没有任何用户输入 —— 那正是决定 39 那道界要防止的结果,而它可以 + 从一个那道界不覆盖的载荷到达。 diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index 31ddc93763d..9a7ad0b8f1f 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -986,6 +986,49 @@ describe('caret editing in dialog text fields (#107)', () => { await press('down'); expect(focusedField()).toEqual({ text: '1234', cell: '4' }); }); + + it('keeps the caret a late install verdict finds parked in an earlier field', async () => { + // The install is async, so its verdict can land after the user has Esc'd back + // to a field that never submitted. Re-arming the latch there re-seeds that + // field from its value and throws the parked caret away, so the next + // Backspace deletes a character the user did not mean to. + let rejectInstall: (error: Error) => void = () => {}; + core.applyProviderInstallPlan.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectInstall = reject; + }), + ); + await runPresetFlowToTerminalModels(); + await press('return'); // models: fires the install, which stays in flight + await pressEsc(); // back to the API-key step + await press('left'); + await press('left'); + expect(focusedField()).toEqual({ text: 'sk-test', cell: 's' }); + await act(async () => { + rejectInstall(new Error('401')); + }); + await vi.waitFor(() => { + expect(screen.getByText(/Failed to authenticate/)).toBeTruthy(); + }); + expect(focusedField()).toEqual({ text: 'sk-test', cell: 's' }); + await press('backspace'); + expect(focusedField()).toEqual({ text: 'sk-tst', cell: 's' }); + }); + + it('draws no bidi override out of a field a paste put one in', async () => { + // stripUnsafeCharacters keeps U+202E, so a bracketed paste can park a + // RIGHT-TO-LEFT OVERRIDE in a field's value. The value holds every code + // point; the cell the caret sits on must not emit one raw and rewrite the + // direction of the row the user is reading. + await runToBaseUrlStep(); + await pasteText('ab\u202ecd'); + await press('left'); + await press('left'); + await press('left'); + expect(focusedField()).toEqual({ text: 'ab cd', cell: ' ' }); + expect(document.body.textContent).not.toContain('\u202e'); + }); }); describe('recommended-model checkboxes out of one read (#113)', () => { diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index 14033caf271..302fe05772e 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -52,11 +52,13 @@ import { ICON } from '../constants.js'; import { useProviderSetupFlow, type ProviderSetupFlow, + type SetupStep, } from '../auth/useProviderSetupFlow.js'; import { normalizeModelIds } from '../auth/useAuth.js'; import { toOriginalKey } from './key-map.js'; import { isPrintableKeyInput } from './input-prompt-key.js'; import { normalizePastedText } from './input-prompt-model.js'; +import { sanitizeTerminalText } from '../utils/textUtils.js'; import { caretSpans, useLineEdit } from './line-edit.js'; import { Shell } from './dialogs-misc.js'; import { C } from './theme.js'; @@ -242,16 +244,16 @@ function FieldText({ const spans = caretSpans({ text: value, cursor: caret }); return ( <> - {spans.before} + {sanitizeTerminalText(spans.before)} {/* The character belongs to the value whatever owns the focus; only the highlight says which field the caret is in. Dropping it while inactive would print a value one character shorter than the one held. */} {active ? ( - {spans.at || ' '} + {sanitizeTerminalText(spans.at) || ' '} ) : ( - {spans.at} + {sanitizeTerminalText(spans.at)} )} - {spans.after} + {sanitizeTerminalText(spans.after)} ); } @@ -905,6 +907,11 @@ function AuthDialogFlow({ // conversation model — leaves the very same step on screen with every key // dead. Bumping this hands the field back. const [retrySeq, setRetrySeq] = useState(0); + // The verdict lands whenever the install finishes, which can be after the user + // has Esc'd back to an earlier field. Re-arming there would re-seed a field + // that never submitted and discard the caret parked in it, so only the step + // that fired the install may bump. + const stepRef = useRef(null); const [viewLevel, setViewLevel] = useState('main'); const [_viewStack, setViewStack] = useState([]); const [mainIndex, setMainIndex] = useState(null); @@ -916,6 +923,10 @@ function AuthDialogFlow({ const handleProviderSubmit = useCallback( async (providerConfig: ProviderConfig, inputs: ProviderSetupInputs) => { const protocol = inputs.protocol ?? providerConfig.protocol; + const stepAtSubmit = stepRef.current; + const reArmField = () => { + if (stepRef.current === stepAtSubmit) setRetrySeq((n) => n + 1); + }; try { const plan = buildInstallPlan( providerConfig, @@ -939,7 +950,7 @@ function AuthDialogFlow({ 'Service models saved. Configure a conversation model to start chatting.', ), ); - setRetrySeq((n) => n + 1); + reArmField(); return; } notify?.( @@ -960,7 +971,7 @@ function AuthDialogFlow({ message: getErrorMessage(error), }); setErrorMessage(msg); - setRetrySeq((n) => n + 1); + reArmField(); logAuth(config, new AuthEvent(protocol, 'manual', 'error', msg)); } }, @@ -968,6 +979,7 @@ function AuthDialogFlow({ ); const setupFlow = useProviderSetupFlow(handleProviderSubmit); + stepRef.current = setupFlow.state.step; // -- Navigation (AuthDialog parity) --------------------------------------- diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 1920884a7a3..9abb6a54767 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -692,6 +692,52 @@ describe('OpenTuiToolConfirmation', () => { ); }); + it('draws no bidi override out of the caret cell a paste put one in', () => { + // stripUnsafeCharacters keeps U+202E, so a bracketed paste can park a + // RIGHT-TO-LEFT OVERRIDE in the value; drawn raw from the one cell the user + // is looking at, it rewrites the direction of the rest of the row and the + // dialog shows an answer that is not the one being submitted. + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('ab\u202ecd'); + press({ name: 'left' }); + press({ name: 'left' }); + press({ name: 'left' }); + // Sanitising leaves the cell empty, so it draws the blank fallback. + expect(cursorCell(container)).toBe(' '); + expect(container.textContent ?? '').not.toContain('\u202e'); + expect(container.textContent ?? '').toContain('ab'); + expect(container.textContent ?? '').toContain('cd'); + // The bound is on the drawing only: the answer keeps every code point. + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'ab\u202ecd' } }, + ); + }); + + it('keeps the long answer inside the box on a narrow terminal', () => { + // The inline confirmation spends two columns of margin and two of padding, + // so at 58 columns the row has 54 and the 7-cell prefix leaves a 47-cell + // cap. The window draws one cell less than the cap it is handed and spends + // that cell on the ellipsis, so 45 x's and the marker. Charging only the + // margin lets the last two cells — the caret cell among them — fall + // outside the box. + mocks.state.dimensions = { width: 58, height: 40 }; + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('x'.repeat(120)); + expect(container.textContent ?? '').toContain('x'.repeat(45) + '…'); + expect(container.textContent ?? '').not.toContain('x'.repeat(46)); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'x'.repeat(120) } }, + ); + }); + it('keeps every character of a burst that shares one batch', () => { const onConfirm = vi.fn(async () => {}); const container = mount(askDetails(twoOptions, onConfirm)); @@ -1207,6 +1253,24 @@ describe('OpenTuiToolConfirmation', () => { expect(container.textContent ?? '').not.toContain('mail, xyz'); }); + it('drops the keystrokes that trail an answer the pause is still holding', () => { + // The same window as the paste above, reached by typing: the field is still + // drawn and focused, and a multi-select answer is recomputed from the typed + // value, so letters accepted here widen an answer Enter already recorded. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'return', sequence: '\r' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('xyz'); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: mail'); + expect(container.textContent ?? '').not.toContain('mail, xyz'); + }); + it('keeps a free-text row editable whose submit the pause rejected', () => { // Nothing was recorded, so the row has not submitted and the letters // trailing that Enter in the same read still belong to it. Latched diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 61ec73da6c5..ac9faec8f6a 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -914,6 +914,10 @@ function AskUserQuestionFlow(props: { submitCustomRow(); } else if ( fieldIsMine() && + // Only a multi-select answer is recomputed from the typed value, so + // only one can be widened after its Enter has been recorded. A + // single-select field whose submit the pause rejected stays editable. + !(multi && answerIsLocked()) && !custom.handleKey(original) && isPrintableKeyInput(key) ) { @@ -1097,7 +1101,9 @@ function AskUserQuestionFlow(props: { 1, Math.min( CUSTOM_FIELD_WIDTH, - width - 2 - getCachedStringWidth(`❯ ${customMark}${customLabel}> `), + // The inline confirmation's own two columns of margin and two of padding; + // `headerCap` charges the same four, split across `rowOverhead`. + width - 4 - getCachedStringWidth(`❯ ${customMark}${customLabel}> `), ), ); const customField = customFieldWindow( @@ -1167,7 +1173,9 @@ function AskUserQuestionFlow(props: { cell has nowhere to go, exactly as in ink's fixed-width TextInput. */} {customField.caretInside ? ( - {customField.at || ' '} + + {sanitizeTerminalText(customField.at) || ' '} + ) : null} {sanitizeTerminalText(customField.after)} From f69b0bef16f1fddd3648a6d22f9b338172b1649b Mon Sep 17 00:00:00 2001 From: chiga0 Date: Thu, 17 Sep 2026 10:49:43 +0800 Subject: [PATCH 51/51] docs: attribute the answer lock to the branches Decision 37 put it on Decision 40 said Decision 37 had stopped the free-text row's insert branch on a locked answer. It had not: the lock went on the digit branch and a multi-select's Space, Decision 39 extended it to the paste path, and the editing branch was left to the field's own latch, which arms only on an Enter that moved the dialog. An Enter on the option row arms the pause without arming that latch, which is the gap this round closed. Also re-wrap the answer-row paragraph Decision 39's scoped premise left one line past the document's width. --- .../2026-09-10-opentui-parity-defect-sweep.md | 24 +++++++++++-------- ...09-10-opentui-parity-defect-sweep.zh-CN.md | 14 ++++++----- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 1ff7911cd36..13bd9a5f4e3 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -1587,10 +1587,11 @@ the screen with no paste anywhere in it. That remainder is recorded under Follow The answer row now draws inside a window of its own — fifty cells, less the label the row prints in front of it, on the width the dialog has — and the caret cell is part of -that: past the window it has nowhere to go, exactly as in ink's fixed-width input. The collapsed echo of the same value is bounded the same way, -since what it prints is the value a paste can make arbitrarily long. The bound is on the -drawing alone. Submission reads the stored value, so the answer keeps every character, -including the line breaks a pasted paragraph carries. +that: past the window it has nowhere to go, exactly as in ink's fixed-width input. The +collapsed echo of the same value is bounded the same way, since what it prints is the +value a paste can make arbitrarily long. The bound is on the drawing alone. Submission +reads the stored value, so the answer keeps every character, including the line breaks +a pasted paragraph carries. A bracketed paste is one event with no keypress per character, and the handler that took it asked the render which drew the row whether the cursor was on it, while writing under @@ -1692,12 +1693,15 @@ when the counter moves, never whether it moves on the step that failed — a rej and an install that saved service models and no conversation model both still hand their own step's field back, which is what the two tests Decision 39 pins. -Decision 37 stopped the free-text row's insert branch on an answer the pause was holding, and -Decision 39 asked the same of the paste path. The row's editing branch as a whole was still -open, and it is not only an insert: the shared line model writes the value on a Backspace, a -Delete and a word-erase as well. On a multi-select the answer is re-assembled from the checked -set and the typed value when the review tab submits, so letters typed inside the pause widened -an answer Enter had already recorded. The branch now asks the lock, and asks it only on a +Decision 37 put the answer lock on the two branches that report an answer — a digit on the +option branch, and the Space a multi-select rebuilds its answer from — and Decision 39 asked +the same of the paste path. The row's editing branch was left to the field's own latch, which +arms only on an Enter that moved the dialog: an answer recorded by an Enter on the option row +arms the pause without arming that latch, so the branch stayed open for the whole of it. And +it is not only an insert — the shared line model writes the value on a Backspace, a Delete and +a word-erase as well. On a multi-select the answer is re-assembled from the checked set and +the typed value when the review tab submits, so letters typed inside the pause widened an +answer Enter had already recorded. The branch now asks the lock, and asks it only on a multi-select: a single-select field whose submit the pause rejected stays editable, which is Decision 39's fourth item and what its own test pins. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 874a28ca808..4915d2a31a7 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -1222,12 +1222,14 @@ head 相比,152 个普通抓取里有 146 个逐字节一致,而不一致的 务模型而没有会话模型的安装,都仍然把它们自己那一步的字段交还回去,这正是决定 39 钉住 的那两条测试。 -决定 37 让自由文本行的插入分支在一个暂停仍在握着的回答前停下,决定 39 又向粘贴路径要了 -同一件事。整条编辑分支当时仍然是开的,而它不只是插入:共享的行模型在 Backspace、Delete -与删词上同样会写值。在多选上,答案是复核标签提交时由勾选集与打出来的值重新拼出来的, -所以暂停里打进去的字母会加宽一个 Enter 早已记下的答案。现在这条分支会去问那把锁,并且 -只在多选上去问:一次被暂停拒绝提交的单选字段仍然可编辑,那是决定 39 的第四项,也有它自 -己的测试钉着。 +决定 37 把那把答案锁加在两条上报答案的分支上 —— 选项分支上的数字键,与多选据以重算自己 +答案的那个 Space —— 决定 39 又向粘贴路径要了同一件事。这一行的编辑分支被留给字段自己的那 +把闩,而那把闩只在一次让对话框移动了的 Enter 上武装:一次在选项行上按下、记下答案的 Enter +武装了那个暂停,却没有武装这把闩,于是整段暂停里这条分支都是开的。而它不只是插入:共享的 +行模型在 Backspace、Delete 与删词上同样会写值。在多选上,答案是复核标签提交时由勾选集与打 +出来的值重新拼出来的,所以暂停里打进去的字母会加宽一个 Enter 早已记下的答案。现在这条分支 +会去问那把锁,并且只在多选上去问:一次被暂停拒绝提交的单选字段仍然可编辑,那是决定 39 的 +第四项,也有它自己的测试钉着。 光标那一格,是两个对话框字段里唯一一处不经过净化就画出来的片段,而它两边的片段都经过 了。行模型在插入时施加的过滤会丢掉 C0 与 C1 控制符,却留下双向控制符,所以一次