diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2127529a45..e496477bec 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -1892,6 +1892,46 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('a question overlay opened on a short terminal keeps its input row after the terminal grows (#4610)', async () => { + const terminal = new FakeTerminal(60, 12); + const driver = new LongOptionsQuestionDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + terminal.input('choose'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Pick a strategy')); + // Over the small budget the overlay self-clamps: the free-text input row + // and divider survive and the option tails are elided. + let screen = plainTerminalOutput(terminal.screenOutput()); + assert.ok(screen.includes('方案甲'), 'option labels stay visible when clamped'); + assert.ok(screen.includes('Other: type your answer'), 'input row must survive clamping'); + assert.ok(!screen.includes('TAIL-A'), 'option tails are elided at 12 rows'); + + // Growing the terminal lifts the cap: the full options render and the + // input row stays on screen. A maxHeight frozen at open time would keep + // slicing to the old 8 rows here (review on #4615). + terminal.resize(60, 24); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('TAIL-B')); + screen = plainTerminalOutput(terminal.screenOutput()); + assert.ok(screen.includes('TAIL-A'), 'option tails render after the grow'); + assert.ok(screen.includes('Other: type your answer'), 'input row must survive the resize'); + + terminal.input('\x1b'); + await waitFor(() => driver.responses.length === 1); + assert.deepEqual(driver.responses, [{ requestId: 'question-1', answers: [null] }]); + + exitMaka(terminal); + await run; + }); + test('Ctrl-C stops a turn while a user-question overlay is open', async () => { const terminal = new FakeTerminal(); const driver = new UserQuestionPromptDriver(); @@ -8586,6 +8626,51 @@ class UserQuestionPromptDriver extends FakeSessionDriver { } } +class LongOptionsQuestionDriver extends FakeSessionDriver { + readonly responses: UserQuestionResponse[] = []; + private release: (() => void) | undefined; + preparePrompt(prompt: string): Promise { + return prepareTestPrompt(this, prompt); + } + async *promptEvents(_prompt: string): AsyncIterable { + const filler = '兼容性说明'.repeat(12); + yield { + type: 'user_question_request', + id: 'event-question', + turnId: 'turn-1', + ts: 1, + requestId: 'question-1', + toolUseId: 'tool-1', + questions: [ + { + question: 'Pick a strategy', + options: [ + { label: '方案甲', description: `${filler}TAIL-A` }, + { label: '方案乙', description: `${filler}TAIL-B` }, + ], + }, + ], + }; + await new Promise((resolve) => { + this.release = resolve; + }); + yield { type: 'complete', id: 'complete-1', turnId: 'turn-1', ts: 2, stopReason: 'end_turn' }; + } + async respondToUserQuestion(response: UserQuestionResponse): Promise { + this.responses.push(response); + this.release?.(); + } + async rewindToTurn(): Promise { + throw new Error('rewind not supported'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string { + return 'session-1'; + } +} + class InterruptibleTurnDriver extends FakeSessionDriver { stopCalls = 0; readonly prompts: string[] = []; 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 new file mode 100644 index 0000000000..afa08df148 --- /dev/null +++ b/packages/cli/src/__tests__/pi-tui-user-question-option.test.ts @@ -0,0 +1,210 @@ +/* + * 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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { visibleWidth } from '@earendil-works/pi-tui'; +import type { TUI } from '@earendil-works/pi-tui'; +import { + clampRowsWithEllipsis, + formatUserQuestionOptionRow, + UserQuestionOverlay, +} from '../pi-tui-pickers.js'; +import { ansi, stripAnsi } from '../tui-ansi.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('long options wrap within the row width instead of truncating (#4610)', () => { + const option = { + label: '默认省略 + 优雅降级(推荐)', + description: + '用户没改默认值就不带新字段(任何版本 Host 都能存);改了才带,旧 Host 拒绝时映射成本地化提示。不动 epoch,远程旧 Host 场景不 break。', + }; + const rows = formatUserQuestionOptionRow(option, false, 40); + assert.ok(rows.length > 1, 'expected the option to wrap onto multiple rows'); + for (const row of rows) { + assert.ok(visibleWidth(row) <= 40, `row exceeds width: ${JSON.stringify(row)}`); + } + // No content is lost: the wrapped rows still carry the full label + + // description. Whitespace is collapsed away because wrap points may fall + // mid-fragment (CJK has no spaces to break on). + const squash = (value: string) => value.replace(/\s+/g, ''); + const text = squash(rows.map((row) => stripAnsi(row)).join('')); + assert.ok( + text.includes(squash(`${option.label} ${option.description}`)), + 'label + description survive wrapping intact', + ); +}); + +test('continuation lines align under the option body, only the first carries the marker', () => { + const rows = formatUserQuestionOptionRow( + { label: 'a'.repeat(30), description: 'b'.repeat(30) }, + false, + 20, + ); + assert.ok(rows.length > 1); + assert.ok(stripAnsi(rows[0] ?? '').startsWith(' ')); + for (const row of rows.slice(1)) { + assert.ok( + stripAnsi(row).startsWith(' '), + `continuation must indent past the marker column: ${JSON.stringify(row)}`, + ); + } +}); + +test('the active row highlights every wrapped line and keeps the arrow marker', () => { + const option = { label: 'c'.repeat(30), description: 'd'.repeat(30) }; + const rows = formatUserQuestionOptionRow(option, true, 20); + assert.ok(rows.length > 1); + assert.ok(stripAnsi(rows[0] ?? '').startsWith('→ ')); + // Exact band coverage, color-level independent: each emitted row must equal + // the ansi module's own reverse of its plain text (identity when colorless, + // SGR-wrapped otherwise) — a band that covers only part of the row fails. + for (const row of rows) { + assert.equal(row, ansi.reverse(stripAnsi(row)), 'highlight band must cover the whole row'); + } + if (COLOR_ENABLED) { + for (const row of rows) { + assert.ok(row.includes(REVERSE_ON), `active line must be reversed: ${JSON.stringify(row)}`); + } + } +}); + +test('the dim description style re-opens on every wrapped line', () => { + if (!COLOR_ENABLED) return; + const option = { label: 'ab', description: '描'.repeat(40) }; + const rows = formatUserQuestionOptionRow(option, false, 20); + assert.ok(rows.length > 1); + for (const row of rows.slice(1)) { + assert.ok( + row.includes('\u001b[2m'), + `wrapped description line lost its dim: ${JSON.stringify(row)}`, + ); + } +}); + +test('over-budget rows clamp with a visible ellipsis and stay within width', () => { + const option = { + label: '默认省略 + 优雅降级(推荐)', + description: '用户没改默认值就不带新字段'.repeat(10), + }; + const rows = formatUserQuestionOptionRow(option, false, 30); + assert.ok(rows.length > 3); + const clamped = clampRowsWithEllipsis(rows, 2, 30); + assert.equal(clamped.length, 2); + assert.ok( + stripAnsi(clamped[1] ?? '') + .trimEnd() + .endsWith('…'), + ); + for (const row of clamped) { + assert.ok(visibleWidth(row) <= 30, `clamped row exceeds width: ${JSON.stringify(row)}`); + } + // The clamp marker survives the active (reversed) variant too. + const activeClamped = clampRowsWithEllipsis(formatUserQuestionOptionRow(option, true, 30), 1, 30); + assert.equal(activeClamped.length, 1); + assert.ok( + stripAnsi(activeClamped[0] ?? '') + .trimEnd() + .endsWith('…'), + ); + assert.ok(visibleWidth(activeClamped[0] ?? '') <= 30); + // Rows already within budget pass through untouched. + assert.deepEqual(clampRowsWithEllipsis(rows, rows.length, 30), rows); +}); + +test('short options stay on one row; options without a description render the label only', () => { + const single = formatUserQuestionOptionRow({ label: '短选项' }, false, 40); + assert.equal(single.length, 1); + assert.equal(stripAnsi(single[0] ?? '').trim(), '短选项'); +}); + +test('degenerate widths still emit one padded row per wrapped segment', () => { + const rows = formatUserQuestionOptionRow({ label: 'x' }, false, 1); + assert.ok(rows.length >= 1); + for (const row of rows) { + assert.ok(visibleWidth(row) <= 1); + } +}); + +test('render() respects the row budget: every option, input row, and divider survive', () => { + const long = { + label: '默认省略 + 优雅降级(推荐)', + description: '用户没改默认值就不带新字段'.repeat(12), + }; + // The Editor only stores the tui reference at construction; render() with an + // option highlighted never calls editor.render, so a stub suffices. + const overlay = new UserQuestionOverlay({} as unknown as TUI, { + title: + '你想怎么处理 runtime-parametric field(如 platform)在 SettingSnapshot 中的序列化兼容性?'.repeat( + 3, + ), + rightLabel: '1 / 1', + hint: '↑↓ move · type to answer · Enter select · Esc unanswered', + placeholder: 'Other: type your answer…', + options: [long, long, long], + maxRows: () => 12, + onSelectOption: () => undefined, + onSubmitText: () => undefined, + onSkip: () => undefined, + }); + const lines = overlay.render(50); + assert.ok(lines.length <= 12, `over budget: ${lines.length} rows`); + const plain = lines.map((line) => stripAnsi(line)); + // Chrome survives: hint, the free-text input row, and the closing divider. + assert.ok( + plain.some((line) => line.includes('↑↓ move')), + 'hint must render', + ); + assert.ok( + plain.some((line) => line.includes('Other: type your answer')), + 'input row must render', + ); + assert.ok(plain[plain.length - 1]?.startsWith('---'), 'divider must close the overlay'); + // Every option still has a visible, selectable row. + const optionRows = plain.filter((line) => line.includes('默认省略')); + assert.equal(optionRows.length, 3, 'each option keeps at least its first row'); + // The elision is visible, and the title was capped at two lines. + assert.ok( + plain.some((line) => line.includes('…')), + 'clamped rows must show an ellipsis', + ); + const hintIndex = plain.findIndex((line) => line.includes('↑↓ move')); + assert.ok(hintIndex <= 3, `title must cap at two lines, hint found at row ${hintIndex}`); +}); + +test('render() without a budget renders every wrapped line', () => { + const overlay = new UserQuestionOverlay({} as unknown as TUI, { + title: 't', + rightLabel: '1 / 1', + hint: 'h', + placeholder: 'Other: type your answer…', + options: [{ label: 'x'.repeat(50), description: 'd'.repeat(200) }], + onSelectOption: () => undefined, + onSubmitText: () => undefined, + onSkip: () => undefined, + }); + const lines = overlay.render(40); + const optionRows = lines.map(stripAnsi).filter((line) => line.includes('ddd')); + assert.ok(optionRows.length > 3, 'no clamping without a budget'); + assert.ok(!optionRows.some((line) => line.includes('…')), 'nothing elided without a budget'); +}); diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 73d846da1f..2567c3fd9b 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -27,6 +27,7 @@ import { matchesKey, truncateToWidth, visibleWidth, + wrapTextWithAnsi, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, @@ -533,6 +534,14 @@ export class UserQuestionOverlay implements Component { hint: string; placeholder: string; options: readonly UserQuestionOption[]; + /** + * Live row budget for the overlay (the runner derives it from + * `terminal.rows`, so it stays correct across resizes). When the wrapped + * content would exceed it, render() degrades gracefully instead of + * letting pi-tui clip the tail — the input row and divider must always + * render (#4610). + */ + maxRows?(): number; onSelectOption(index: number): void; onSubmitText(value: string): void; onSkip(): void; @@ -608,25 +617,49 @@ export class UserQuestionOverlay implements Component { render(width: number): string[] { const safeWidth = Math.max(1, width); - const lines: string[] = [ - padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), - padLine(ansi.dim(this.input.hint), safeWidth), - padLine('', safeWidth), + // The title wraps like the option rows: a long question must not lose its + // tail to a hard cut at the terminal width (#4610). + const wrappedTitle = wrapTextWithAnsi( + `${this.input.title} ${ansi.accent(this.input.rightLabel)}`, + safeWidth, + ); + const titleLines = (wrappedTitle.length > 0 ? wrappedTitle : ['']).map((line) => + padLine(line, safeWidth), + ); + const hint = padLine(ansi.dim(this.input.hint), safeWidth); + const blank = padLine('', safeWidth); + const divider = padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth); + const inputRows = this.renderInputRow(safeWidth); + const optionRows = this.input.options.map((option, index) => + formatUserQuestionOptionRow(option, index === this.activeIndex, safeWidth), + ); + const assemble = (title: string[], options: string[][]): string[] => [ + ...title, + hint, + blank, + ...options.flat(), + ...inputRows, + divider, ]; - this.input.options.forEach((option, index) => { - lines.push(this.renderOptionRow(option, index === this.activeIndex, safeWidth)); - }); - lines.push(...this.renderInputRow(safeWidth)); - lines.push(padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth)); - return lines; - } - - private renderOptionRow(option: UserQuestionOption, active: boolean, width: number): string { - const prefix = active ? '→ ' : ' '; - const body = option.description - ? `${option.label} ${active ? option.description : ansi.dim(option.description)}` - : option.label; - return formatPickerItemLine(`${prefix}${body}`, width); + const full = assemble(titleLines, optionRows); + const budget = this.input.maxRows?.() ?? Number.POSITIVE_INFINITY; + if (full.length <= budget) return full; + // Over budget pi-tui would slice(0, maxHeight) — silently dropping the + // input row and divider. Degrade instead: cap the title at two lines and + // give every option an equal share of the remaining rows, each ending in + // a visible ellipsis when clamped. Only a terminal too short for one row + // per option still overflows, falling back to the pre-existing clip. + const cappedTitle = clampRowsWithEllipsis(titleLines, 2, safeWidth); + const fixedRows = cappedTitle.length + 2 + inputRows.length + 1; + const optionBudget = Math.max(this.input.options.length, budget - fixedRows); + const perOption = Math.max( + 1, + Math.floor(optionBudget / Math.max(1, this.input.options.length)), + ); + return assemble( + cappedTitle, + optionRows.map((rows) => clampRowsWithEllipsis(rows, perOption, safeWidth)), + ); } private renderInputRow(width: number): string[] { @@ -979,6 +1012,49 @@ function formatPickerItemLine(line: string, width: number): string { return stripAnsi(line).startsWith('→ ') ? ansi.reverse(padded) : padded; } +/** + * One AskUserQuestion option row, wrapped instead of truncated (#4610): the + * option body is the decision content, and options routinely carry long + * trade-off descriptions that a hard cut at the terminal width made + * unreadable. Continuation lines indent under the option body, aligned past + * the `→ `/` ` marker; the active row's highlight band covers every wrapped + * line, not just the first. + */ +export function formatUserQuestionOptionRow( + option: UserQuestionOption, + active: boolean, + width: number, +): string[] { + const safeWidth = Math.max(1, width); + const prefix = active ? '→ ' : ' '; + const body = option.description + ? `${option.label} ${active ? option.description : ansi.dim(option.description)}` + : option.label; + const wrapped = wrapTextWithAnsi(body, Math.max(1, safeWidth - USER_QUESTION_ROW_PREFIX_WIDTH)); + const continuation = ' '.repeat(USER_QUESTION_ROW_PREFIX_WIDTH); + return (wrapped.length > 0 ? wrapped : ['']).map((line, index) => { + const padded = padLine(`${index === 0 ? prefix : continuation}${line}`, safeWidth); + return active ? ansi.reverse(padded) : padded; + }); +} + +/** + * Cap an already-formatted wrapped row group at `maxRows`, folding the last + * kept line into an ellipsis so the elision is visible. truncateToWidth is + * ANSI-aware, so an active (reversed) line keeps its closing SGR. + */ +export function clampRowsWithEllipsis(rows: string[], maxRows: number, width: number): string[] { + if (rows.length <= maxRows) return rows; + const keep = Math.max(1, maxRows); + const kept = rows.slice(0, keep); + // truncateToWidth only appends its marker when it actually cuts, and the + // kept row is already padded to full width — so cut one column short and + // add the ellipsis by hand to guarantee the elision stays visible. + const shortened = truncateToWidth(kept[kept.length - 1] ?? '', Math.max(1, width - 1), ''); + kept[kept.length - 1] = padLine(`${shortened}…`, width); + return kept; +} + function padLine(text: string, width: number): string { const safeWidth = Math.max(1, width); const trimmed = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, '') : text; diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index b025e7c572..b3eaa7578d 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2021,7 +2021,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { tui.showOverlay(picker, { anchor: 'bottom-left', width: '100%', - maxHeight: Math.max(1, terminal.rows - BOTTOM_PICKER_MARGIN_ROWS), + // A numeric cap would freeze today's rows into the option; '100%' is + // re-resolved against the live terminal height on every composite, so + // after a resize the cap still lands at rows - margin (#4610 review). + maxHeight: '100%', margin: { bottom: BOTTOM_PICKER_MARGIN_ROWS }, }); @@ -2079,6 +2082,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { hint: '↑↓ move · type to answer · Enter select · Esc unanswered · Ctrl+C stop', placeholder: 'Other: type your answer…', options: question.options, + // Live budget: terminal.rows changes on resize, so read it per render + // rather than at overlay construction. + maxRows: () => Math.max(1, terminal.rows - BOTTOM_PICKER_MARGIN_ROWS), onSelectOption: (index) => advance(question.options[index]?.label ?? null), onSubmitText: (value) => advance(value), onSkip: () => advance(null),