From 019ddc8e9e4dc51158399da858848b76f340b1a3 Mon Sep 17 00:00:00 2001 From: Jover Date: Fri, 4 Sep 2026 14:07:02 +0800 Subject: [PATCH 01/12] fix(cli): show setup cursor only on active field Strip Editor styling from inactive onboarding fields so pi-tui's synthetic reverse-video cursor is not rendered twice. Cover the Name-to-Slug transition with field-specific cursor assertions. Generated-by: Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 17 +++++++++++++++++ packages/cli/src/pi-tui-pickers.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2c633b4642..a980afae21 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1691,6 +1691,15 @@ describe('Maka Pi TUI runner', () => { const driver = new SlashCommandDriver(); const verifyCalls: OnboardingVerifyInput[] = []; const saveCalls: OnboardingSaveInput[] = []; + const editorEndCursor = '\x1b[7m \x1b[0m'; + const identityFieldLine = (expectedText: string) => { + return ( + terminal.writes + .flatMap((write) => write.split('\n')) + .reverse() + .find((line) => plainTerminalOutput(line).trim() === expectedText) ?? '' + ); + }; const run = runMakaPiTui({ title: 'Maka', driver, @@ -1727,10 +1736,18 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); terminal.input('\r'); // pick the only row -> identity step, name focused await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); + assert.equal(identityFieldLine('Name OpenAI').includes(editorEndCursor), true); + assert.equal(identityFieldLine('Slug openai').includes(editorEndCursor), false); // Replace the prefilled provider label with a display name. for (let i = 0; i < 'OpenAI'.length; i++) terminal.input('\x7f'); terminal.input('Work OpenAI'); terminal.input('\r'); // name -> slug field + await waitFor( + () => identityFieldLine('Slug openai').includes(editorEndCursor), + 'the identity cursor to move from Name to Slug', + ); + assert.equal(identityFieldLine('Name Work OpenAI').includes(editorEndCursor), false); + assert.equal(identityFieldLine('Slug openai').includes(editorEndCursor), true); // Replace the derived suggestion with a chosen slug. for (let i = 0; i < 'openai'.length; i++) terminal.input('\x7f'); terminal.input('openai-work'); diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index cf086d6c4a..986c3aa5e5 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -1871,10 +1871,14 @@ export class OnboardingWizard implements Component { const prefixWidth = visibleWidth(prefix); const contentWidth = Math.max(1, width - prefixWidth); const editorLines = editor.render(contentWidth).slice(1, -1); + // pi-tui's Editor always paints its fake cursor; `focused` only controls + // the hardware-cursor marker used for IME placement. Remove styling from + // inactive fields while preserving the Editor's wrapping and scrolling. + const renderedLines = editor.focused ? editorLines : editorLines.map(stripAnsi); if (editorLines.length === 0) { return [padLine(prefix, width)]; } - return editorLines.map((line, index) => + return renderedLines.map((line, index) => padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), ); } From 505f4a15c3ff7ac90a4a349711ba3543f4a3173a Mon Sep 17 00:00:00 2001 From: Jover Date: Tue, 8 Sep 2026 10:06:43 +0800 Subject: [PATCH 02/12] fix(cli): hide cursors in unfocused editors Share the pi-tui cursor compatibility rendering across inline fields and the main composer. Preserve drafts, borders, and other styles when focus moves to another field or overlay. Cover wizard field switching, Other answer focus and combining characters, and composer focus restoration after an overlay closes. Generated-by: Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 12 ++--- .../pi-tui-user-question-option.test.ts | 42 ++++++++++++++- .../__tests__/tui-autocomplete-layout.test.ts | 42 ++++++++++++++- packages/cli/src/pi-tui-pickers.ts | 54 ++++++------------- packages/cli/src/tui-autocomplete-layout.ts | 3 +- packages/cli/src/tui-editor-render.ts | 30 +++++++++++ 6 files changed, 137 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/tui-editor-render.ts diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index ed3b12eafe..02b7cf1b9b 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1693,12 +1693,12 @@ describe('Maka Pi TUI runner', () => { const saveCalls: OnboardingSaveInput[] = []; const editorEndCursor = '\x1b[7m \x1b[0m'; const identityFieldLine = (expectedText: string) => { - return ( - terminal.writes - .flatMap((write) => write.split('\n')) - .reverse() - .find((line) => plainTerminalOutput(line).trim() === expectedText) ?? '' - ); + const line = terminal.writes + .flatMap((write) => write.split('\n')) + .reverse() + .find((line) => plainTerminalOutput(line).trim() === expectedText); + assert.ok(line, `expected a rendered field: ${expectedText}`); + return line; }; const run = runMakaPiTui({ title: 'Maka', diff --git a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts index afa08df148..40d077a77c 100644 --- a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts +++ b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER, TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui'; import type { TUI } from '@earendil-works/pi-tui'; import { clampRowsWithEllipsis, @@ -27,12 +27,52 @@ import { UserQuestionOverlay, } from '../pi-tui-pickers.js'; import { ansi, stripAnsi } from '../tui-ansi.js'; +import { FakeTerminal, plainTerminalOutput } from './tui-terminal-mock.js'; // SGR reverse degrades to identity when the terminal reports no color support // (piped CI), so the highlight assertion keys off this build's actual behavior. const REVERSE_ON = '\u001b[7m'; const COLOR_ENABLED = ansi.reverse('').length > 0; +test('Other keeps its draft and shows a cursor only while its input row is selected', () => { + const draft = 'custom e\u0301'; + const answers: string[] = []; + const overlay = new UserQuestionOverlay(new TuiMainScreen(new FakeTerminal()), { + title: 'Pick one', + rightLabel: '1 / 1', + hint: '↑↓ move · type to answer', + placeholder: 'Other: type answer', + options: [{ label: 'Preset' }], + onSelectOption: () => undefined, + onSubmitText: (value) => answers.push(value), + onSkip: () => undefined, + }); + const inputLine = () => { + const row = overlay.render(40).find((line) => plainTerminalOutput(line).includes(draft)); + assert.ok(row, 'the typed answer must remain visible'); + return row; + }; + + overlay.handleInput(draft); // Typing on a preset jumps to Other. + const focused = inputLine(); + assert.ok(focused.includes(REVERSE_ON)); + assert.ok(focused.includes(CURSOR_MARKER)); + + overlay.handleInput('\x1b[A'); // Other -> preset. + assert.ok(!inputLine().includes(REVERSE_ON), 'the inactive input must hide its cursor'); + assert.ok(!inputLine().includes(CURSOR_MARKER), 'the inactive input must not anchor the IME'); + overlay.handleInput('\x1b[B'); + assert.equal(inputLine(), focused, 'refocus restores the cursor and IME position'); + + overlay.handleInput('\x1b[D'); // Put the cursor on e + combining accent, not a trailing space. + assert.ok(inputLine().includes(`${REVERSE_ON}e\u0301`)); + overlay.handleInput('\x1b[A'); + assert.ok(!inputLine().includes(REVERSE_ON), 'a cursor on a character must also disappear'); + overlay.handleInput('\x1b[B'); + overlay.handleInput('\r'); + assert.deepEqual(answers, [draft]); +}); + test('long options wrap within the row width instead of truncating (#4610)', () => { const option = { label: '默认省略 + 优雅降级(推荐)', diff --git a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts index 3b28dea4f4..37e7e3b41b 100644 --- a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts +++ b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts @@ -19,8 +19,48 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { fitAutocompleteLines } from '../tui-autocomplete-layout.js'; +import { Editor, Text, TuiMainScreen } from '@earendil-works/pi-tui'; +import { + fitAutocompleteLines, + MakaAutocompleteAboveEditorComponent, +} from '../tui-autocomplete-layout.js'; import { fitPendingQueueLines } from '../pi-tui-layout.js'; +import { editorTheme } from '../tui-ansi.js'; +import { FakeTerminal } from './tui-terminal-mock.js'; + +const REVERSE_ON = '\x1b[7m'; +const CYAN_FOREGROUND = '\x1b[36m'; +const RESET_FOREGROUND = '\x1b[39m'; + +test('an overlay hides the composer cursor, preserves its draft and colors, and restores focus', (t) => { + const terminal = new FakeTerminal(); + const tui = new TuiMainScreen(terminal); + t.after(() => tui.stop()); + const editor = new Editor(tui, { + ...editorTheme(), + // Emit color even under NO_COLOR so the test can detect accidental style loss. + borderColor: (text) => `${CYAN_FOREGROUND}${text}${RESET_FOREGROUND}`, + }); + editor.setText('draft'); + const composer = new MakaAutocompleteAboveEditorComponent(editor); + tui.addChild(composer); + tui.setFocus(composer); + const renderScreen = () => { + terminal.writes.length = 0; + tui.renderNow(true); + return terminal.output(); + }; + + assert.ok(renderScreen().includes(REVERSE_ON)); + const overlay = tui.showOverlay(new Text('Picker', 0, 0), { anchor: 'top-left' }); + const screen = renderScreen(); + assert.ok(screen.includes('Picker')); + assert.ok(screen.includes('draft')); + assert.ok(screen.includes(CYAN_FOREGROUND), 'unfocused editor borders must keep their color'); + assert.ok(!screen.includes(REVERSE_ON), 'the inactive composer must not show a block cursor'); + overlay.hide(); + assert.ok(renderScreen().includes(REVERSE_ON)); +}); describe('fitAutocompleteLines', () => { test('keeps the selected item visible and reports the full command count', () => { diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 986c3aa5e5..1a2e775d7c 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -62,6 +62,7 @@ import type { OnboardingRejectionReason, } from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; +import { renderEditorWithFocus } from './tui-editor-render.js'; import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; interface TuiPickerCopy { @@ -672,23 +673,12 @@ export class UserQuestionOverlay implements Component { private renderInputRow(width: number): string[] { const prefix = this.onInputRow ? '→ ' : ' '; - const contentWidth = Math.max(1, width - USER_QUESTION_ROW_PREFIX_WIDTH); - // Focused only while the input row is highlighted: that both shows the block - // cursor and emits the hardware-cursor marker (#1064) so IME candidate windows - // anchor to the edited text instead of the terminal bottom. + // Focus controls the IME marker and our cursor-visibility adapter (#1064). this.editor.focused = this.onInputRow; if (!this.onInputRow && this.editor.getText().length === 0) { return [padLine(`${prefix}${ansi.dim(this.input.placeholder)}`, width)]; } - // Drop the editor's own top/bottom border rows; keep just its content lines - // so the answer reads as one row of the list. - const editorLines = this.editor.render(contentWidth).slice(1, -1); - if (editorLines.length === 0) { - return [padLine(`${prefix}${ansi.dim(this.input.placeholder)}`, width)]; - } - return editorLines.map((line, index) => - padLine(`${index === 0 ? prefix : ' '}${line}`, width), - ); + return renderEditorRow(this.editor, prefix, width); } } @@ -922,16 +912,7 @@ export class ModelSearchOverlay implements Component { } private renderFieldRow(editor: Editor, label: string, width: number): string[] { - const prefix = `${label} `; - const prefixWidth = visibleWidth(prefix); - const contentWidth = Math.max(1, width - prefixWidth); - const editorLines = editor.render(contentWidth).slice(1, -1); - if (editorLines.length === 0) { - return [padLine(prefix, width)]; - } - return editorLines.map((line, index) => - padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), - ); + return renderEditorRow(editor, `${label} `, width); } } @@ -1069,6 +1050,18 @@ function padLine(text: string, width: number): string { return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; } +function renderEditorRow(editor: Editor, prefix: string, width: number): string[] { + const prefixWidth = visibleWidth(prefix); + const contentWidth = Math.max(1, width - prefixWidth); + // These inline editors have no autocomplete rows. Keep Editor's wrapping and + // scrolling, but omit its top/bottom borders. + const editorLines = renderEditorWithFocus(editor, contentWidth).slice(1, -1); + if (editorLines.length === 0) return [padLine(prefix, width)]; + return editorLines.map((line, index) => + padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), + ); +} + function keyEntryHint( copy: TuiPickerCopy, hasConnection: boolean, @@ -1867,19 +1860,6 @@ export class OnboardingWizard implements Component { } private renderFieldRow(editor: Editor, label: string, width: number): string[] { - const prefix = `${label} `; - const prefixWidth = visibleWidth(prefix); - const contentWidth = Math.max(1, width - prefixWidth); - const editorLines = editor.render(contentWidth).slice(1, -1); - // pi-tui's Editor always paints its fake cursor; `focused` only controls - // the hardware-cursor marker used for IME placement. Remove styling from - // inactive fields while preserving the Editor's wrapping and scrolling. - const renderedLines = editor.focused ? editorLines : editorLines.map(stripAnsi); - if (editorLines.length === 0) { - return [padLine(prefix, width)]; - } - return renderedLines.map((line, index) => - padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), - ); + return renderEditorRow(editor, `${label} `, width); } } diff --git a/packages/cli/src/tui-autocomplete-layout.ts b/packages/cli/src/tui-autocomplete-layout.ts index 435dd3b07e..6bdaa9a1dc 100644 --- a/packages/cli/src/tui-autocomplete-layout.ts +++ b/packages/cli/src/tui-autocomplete-layout.ts @@ -19,6 +19,7 @@ import type { Component, Editor } from '@earendil-works/pi-tui'; import { selectListTheme, stripAnsi } from './tui-ansi.js'; +import { renderEditorWithFocus } from './tui-editor-render.js'; // The pi-tui Editor renders its autocomplete menu at the tail of its render // output, i.e. below the input box. The Maka TUI pins the input at the bottom @@ -124,7 +125,7 @@ export class MakaAutocompleteAboveEditorComponent implements Component { } render(width: number): string[] { - const lines = this.editor.render(width); + const lines = renderEditorWithFocus(this.editor, width); const result = arrangeAutocompleteAboveEditor({ lines, autocompleteShowing: this.editor.isShowingAutocomplete(), diff --git a/packages/cli/src/tui-editor-render.ts b/packages/cli/src/tui-editor-render.ts new file mode 100644 index 0000000000..da3a9a7e7a --- /dev/null +++ b/packages/cli/src/tui-editor-render.ts @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Editor } from '@earendil-works/pi-tui'; + +export function renderEditorWithFocus(editor: Editor, width: number): string[] { + const lines = editor.render(width); + if (editor.focused) return lines; + // pi-tui 0.84.4 gates only the IME marker on `focused`, not its synthetic cursor. + // Our editor themes use SGR 7/text/SGR 0 only for that cursor. Remove its wrapper, + // keeping the character (or paste marker) and other styles, including borders + // and skill highlights. Recheck this contract when upgrading pi-tui. + return lines.map((line) => line.replace(/\x1b\[7m([^\x1b]*)\x1b\[0m/gu, '$1')); +} From 190556d2d6b2c377c416d3dc850e412cd1ce77d9 Mon Sep 17 00:00:00 2001 From: Jover Date: Tue, 8 Sep 2026 10:32:36 +0800 Subject: [PATCH 03/12] test(cli): name input keys in focus regression Use named arrow and Enter key constants so the Other answer focus test reads as user actions. Generated-by: Codex --- .../pi-tui-user-question-option.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts index 40d077a77c..4bc3f03fb3 100644 --- a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts +++ b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts @@ -29,6 +29,11 @@ import { import { ansi, stripAnsi } from '../tui-ansi.js'; import { FakeTerminal, plainTerminalOutput } from './tui-terminal-mock.js'; +const ARROW_UP = '\x1b[A'; +const ARROW_DOWN = '\x1b[B'; +const ARROW_LEFT = '\x1b[D'; +const ENTER = '\r'; + // SGR reverse degrades to identity when the terminal reports no color support // (piped CI), so the highlight assertion keys off this build's actual behavior. const REVERSE_ON = '\u001b[7m'; @@ -58,18 +63,18 @@ test('Other keeps its draft and shows a cursor only while its input row is selec assert.ok(focused.includes(REVERSE_ON)); assert.ok(focused.includes(CURSOR_MARKER)); - overlay.handleInput('\x1b[A'); // Other -> preset. + overlay.handleInput(ARROW_UP); // Other -> preset. assert.ok(!inputLine().includes(REVERSE_ON), 'the inactive input must hide its cursor'); assert.ok(!inputLine().includes(CURSOR_MARKER), 'the inactive input must not anchor the IME'); - overlay.handleInput('\x1b[B'); + overlay.handleInput(ARROW_DOWN); assert.equal(inputLine(), focused, 'refocus restores the cursor and IME position'); - overlay.handleInput('\x1b[D'); // Put the cursor on e + combining accent, not a trailing space. + overlay.handleInput(ARROW_LEFT); // Put the cursor on e + combining accent, not a trailing space. assert.ok(inputLine().includes(`${REVERSE_ON}e\u0301`)); - overlay.handleInput('\x1b[A'); + overlay.handleInput(ARROW_UP); assert.ok(!inputLine().includes(REVERSE_ON), 'a cursor on a character must also disappear'); - overlay.handleInput('\x1b[B'); - overlay.handleInput('\r'); + overlay.handleInput(ARROW_DOWN); + overlay.handleInput(ENTER); assert.deepEqual(answers, [draft]); }); From 5148de8a1bca737aa5438e728652c10b8d32c0c5 Mon Sep 17 00:00:00 2001 From: Jover Date: Tue, 8 Sep 2026 15:57:30 +0800 Subject: [PATCH 04/12] docs(cli): illustrate cursor wrapper removal Show the matched ANSI wrapper and captured text in an aligned diagram, and document the format-based compatibility limit. Generated-by: Codex --- packages/cli/src/tui-editor-render.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/tui-editor-render.ts b/packages/cli/src/tui-editor-render.ts index da3a9a7e7a..6f52936a79 100644 --- a/packages/cli/src/tui-editor-render.ts +++ b/packages/cli/src/tui-editor-render.ts @@ -22,9 +22,18 @@ import type { Editor } from '@earendil-works/pi-tui'; export function renderEditorWithFocus(editor: Editor, width: number): string[] { const lines = editor.render(width); if (editor.focused) return lines; - // pi-tui 0.84.4 gates only the IME marker on `focused`, not its synthetic cursor. - // Our editor themes use SGR 7/text/SGR 0 only for that cursor. Remove its wrapper, - // keeping the character (or paste marker) and other styles, including borders - // and skill highlights. Recheck this contract when upgrading pi-tui. + // pi-tui 0.84.4 paints its cursor even when the editor is unfocused. + // Remove the reverse-video wrapper, keeping the captured text ($1): + // + // Input: \x1b[7mhello\x1b[0m + // └─────┘└───┘└─────┘ + // reverse text reset + // remove keep remove + // $1 + // Output: hello + // + // ([^\x1b]*) captures text without ESC, so the match cannot cross another + // ANSI sequence. Other text using this same wrapper would also lose its + // reverse styling. Recheck when changing themes or upgrading pi-tui. return lines.map((line) => line.replace(/\x1b\[7m([^\x1b]*)\x1b\[0m/gu, '$1')); } From f4451ffd97ce1e8af1867f30cf796ef65fec8e5a Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 11:36:40 +0800 Subject: [PATCH 05/12] refactor(cli): separate editor rendering from cursor visibility Render fields before removing their borders and hiding unfocused cursors. Keep the full rendered output for the main composer, and name the shared layout helper renderFieldRow. Generated-by: Codex --- packages/cli/src/pi-tui-pickers.ts | 15 ++++++++------- packages/cli/src/tui-autocomplete-layout.ts | 5 +++-- packages/cli/src/tui-editor-render.ts | 7 ++----- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 1a2e775d7c..f6711fee1e 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -62,7 +62,7 @@ import type { OnboardingRejectionReason, } from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; -import { renderEditorWithFocus } from './tui-editor-render.js'; +import { hideUnfocusedCursor } from './tui-editor-render.js'; import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; interface TuiPickerCopy { @@ -678,7 +678,7 @@ export class UserQuestionOverlay implements Component { if (!this.onInputRow && this.editor.getText().length === 0) { return [padLine(`${prefix}${ansi.dim(this.input.placeholder)}`, width)]; } - return renderEditorRow(this.editor, prefix, width); + return renderFieldRow(this.editor, prefix, width); } } @@ -912,7 +912,7 @@ export class ModelSearchOverlay implements Component { } private renderFieldRow(editor: Editor, label: string, width: number): string[] { - return renderEditorRow(editor, `${label} `, width); + return renderFieldRow(editor, `${label} `, width); } } @@ -1050,12 +1050,13 @@ function padLine(text: string, width: number): string { return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; } -function renderEditorRow(editor: Editor, prefix: string, width: number): string[] { +function renderFieldRow(editor: Editor, prefix: string, width: number): string[] { const prefixWidth = visibleWidth(prefix); const contentWidth = Math.max(1, width - prefixWidth); - // These inline editors have no autocomplete rows. Keep Editor's wrapping and + // These field editors have no autocomplete rows. Keep Editor's wrapping and // scrolling, but omit its top/bottom borders. - const editorLines = renderEditorWithFocus(editor, contentWidth).slice(1, -1); + const lines = editor.render(contentWidth).slice(1, -1); + const editorLines = hideUnfocusedCursor(lines, editor.focused); if (editorLines.length === 0) return [padLine(prefix, width)]; return editorLines.map((line, index) => padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), @@ -1860,6 +1861,6 @@ export class OnboardingWizard implements Component { } private renderFieldRow(editor: Editor, label: string, width: number): string[] { - return renderEditorRow(editor, `${label} `, width); + return renderFieldRow(editor, `${label} `, width); } } diff --git a/packages/cli/src/tui-autocomplete-layout.ts b/packages/cli/src/tui-autocomplete-layout.ts index 6bdaa9a1dc..12404a9422 100644 --- a/packages/cli/src/tui-autocomplete-layout.ts +++ b/packages/cli/src/tui-autocomplete-layout.ts @@ -19,7 +19,7 @@ import type { Component, Editor } from '@earendil-works/pi-tui'; import { selectListTheme, stripAnsi } from './tui-ansi.js'; -import { renderEditorWithFocus } from './tui-editor-render.js'; +import { hideUnfocusedCursor } from './tui-editor-render.js'; // The pi-tui Editor renders its autocomplete menu at the tail of its render // output, i.e. below the input box. The Maka TUI pins the input at the bottom @@ -125,7 +125,8 @@ export class MakaAutocompleteAboveEditorComponent implements Component { } render(width: number): string[] { - const lines = renderEditorWithFocus(this.editor, width); + const renderedLines = this.editor.render(width); + const lines = hideUnfocusedCursor(renderedLines, this.editor.focused); const result = arrangeAutocompleteAboveEditor({ lines, autocompleteShowing: this.editor.isShowingAutocomplete(), diff --git a/packages/cli/src/tui-editor-render.ts b/packages/cli/src/tui-editor-render.ts index 6f52936a79..3e89132f62 100644 --- a/packages/cli/src/tui-editor-render.ts +++ b/packages/cli/src/tui-editor-render.ts @@ -17,11 +17,8 @@ * under the License. */ -import type { Editor } from '@earendil-works/pi-tui'; - -export function renderEditorWithFocus(editor: Editor, width: number): string[] { - const lines = editor.render(width); - if (editor.focused) return lines; +export function hideUnfocusedCursor(lines: string[], focused: boolean): string[] { + if (focused) return lines; // pi-tui 0.84.4 paints its cursor even when the editor is unfocused. // Remove the reverse-video wrapper, keeping the captured text ($1): // From 0ef1cccba0c6cd843e261fa6dfefd8d20f4f7b16 Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 11:44:48 +0800 Subject: [PATCH 06/12] refactor(cli): name cursor style cleanup explicitly Generated-by: Codex --- packages/cli/src/pi-tui-pickers.ts | 4 ++-- packages/cli/src/tui-autocomplete-layout.ts | 4 ++-- packages/cli/src/tui-editor-render.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index f6711fee1e..d876359eb9 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -62,7 +62,7 @@ import type { OnboardingRejectionReason, } from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; -import { hideUnfocusedCursor } from './tui-editor-render.js'; +import { stripUnfocusedCursorStyle } from './tui-editor-render.js'; import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; interface TuiPickerCopy { @@ -1056,7 +1056,7 @@ function renderFieldRow(editor: Editor, prefix: string, width: number): string[] // These field editors have no autocomplete rows. Keep Editor's wrapping and // scrolling, but omit its top/bottom borders. const lines = editor.render(contentWidth).slice(1, -1); - const editorLines = hideUnfocusedCursor(lines, editor.focused); + const editorLines = stripUnfocusedCursorStyle(lines, editor.focused); if (editorLines.length === 0) return [padLine(prefix, width)]; return editorLines.map((line, index) => padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), diff --git a/packages/cli/src/tui-autocomplete-layout.ts b/packages/cli/src/tui-autocomplete-layout.ts index 12404a9422..2dd5357125 100644 --- a/packages/cli/src/tui-autocomplete-layout.ts +++ b/packages/cli/src/tui-autocomplete-layout.ts @@ -19,7 +19,7 @@ import type { Component, Editor } from '@earendil-works/pi-tui'; import { selectListTheme, stripAnsi } from './tui-ansi.js'; -import { hideUnfocusedCursor } from './tui-editor-render.js'; +import { stripUnfocusedCursorStyle } from './tui-editor-render.js'; // The pi-tui Editor renders its autocomplete menu at the tail of its render // output, i.e. below the input box. The Maka TUI pins the input at the bottom @@ -126,7 +126,7 @@ export class MakaAutocompleteAboveEditorComponent implements Component { render(width: number): string[] { const renderedLines = this.editor.render(width); - const lines = hideUnfocusedCursor(renderedLines, this.editor.focused); + const lines = stripUnfocusedCursorStyle(renderedLines, this.editor.focused); const result = arrangeAutocompleteAboveEditor({ lines, autocompleteShowing: this.editor.isShowingAutocomplete(), diff --git a/packages/cli/src/tui-editor-render.ts b/packages/cli/src/tui-editor-render.ts index 3e89132f62..f0765d88b2 100644 --- a/packages/cli/src/tui-editor-render.ts +++ b/packages/cli/src/tui-editor-render.ts @@ -17,7 +17,7 @@ * under the License. */ -export function hideUnfocusedCursor(lines: string[], focused: boolean): string[] { +export function stripUnfocusedCursorStyle(lines: string[], focused: boolean): string[] { if (focused) return lines; // pi-tui 0.84.4 paints its cursor even when the editor is unfocused. // Remove the reverse-video wrapper, keeping the captured text ($1): From f985959f0622bb7175d8802f7f111c8775cf22f9 Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 13:22:07 +0800 Subject: [PATCH 07/12] test(cli): verify cursor restoration on combining characters Compare the refocused answer row with its rendering before selecting a preset, covering the cursor and IME marker position. Generated-by: Codex --- .../cli/src/__tests__/pi-tui-user-question-option.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts index 4bc3f03fb3..818b26d49a 100644 --- a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts +++ b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts @@ -70,10 +70,16 @@ test('Other keeps its draft and shows a cursor only while its input row is selec assert.equal(inputLine(), focused, 'refocus restores the cursor and IME position'); overlay.handleInput(ARROW_LEFT); // Put the cursor on e + combining accent, not a trailing space. - assert.ok(inputLine().includes(`${REVERSE_ON}e\u0301`)); + const focusedOnCharacter = inputLine(); + assert.ok(focusedOnCharacter.includes(`${REVERSE_ON}e\u0301`)); overlay.handleInput(ARROW_UP); assert.ok(!inputLine().includes(REVERSE_ON), 'a cursor on a character must also disappear'); overlay.handleInput(ARROW_DOWN); + assert.equal( + inputLine(), + focusedOnCharacter, + 'refocus restores the cursor on the combining character', + ); overlay.handleInput(ENTER); assert.deepEqual(answers, [draft]); }); From 4c71100b3db32d4eb24de3bb5cc43642468f58f1 Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 13:48:49 +0800 Subject: [PATCH 08/12] refactor(cli): preserve field rendering label parameter Generated-by: Codex --- packages/cli/src/pi-tui-pickers.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index d876359eb9..d19eb9cf46 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -672,13 +672,13 @@ export class UserQuestionOverlay implements Component { } private renderInputRow(width: number): string[] { - const prefix = this.onInputRow ? '→ ' : ' '; + const marker = this.onInputRow ? '→' : ' '; // Focus controls the IME marker and our cursor-visibility adapter (#1064). this.editor.focused = this.onInputRow; if (!this.onInputRow && this.editor.getText().length === 0) { - return [padLine(`${prefix}${ansi.dim(this.input.placeholder)}`, width)]; + return [padLine(`${marker} ${ansi.dim(this.input.placeholder)}`, width)]; } - return renderFieldRow(this.editor, prefix, width); + return renderFieldRow(this.editor, marker, width); } } @@ -912,7 +912,7 @@ export class ModelSearchOverlay implements Component { } private renderFieldRow(editor: Editor, label: string, width: number): string[] { - return renderFieldRow(editor, `${label} `, width); + return renderFieldRow(editor, label, width); } } @@ -1050,7 +1050,8 @@ function padLine(text: string, width: number): string { return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; } -function renderFieldRow(editor: Editor, prefix: string, width: number): string[] { +function renderFieldRow(editor: Editor, label: string, width: number): string[] { + const prefix = `${label} `; const prefixWidth = visibleWidth(prefix); const contentWidth = Math.max(1, width - prefixWidth); // These field editors have no autocomplete rows. Keep Editor's wrapping and @@ -1861,6 +1862,6 @@ export class OnboardingWizard implements Component { } private renderFieldRow(editor: Editor, label: string, width: number): string[] { - return renderFieldRow(editor, `${label} `, width); + return renderFieldRow(editor, label, width); } } From 5764e4e57172356b78798f702a79b5f447a444f3 Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 15:19:49 +0800 Subject: [PATCH 09/12] test(cli): describe focus changes with cursor fixtures Generated-by: Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 86 +++++++++++------ .../pi-tui-user-question-option.test.ts | 92 ++++++++++++------- .../__tests__/tui-autocomplete-layout.test.ts | 71 +++++++++----- .../cli/src/__tests__/tui-render-fixture.ts | 44 +++++++++ 4 files changed, 211 insertions(+), 82 deletions(-) create mode 100644 packages/cli/src/__tests__/tui-render-fixture.ts diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 02b7cf1b9b..0378e8dec9 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -66,7 +66,7 @@ import { skillInvocationBlockedMessage } from '../session-driver.js'; import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js'; import { listApiKeyOnboardableProviders } from '../onboarding-catalog.js'; import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js'; -import { modelChoiceConnectionLabels } from '../pi-tui-pickers.js'; +import { modelChoiceConnectionLabels, OnboardingWizard } from '../pi-tui-pickers.js'; import type { MakaOnboardingSurface, MakaPiTuiTurnActivitySurface, @@ -98,6 +98,7 @@ import { waitForTuiPaint, } from './tui-terminal-mock.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { renderFixture } from './tui-render-fixture.js'; // Deadline for `Promise.race([run, …])` close watchdogs. A passing race // resolves the moment `run` settles, so this only bounds how long a FAILING @@ -1686,19 +1687,22 @@ describe('Maka Pi TUI runner', () => { assert.equal(terminal.stopCalls, 1); }); - test('wizard identity step sends a caller-chosen slug and name on the create target', async () => { + test('wizard identity step sends a caller-chosen slug and name on the create target', async (t) => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); const verifyCalls: OnboardingVerifyInput[] = []; const saveCalls: OnboardingSaveInput[] = []; - const editorEndCursor = '\x1b[7m \x1b[0m'; - const identityFieldLine = (expectedText: string) => { - const line = terminal.writes - .flatMap((write) => write.split('\n')) - .reverse() - .find((line) => plainTerminalOutput(line).trim() === expectedText); - assert.ok(line, `expected a rendered field: ${expectedText}`); - return line; + const ENTER = '\r'; + const CLEAR_LINE = '\x15'; // Ctrl+U. + // Record real renders before TUI consumes the IME marker. + const render = t.mock.method(OnboardingWizard.prototype, 'render'); + const assertIdentity = async (fixture: string) => { + await waitFor(() => render.mock.callCount() > 0, 'wizard redraw'); + const call = render.mock.calls.at(-1); + assert.ok(call?.result, 'the wizard must render its fields'); + // Name, the separating blank row, and Slug in this identity layout. + assert.deepEqual(call.result.slice(3, 6), renderFixture(fixture, call.arguments[0])); + render.mock.resetCalls(); }; const run = runMakaPiTui({ title: 'Maka', @@ -1732,29 +1736,55 @@ describe('Maka Pi TUI runner', () => { await waitForTuiPaint(terminal); terminal.input('/setup'); - terminal.input('\r'); + terminal.input(ENTER); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); - terminal.input('\r'); // pick the only row -> identity step, name focused + terminal.input(ENTER); // pick the only row -> identity step, name focused await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); - assert.equal(identityFieldLine('Name OpenAI').includes(editorEndCursor), true); - assert.equal(identityFieldLine('Slug openai').includes(editorEndCursor), false); - // Replace the prefilled provider label with a display name. - for (let i = 0; i < 'OpenAI'.length; i++) terminal.input('\x7f'); + await assertIdentity(` +Name OpenAI + +Slug openai +`); + + terminal.input(CLEAR_LINE); + await assertIdentity(` +Name + +Slug openai +`); + terminal.input('Work OpenAI'); - terminal.input('\r'); // name -> slug field - await waitFor( - () => identityFieldLine('Slug openai').includes(editorEndCursor), - 'the identity cursor to move from Name to Slug', - ); - assert.equal(identityFieldLine('Name Work OpenAI').includes(editorEndCursor), false); - assert.equal(identityFieldLine('Slug openai').includes(editorEndCursor), true); - // Replace the derived suggestion with a chosen slug. - for (let i = 0; i < 'openai'.length; i++) terminal.input('\x7f'); + await assertIdentity(` +Name Work OpenAI + +Slug openai +`); + + terminal.input(ENTER); + await assertIdentity(` +Name Work OpenAI + +Slug openai +`); + + terminal.input(CLEAR_LINE); + await assertIdentity(` +Name Work OpenAI + +Slug +`); + terminal.input('openai-work'); - terminal.input('\r'); // slug -> key phase + await assertIdentity(` +Name Work OpenAI + +Slug openai-work +`); + + terminal.input(ENTER); // slug -> key phase await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); terminal.input('sk-live'); - terminal.input('\r'); + terminal.input(ENTER); await waitFor(() => verifyCalls.length === 1); assert.deepEqual(verifyCalls[0]?.target, { kind: 'create', @@ -1764,7 +1794,7 @@ describe('Maka Pi TUI runner', () => { }); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('4/4')); terminal.input(' '); // toggle the model on - terminal.input('\r'); // save + terminal.input(ENTER); // save await waitFor(() => saveCalls.length === 1); assert.deepEqual(saveCalls[0]?.target, verifyCalls[0]?.target); diff --git a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts index 818b26d49a..47ba1f094d 100644 --- a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts +++ b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { CURSOR_MARKER, TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui'; +import { TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui'; import type { TUI } from '@earendil-works/pi-tui'; import { clampRowsWithEllipsis, @@ -27,20 +27,22 @@ import { UserQuestionOverlay, } from '../pi-tui-pickers.js'; import { ansi, stripAnsi } from '../tui-ansi.js'; -import { FakeTerminal, plainTerminalOutput } from './tui-terminal-mock.js'; - -const ARROW_UP = '\x1b[A'; -const ARROW_DOWN = '\x1b[B'; -const ARROW_LEFT = '\x1b[D'; -const ENTER = '\r'; +import { FakeTerminal } from './tui-terminal-mock.js'; +import { renderFixture } from './tui-render-fixture.js'; // SGR reverse degrades to identity when the terminal reports no color support // (piped CI), so the highlight assertion keys off this build's actual behavior. const REVERSE_ON = '\u001b[7m'; const COLOR_ENABLED = ansi.reverse('').length > 0; -test('Other keeps its draft and shows a cursor only while its input row is selected', () => { - const draft = 'custom e\u0301'; +test('Other keeps its wrapped draft and restores its cursor after refocus', () => { + const WIDTH = 40; + const ARROW_UP = '\x1b[A'; + const ARROW_DOWN = '\x1b[B'; + const ARROW_LEFT = '\x1b[D'; + const ENTER = '\r'; + const draft = + 'Please use the custom provider and keep the current model settings for this workspace'; const answers: string[] = []; const overlay = new UserQuestionOverlay(new TuiMainScreen(new FakeTerminal()), { title: 'Pick one', @@ -52,35 +54,63 @@ test('Other keeps its draft and shows a cursor only while its input row is selec onSubmitText: (value) => answers.push(value), onSkip: () => undefined, }); - const inputLine = () => { - const row = overlay.render(40).find((line) => plainTerminalOutput(line).includes(draft)); - assert.ok(row, 'the typed answer must remain visible'); - return row; - }; + // Keep all choices and input rows; omit the title, hint, blank row and divider. + const assertQuestionBody = (fixture: string) => + assert.deepEqual(overlay.render(WIDTH).slice(3, -1), renderFixture(fixture, WIDTH)); - overlay.handleInput(draft); // Typing on a preset jumps to Other. - const focused = inputLine(); - assert.ok(focused.includes(REVERSE_ON)); - assert.ok(focused.includes(CURSOR_MARKER)); + overlay.handleInput(draft); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); + + overlay.handleInput(ARROW_UP); + assertQuestionBody(` +→ Preset + Please use the custom provider and + keep the current model settings for + this workspace +`); - overlay.handleInput(ARROW_UP); // Other -> preset. - assert.ok(!inputLine().includes(REVERSE_ON), 'the inactive input must hide its cursor'); - assert.ok(!inputLine().includes(CURSOR_MARKER), 'the inactive input must not anchor the IME'); overlay.handleInput(ARROW_DOWN); - assert.equal(inputLine(), focused, 'refocus restores the cursor and IME position'); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); + + overlay.handleInput(ARROW_LEFT); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); - overlay.handleInput(ARROW_LEFT); // Put the cursor on e + combining accent, not a trailing space. - const focusedOnCharacter = inputLine(); - assert.ok(focusedOnCharacter.includes(`${REVERSE_ON}e\u0301`)); overlay.handleInput(ARROW_UP); - assert.ok(!inputLine().includes(REVERSE_ON), 'a cursor on a character must also disappear'); + assertQuestionBody(` +→ Preset + Please use the custom provider and + keep the current model settings for + this workspace +`); + overlay.handleInput(ARROW_DOWN); - assert.equal( - inputLine(), - focusedOnCharacter, - 'refocus restores the cursor on the combining character', - ); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); + overlay.handleInput(ENTER); + assertQuestionBody(` + Preset +→ +`); assert.deepEqual(answers, [draft]); }); diff --git a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts index 37e7e3b41b..22e3f98895 100644 --- a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts +++ b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts @@ -19,47 +19,72 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { Editor, Text, TuiMainScreen } from '@earendil-works/pi-tui'; +import { Editor, Spacer, Text, TuiMainScreen } from '@earendil-works/pi-tui'; import { fitAutocompleteLines, MakaAutocompleteAboveEditorComponent, } from '../tui-autocomplete-layout.js'; import { fitPendingQueueLines } from '../pi-tui-layout.js'; import { editorTheme } from '../tui-ansi.js'; -import { FakeTerminal } from './tui-terminal-mock.js'; +import { FakeTerminal, plainTerminalOutput } from './tui-terminal-mock.js'; +import { renderFixture } from './tui-render-fixture.js'; -const REVERSE_ON = '\x1b[7m'; -const CYAN_FOREGROUND = '\x1b[36m'; -const RESET_FOREGROUND = '\x1b[39m'; - -test('an overlay hides the composer cursor, preserves its draft and colors, and restores focus', (t) => { - const terminal = new FakeTerminal(); +test('an overlay hides the composer cursor and preserves its draft and border colors', (t) => { + const WIDTH = 40; + const CYAN_FOREGROUND = '\x1b[36m'; + const RESET_FOREGROUND = '\x1b[39m'; + // Emit color even under NO_COLOR so accidental style loss remains detectable. + const borderColor = (text: string) => `${CYAN_FOREGROUND}${text}${RESET_FOREGROUND}`; + const terminal = new FakeTerminal(WIDTH, 4); const tui = new TuiMainScreen(terminal); t.after(() => tui.stop()); - const editor = new Editor(tui, { - ...editorTheme(), - // Emit color even under NO_COLOR so the test can detect accidental style loss. - borderColor: (text) => `${CYAN_FOREGROUND}${text}${RESET_FOREGROUND}`, - }); + const editor = new Editor(tui, { ...editorTheme(), borderColor }); editor.setText('draft'); const composer = new MakaAutocompleteAboveEditorComponent(editor); + tui.addChild(new Spacer(1)); // Reserve the first row for the overlay. tui.addChild(composer); tui.setFocus(composer); - const renderScreen = () => { - terminal.writes.length = 0; + const render = t.mock.method(composer, 'render'); + const assertScreen = (fixture: string) => { tui.renderNow(true); - return terminal.output(); + const expected = renderFixture(fixture, WIDTH); + // Check the composed screen, including the overlay's placement. + assert.deepEqual( + terminal + .screenOutput() + .split('\n') + .map((line) => line.padEnd(WIDTH)), + expected.map(plainTerminalOutput), + ); + // Check cursor + IME before TUI consumes the marker, retaining border colors. + assert.deepEqual( + render.mock.calls.at(-1)?.result, + expected.slice(1).map((line) => line.replaceAll('─', borderColor('─'))), + ); }; - assert.ok(renderScreen().includes(REVERSE_ON)); + assertScreen(` + +──────────────────────────────────────── +draft +──────────────────────────────────────── +`); + const overlay = tui.showOverlay(new Text('Picker', 0, 0), { anchor: 'top-left' }); - const screen = renderScreen(); - assert.ok(screen.includes('Picker')); - assert.ok(screen.includes('draft')); - assert.ok(screen.includes(CYAN_FOREGROUND), 'unfocused editor borders must keep their color'); - assert.ok(!screen.includes(REVERSE_ON), 'the inactive composer must not show a block cursor'); + assertScreen(` +Picker +──────────────────────────────────────── +draft +──────────────────────────────────────── +`); + overlay.hide(); - assert.ok(renderScreen().includes(REVERSE_ON)); + assertScreen(` + +──────────────────────────────────────── +draft +──────────────────────────────────────── +`); }); describe('fitAutocompleteLines', () => { diff --git a/packages/cli/src/__tests__/tui-render-fixture.ts b/packages/cli/src/__tests__/tui-render-fixture.ts new file mode 100644 index 0000000000..7a5af77245 --- /dev/null +++ b/packages/cli/src/__tests__/tui-render-fixture.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CURSOR_MARKER, visibleWidth } from '@earendil-works/pi-tui'; +import { ansi } from '../tui-ansi.js'; + +const REVERSE_ON = '\x1b[7m'; +const RESET = '\x1b[0m'; + +// These fixtures use ASCII input. The cursor covers the next character, +// or a space at the end of a row, with the IME marker at the same position. +export function renderFixture(fixture: string, width: number): string[] { + return fixture + .split('\n') + .slice(1, -1) + .map((row) => { + const selected = row.startsWith(''); + const line = row + .replace('', '') + .replace('', '') + .replace( + /(.)?/gu, + (_, character = ' ') => `${CURSOR_MARKER}${REVERSE_ON}${character}${RESET}`, + ); + const padded = line + ' '.repeat(width - visibleWidth(line)); + return selected ? ansi.reverse(padded) : padded; + }); +} From c481cf210c3e54d7cef0ce8f2e6329b54e90a6a6 Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 15:44:13 +0800 Subject: [PATCH 10/12] test(cli): clarify focus test names and flow Generated-by: Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 48 +++++++++++-------- .../pi-tui-user-question-option.test.ts | 43 ++++++++++------- .../__tests__/tui-autocomplete-layout.test.ts | 38 ++++++++------- ...-fixture.ts => tui-render-expectations.ts} | 35 +++++++------- 4 files changed, 90 insertions(+), 74 deletions(-) rename packages/cli/src/__tests__/{tui-render-fixture.ts => tui-render-expectations.ts} (50%) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 0378e8dec9..ea56732c60 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -98,7 +98,7 @@ import { waitForTuiPaint, } from './tui-terminal-mock.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; -import { renderFixture } from './tui-render-fixture.js'; +import { encodeExpectedRows } from './tui-render-expectations.js'; // Deadline for `Promise.race([run, …])` close watchdogs. A passing race // resolves the moment `run` settles, so this only bounds how long a FAILING @@ -1687,23 +1687,16 @@ describe('Maka Pi TUI runner', () => { assert.equal(terminal.stopCalls, 1); }); - test('wizard identity step sends a caller-chosen slug and name on the create target', async (t) => { + test('wizard moves focus from Name to Slug and submits the edited identity', async (t) => { + const ENTER = '\r'; + const CLEAR_LINE = '\x15'; // Ctrl+U. + const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); const verifyCalls: OnboardingVerifyInput[] = []; const saveCalls: OnboardingSaveInput[] = []; - const ENTER = '\r'; - const CLEAR_LINE = '\x15'; // Ctrl+U. // Record real renders before TUI consumes the IME marker. - const render = t.mock.method(OnboardingWizard.prototype, 'render'); - const assertIdentity = async (fixture: string) => { - await waitFor(() => render.mock.callCount() > 0, 'wizard redraw'); - const call = render.mock.calls.at(-1); - assert.ok(call?.result, 'the wizard must render its fields'); - // Name, the separating blank row, and Slug in this identity layout. - assert.deepEqual(call.result.slice(3, 6), renderFixture(fixture, call.arguments[0])); - render.mock.resetCalls(); - }; + const wizardRenderSpy = t.mock.method(OnboardingWizard.prototype, 'render'); const run = runMakaPiTui({ title: 'Maka', driver, @@ -1734,54 +1727,67 @@ describe('Maka Pi TUI runner', () => { }), }); + const assertNextIdentityRender = async (expectedScene: string) => { + await waitFor(() => wizardRenderSpy.mock.callCount() > 0, 'wizard redraw'); + const latestRender = wizardRenderSpy.mock.calls.at(-1); + assert.ok(latestRender?.result, 'the wizard must render its fields'); + const [width] = latestRender.arguments; + // Name, the separating blank row, and Slug in this identity layout. + const actualIdentityRows = latestRender.result.slice(3, 6); + assert.deepEqual(actualIdentityRows, encodeExpectedRows(expectedScene, width)); + // The next assertion must observe a fresh render after the next input. + wizardRenderSpy.mock.resetCalls(); + }; + await waitForTuiPaint(terminal); terminal.input('/setup'); terminal.input(ENTER); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); - terminal.input(ENTER); // pick the only row -> identity step, name focused + terminal.input(ENTER); // Select OpenAI; Name receives focus. await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); - await assertIdentity(` + await assertNextIdentityRender(` Name OpenAI Slug openai `); terminal.input(CLEAR_LINE); - await assertIdentity(` + await assertNextIdentityRender(` Name Slug openai `); terminal.input('Work OpenAI'); - await assertIdentity(` + await assertNextIdentityRender(` Name Work OpenAI Slug openai `); terminal.input(ENTER); - await assertIdentity(` + await assertNextIdentityRender(` Name Work OpenAI Slug openai `); terminal.input(CLEAR_LINE); - await assertIdentity(` + await assertNextIdentityRender(` Name Work OpenAI Slug `); terminal.input('openai-work'); - await assertIdentity(` + await assertNextIdentityRender(` Name Work OpenAI Slug openai-work `); - terminal.input(ENTER); // slug -> key phase + // Continue through verification and saving with the edited Name and Slug. + terminal.input(ENTER); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); terminal.input('sk-live'); terminal.input(ENTER); diff --git a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts index 47ba1f094d..28fd9dd378 100644 --- a/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts +++ b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts @@ -28,37 +28,43 @@ import { } from '../pi-tui-pickers.js'; import { ansi, stripAnsi } from '../tui-ansi.js'; import { FakeTerminal } from './tui-terminal-mock.js'; -import { renderFixture } from './tui-render-fixture.js'; +import { encodeExpectedRows } from './tui-render-expectations.js'; // SGR reverse degrades to identity when the terminal reports no color support // (piped CI), so the highlight assertion keys off this build's actual behavior. const REVERSE_ON = '\u001b[7m'; const COLOR_ENABLED = ansi.reverse('').length > 0; -test('Other keeps its wrapped draft and restores its cursor after refocus', () => { +test('Other preserves its wrapped draft and cursor position across focus changes', () => { const WIDTH = 40; const ARROW_UP = '\x1b[A'; const ARROW_DOWN = '\x1b[B'; const ARROW_LEFT = '\x1b[D'; const ENTER = '\r'; - const draft = - 'Please use the custom provider and keep the current model settings for this workspace'; - const answers: string[] = []; - const overlay = new UserQuestionOverlay(new TuiMainScreen(new FakeTerminal()), { + + const submittedAnswers: string[] = []; + const tui = new TuiMainScreen(new FakeTerminal(WIDTH)); + const question = new UserQuestionOverlay(tui, { title: 'Pick one', rightLabel: '1 / 1', hint: '↑↓ move · type to answer', placeholder: 'Other: type answer', options: [{ label: 'Preset' }], onSelectOption: () => undefined, - onSubmitText: (value) => answers.push(value), + onSubmitText: (value) => submittedAnswers.push(value), onSkip: () => undefined, }); - // Keep all choices and input rows; omit the title, hint, blank row and divider. - const assertQuestionBody = (fixture: string) => - assert.deepEqual(overlay.render(WIDTH).slice(3, -1), renderFixture(fixture, WIDTH)); - overlay.handleInput(draft); + const assertQuestionBody = (expectedScene: string) => { + // Keep all choices and input rows; omit the title, hint, blank row and divider. + const actualRows = question.render(WIDTH).slice(3, -1); + assert.deepEqual(actualRows, encodeExpectedRows(expectedScene, WIDTH)); + }; + + // Refocus with the cursor at the end of a wrapped answer. + const draft = + 'Please use the custom provider and keep the current model settings for this workspace'; + question.handleInput(draft); assertQuestionBody(` Preset → Please use the custom provider and @@ -66,7 +72,7 @@ test('Other keeps its wrapped draft and restores its cursor after refocus', () = this workspace `); - overlay.handleInput(ARROW_UP); + question.handleInput(ARROW_UP); assertQuestionBody(` → Preset Please use the custom provider and @@ -74,7 +80,7 @@ test('Other keeps its wrapped draft and restores its cursor after refocus', () = this workspace `); - overlay.handleInput(ARROW_DOWN); + question.handleInput(ARROW_DOWN); assertQuestionBody(` Preset → Please use the custom provider and @@ -82,7 +88,8 @@ test('Other keeps its wrapped draft and restores its cursor after refocus', () = this workspace `); - overlay.handleInput(ARROW_LEFT); + // Refocus with the cursor over an existing character. + question.handleInput(ARROW_LEFT); assertQuestionBody(` Preset → Please use the custom provider and @@ -90,7 +97,7 @@ test('Other keeps its wrapped draft and restores its cursor after refocus', () = this workspace `); - overlay.handleInput(ARROW_UP); + question.handleInput(ARROW_UP); assertQuestionBody(` → Preset Please use the custom provider and @@ -98,7 +105,7 @@ test('Other keeps its wrapped draft and restores its cursor after refocus', () = this workspace `); - overlay.handleInput(ARROW_DOWN); + question.handleInput(ARROW_DOWN); assertQuestionBody(` Preset → Please use the custom provider and @@ -106,12 +113,12 @@ test('Other keeps its wrapped draft and restores its cursor after refocus', () = this workspace `); - overlay.handleInput(ENTER); + question.handleInput(ENTER); assertQuestionBody(` Preset → `); - assert.deepEqual(answers, [draft]); + assert.deepEqual(submittedAnswers, [draft]); }); test('long options wrap within the row width instead of truncating (#4610)', () => { diff --git a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts index 22e3f98895..72030a0ea1 100644 --- a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts +++ b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts @@ -27,12 +27,13 @@ import { import { fitPendingQueueLines } from '../pi-tui-layout.js'; import { editorTheme } from '../tui-ansi.js'; import { FakeTerminal, plainTerminalOutput } from './tui-terminal-mock.js'; -import { renderFixture } from './tui-render-fixture.js'; +import { encodeExpectedRows } from './tui-render-expectations.js'; test('an overlay hides the composer cursor and preserves its draft and border colors', (t) => { const WIDTH = 40; const CYAN_FOREGROUND = '\x1b[36m'; const RESET_FOREGROUND = '\x1b[39m'; + // Emit color even under NO_COLOR so accidental style loss remains detectable. const borderColor = (text: string) => `${CYAN_FOREGROUND}${text}${RESET_FOREGROUND}`; const terminal = new FakeTerminal(WIDTH, 4); @@ -44,23 +45,26 @@ test('an overlay hides the composer cursor and preserves its draft and border co tui.addChild(new Spacer(1)); // Reserve the first row for the overlay. tui.addChild(composer); tui.setFocus(composer); - const render = t.mock.method(composer, 'render'); - const assertScreen = (fixture: string) => { + const composerRenderSpy = t.mock.method(composer, 'render'); + + const assertScreen = (expectedScene: string) => { tui.renderNow(true); - const expected = renderFixture(fixture, WIDTH); - // Check the composed screen, including the overlay's placement. - assert.deepEqual( - terminal - .screenOutput() - .split('\n') - .map((line) => line.padEnd(WIDTH)), - expected.map(plainTerminalOutput), - ); - // Check cursor + IME before TUI consumes the marker, retaining border colors. - assert.deepEqual( - render.mock.calls.at(-1)?.result, - expected.slice(1).map((line) => line.replaceAll('─', borderColor('─'))), - ); + const expectedRows = encodeExpectedRows(expectedScene, WIDTH); + + // The terminal screen checks text and overlay placement, but omits styles. + const actualScreenRows = terminal + .screenOutput() + .split('\n') + .map((line) => line.padEnd(WIDTH)); + const expectedScreenRows = expectedRows.map(plainTerminalOutput); + assert.deepEqual(actualScreenRows, expectedScreenRows); + + // The real render retains the cursor, IME marker and per-character border colors. + const actualComposerRows = composerRenderSpy.mock.calls.at(-1)?.result; + const expectedComposerRows = expectedRows + .slice(1) + .map((line) => line.replaceAll('─', borderColor('─'))); + assert.deepEqual(actualComposerRows, expectedComposerRows); }; assertScreen(` diff --git a/packages/cli/src/__tests__/tui-render-fixture.ts b/packages/cli/src/__tests__/tui-render-expectations.ts similarity index 50% rename from packages/cli/src/__tests__/tui-render-fixture.ts rename to packages/cli/src/__tests__/tui-render-expectations.ts index 7a5af77245..4c9b31d0dc 100644 --- a/packages/cli/src/__tests__/tui-render-fixture.ts +++ b/packages/cli/src/__tests__/tui-render-expectations.ts @@ -23,22 +23,21 @@ import { ansi } from '../tui-ansi.js'; const REVERSE_ON = '\x1b[7m'; const RESET = '\x1b[0m'; -// These fixtures use ASCII input. The cursor covers the next character, -// or a space at the end of a row, with the IME marker at the same position. -export function renderFixture(fixture: string, width: number): string[] { - return fixture - .split('\n') - .slice(1, -1) - .map((row) => { - const selected = row.startsWith(''); - const line = row - .replace('', '') - .replace('', '') - .replace( - /(.)?/gu, - (_, character = ' ') => `${CURSOR_MARKER}${REVERSE_ON}${character}${RESET}`, - ); - const padded = line + ' '.repeat(width - visibleWidth(line)); - return selected ? ansi.reverse(padded) : padded; - }); +// Encode a multiline expectation as full-width ANSI rows. +// requires both the visible cursor and the IME marker at this position. +// These tests place the cursor on an ASCII character, or a space at row end. +// ... highlights the entire padded row. +export function encodeExpectedRows(expectedScene: string, width: number): string[] { + // Remove only the template literal's framing newlines, preserving indentation. + const rows = expectedScene.split('\n').slice(1, -1); + return rows.map((row) => { + const isSelected = row.startsWith(''); + const text = row.replace('', '').replace('', ''); + const withCursor = text.replace( + /(.)?/gu, + (_, character = ' ') => `${CURSOR_MARKER}${REVERSE_ON}${character}${RESET}`, + ); + const paddedRow = withCursor + ' '.repeat(width - visibleWidth(withCursor)); + return isSelected ? ansi.reverse(paddedRow) : paddedRow; + }); } From ae9562363041500cdf86ead43c3def796aa7debe Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 15:54:17 +0800 Subject: [PATCH 11/12] test(cli): simplify identity assertion and cleanup Generated-by: Codex --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index ea56732c60..9e777cc2d8 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1727,7 +1727,7 @@ describe('Maka Pi TUI runner', () => { }), }); - const assertNextIdentityRender = async (expectedScene: string) => { + const assertIdentityFields = async (expectedScene: string) => { await waitFor(() => wizardRenderSpy.mock.callCount() > 0, 'wizard redraw'); const latestRender = wizardRenderSpy.mock.calls.at(-1); assert.ok(latestRender?.result, 'the wizard must render its fields'); @@ -1745,42 +1745,42 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); terminal.input(ENTER); // Select OpenAI; Name receives focus. await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); - await assertNextIdentityRender(` + await assertIdentityFields(` Name OpenAI Slug openai `); terminal.input(CLEAR_LINE); - await assertNextIdentityRender(` + await assertIdentityFields(` Name Slug openai `); terminal.input('Work OpenAI'); - await assertNextIdentityRender(` + await assertIdentityFields(` Name Work OpenAI Slug openai `); terminal.input(ENTER); - await assertNextIdentityRender(` + await assertIdentityFields(` Name Work OpenAI Slug openai `); terminal.input(CLEAR_LINE); - await assertNextIdentityRender(` + await assertIdentityFields(` Name Work OpenAI Slug `); terminal.input('openai-work'); - await assertNextIdentityRender(` + await assertIdentityFields(` Name Work OpenAI Slug openai-work @@ -1804,7 +1804,7 @@ Slug openai-work await waitFor(() => saveCalls.length === 1); assert.deepEqual(saveCalls[0]?.target, verifyCalls[0]?.target); - process.emit('SIGTERM'); + exitMaka(terminal); await Promise.race([ run, delay(CLOSE_BUDGET_MS).then(() => { From f08de4be8fc32e6d6afb5e657d0962c4acab3836 Mon Sep 17 00:00:00 2001 From: Jover Date: Wed, 9 Sep 2026 18:03:35 +0800 Subject: [PATCH 12/12] fix(ci): classify the TUI cursor style helper The new helper only transforms ANSI styles and contains no user-facing copy. Register it alongside the other rendering infrastructure so the TUI copy inventory accepts it. Generated-by: Codex --- scripts/check-tui-copy.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs index 5727114fe8..6e054d2c62 100644 --- a/scripts/check-tui-copy.mjs +++ b/scripts/check-tui-copy.mjs @@ -55,6 +55,7 @@ export const EXCLUDED_TUI_FILES = [ 'packages/cli/src/tui-context-refresh.ts', 'packages/cli/src/tui-copy-catalog.ts', 'packages/cli/src/tui-diff.ts', + 'packages/cli/src/tui-editor-render.ts', 'packages/cli/src/tui-mcp-control.ts', 'packages/cli/src/tui-mcp-remote-publication.ts', ];