diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index be4a167d80..0cd340be06 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -69,6 +69,7 @@ import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js'; import { getTuiPickerCopy, modelChoiceConnectionLabels, + OnboardingWizard, SessionSearchOverlay, } from '../pi-tui-pickers.js'; import type { @@ -102,6 +103,7 @@ import { waitForTuiPaint, } from './tui-terminal-mock.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +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 @@ -1769,11 +1771,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 () => { + 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[] = []; + // Record real renders before TUI consumes the IME marker. + const wizardRenderSpy = t.mock.method(OnboardingWizard.prototype, 'render'); const run = runMakaPiTui({ title: 'Maka', driver, @@ -1804,23 +1811,70 @@ describe('Maka Pi TUI runner', () => { }), }); + 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'); + 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('\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); // Select OpenAI; Name receives focus. await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('2/4')); - // Replace the prefilled provider label with a display name. - for (let i = 0; i < 'OpenAI'.length; i++) terminal.input('\x7f'); + await assertIdentityFields(` +Name OpenAI + +Slug openai +`); + + terminal.input(CLEAR_LINE); + await assertIdentityFields(` +Name + +Slug openai +`); + terminal.input('Work OpenAI'); - terminal.input('\r'); // name -> slug field - // Replace the derived suggestion with a chosen slug. - for (let i = 0; i < 'openai'.length; i++) terminal.input('\x7f'); + await assertIdentityFields(` +Name Work OpenAI + +Slug openai +`); + + terminal.input(ENTER); + await assertIdentityFields(` +Name Work OpenAI + +Slug openai +`); + + terminal.input(CLEAR_LINE); + await assertIdentityFields(` +Name Work OpenAI + +Slug +`); + terminal.input('openai-work'); - terminal.input('\r'); // slug -> key phase + await assertIdentityFields(` +Name Work OpenAI + +Slug openai-work +`); + + // 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('\r'); + terminal.input(ENTER); await waitFor(() => verifyCalls.length === 1); assert.deepEqual(verifyCalls[0]?.target, { kind: 'create', @@ -1830,11 +1884,11 @@ 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); - process.emit('SIGTERM'); + exitMaka(terminal); await Promise.race([ run, delay(CLOSE_BUDGET_MS).then(() => { 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..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 @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { 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,12 +27,100 @@ import { UserQuestionOverlay, } from '../pi-tui-pickers.js'; import { ansi, stripAnsi } from '../tui-ansi.js'; +import { FakeTerminal } from './tui-terminal-mock.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 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 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) => submittedAnswers.push(value), + onSkip: () => undefined, + }); + + 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 + keep the current model settings for + this workspace +`); + + question.handleInput(ARROW_UP); + assertQuestionBody(` +→ Preset + Please use the custom provider and + keep the current model settings for + this workspace +`); + + question.handleInput(ARROW_DOWN); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); + + // Refocus with the cursor over an existing character. + question.handleInput(ARROW_LEFT); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); + + question.handleInput(ARROW_UP); + assertQuestionBody(` +→ Preset + Please use the custom provider and + keep the current model settings for + this workspace +`); + + question.handleInput(ARROW_DOWN); + assertQuestionBody(` + Preset +→ Please use the custom provider and + keep the current model settings for + this workspace +`); + + question.handleInput(ENTER); + assertQuestionBody(` + Preset +→ +`); + assert.deepEqual(submittedAnswers, [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..72030a0ea1 100644 --- a/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts +++ b/packages/cli/src/__tests__/tui-autocomplete-layout.test.ts @@ -19,8 +19,77 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { fitAutocompleteLines } from '../tui-autocomplete-layout.js'; +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, plainTerminalOutput } from './tui-terminal-mock.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); + const tui = new TuiMainScreen(terminal); + t.after(() => tui.stop()); + 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 composerRenderSpy = t.mock.method(composer, 'render'); + + const assertScreen = (expectedScene: string) => { + tui.renderNow(true); + 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(` + +──────────────────────────────────────── +draft +──────────────────────────────────────── +`); + + const overlay = tui.showOverlay(new Text('Picker', 0, 0), { anchor: 'top-left' }); + assertScreen(` +Picker +──────────────────────────────────────── +draft +──────────────────────────────────────── +`); + + overlay.hide(); + assertScreen(` + +──────────────────────────────────────── +draft +──────────────────────────────────────── +`); +}); describe('fitAutocompleteLines', () => { test('keeps the selected item visible and reports the full command count', () => { diff --git a/packages/cli/src/__tests__/tui-render-expectations.ts b/packages/cli/src/__tests__/tui-render-expectations.ts new file mode 100644 index 0000000000..4c9b31d0dc --- /dev/null +++ b/packages/cli/src/__tests__/tui-render-expectations.ts @@ -0,0 +1,43 @@ +/* + * 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'; + +// 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; + }); +} diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index b57611a640..bda3b02670 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -63,6 +63,7 @@ import type { OnboardingRejectionReason, } from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; +import { stripUnfocusedCursorStyle } from './tui-editor-render.js'; import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; interface TuiPickerCopy { @@ -677,24 +678,13 @@ 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. + 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)]; } - // 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 renderFieldRow(this.editor, marker, width); } } @@ -1056,16 +1046,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 renderFieldRow(editor, label, width); } } @@ -1203,6 +1184,20 @@ function padLine(text: string, width: number): string { return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; } +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 + // scrolling, but omit its top/bottom borders. + const lines = editor.render(contentWidth).slice(1, -1); + 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), + ); +} + function keyEntryHint( copy: TuiPickerCopy, hasConnection: boolean, @@ -2001,15 +1996,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); - if (editorLines.length === 0) { - return [padLine(prefix, width)]; - } - return editorLines.map((line, index) => - padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, 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 435dd3b07e..2dd5357125 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 { 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 @@ -124,7 +125,8 @@ export class MakaAutocompleteAboveEditorComponent implements Component { } render(width: number): string[] { - const lines = this.editor.render(width); + const renderedLines = this.editor.render(width); + 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 new file mode 100644 index 0000000000..f0765d88b2 --- /dev/null +++ b/packages/cli/src/tui-editor-render.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +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): + // + // 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')); +} 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', ];