diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index be446a634f..1c796e2e40 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Ordinary `ask` selectors now bound long question premises and page through every premise row without skipping rows hidden by overflow indicators (#3675). ## [0.12.6] - 2026-07-31 ### Added diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index a1aa173fde..ef76cb25c2 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -47,6 +47,21 @@ export interface HookSelectorOptions { */ wrapFocused?: boolean; scrollTitleRows?: number; + /** + * Maximum visible rows for the inline editor in bounded scroll-title selectors. + * The editor keeps the full draft and scrolls within this height. + */ + inlineEditorMaxHeight?: number; + /** + * Maximum visible autocomplete rows for the inline editor in a bounded selector. + * The Editor retains its normal minimum and scroll indicator behavior. + */ + inlineAutocompleteMaxVisible?: number; + /** + * Compact bounded-selector chrome while the inline editor is active. + * Used only when the terminal cannot fit the normal editor and dropdown rows. + */ + compactInlineInput?: boolean; /** * Inline free-text entry for the option with this label (e.g. the ask * tool's "Other (type your own)"). Selecting it keeps the title and option @@ -103,6 +118,7 @@ class ScrollableTitle extends Container { #maxRows: number; #scrollOffset = 0; #lastMaxScrollOffset = 0; + #lastRenderWidth?: number; constructor(title: string, maxRows: number) { super(); @@ -110,9 +126,15 @@ class ScrollableTitle extends Container { this.#markdown = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) }); } - setText(text: string): void { + setText(text: string, resetScroll = true): void { this.#markdown.setText(text); - this.#scrollOffset = 0; + if (resetScroll) this.#scrollOffset = 0; + this.invalidate(); + } + setMaxRows(maxRows: number): void { + const wasAtBottom = this.#lastMaxScrollOffset > 0 && this.#scrollOffset >= this.#lastMaxScrollOffset; + this.#maxRows = Math.max(1, Math.floor(maxRows)); + if (wasAtBottom) this.#scrollOffset = Number.MAX_SAFE_INTEGER; this.invalidate(); } @@ -125,7 +147,30 @@ class ScrollableTitle extends Container { } render(width: number): string[] { - const lines = this.#markdown.render(width); + const wasAtBottom = this.#lastMaxScrollOffset > 0 && this.#scrollOffset >= this.#lastMaxScrollOffset; + let lines: string[]; + if (this.#lastRenderWidth !== undefined && this.#lastRenderWidth !== width) { + const previous = this.#markdown.renderWithViewportAnchorSource(this.#lastRenderWidth, { id: "title" }); + const previousOffset = Math.min(this.#scrollOffset, previous.anchors.length - 1); + const previousAnchor = + previous.anchors[previousOffset] ?? + previous.anchors.slice(previousOffset + 1).find(anchor => anchor !== null) ?? + previous.anchors.slice(0, previousOffset).findLast(anchor => anchor !== null); + const reflowed = this.#markdown.renderWithViewportAnchorSource(width, { id: "title" }); + lines = reflowed.lines; + if (previousAnchor) { + const anchoredLine = reflowed.anchors.findIndex( + anchor => + anchor !== null && + anchor.graphemeStart <= previousAnchor.graphemeStart && + previousAnchor.graphemeStart < anchor.graphemeEnd, + ); + if (anchoredLine >= 0) this.#scrollOffset = anchoredLine; + } + } else { + lines = this.#markdown.render(width); + } + this.#lastRenderWidth = width; if (lines.length <= this.#maxRows) { this.#lastMaxScrollOffset = 0; this.#scrollOffset = 0; @@ -137,22 +182,17 @@ class ScrollableTitle extends Container { this.#lastMaxScrollOffset = maxScrollOffset; this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset)); - const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows); - const indicator = - this.#scrollOffset === 0 - ? theme.fg("dim", " PgDn↓") - : this.#scrollOffset >= maxScrollOffset - ? theme.fg("dim", " PgUp↑") - : theme.fg("dim", " PgUp/PgDn↕"); - const lastIndex = visibleLines.length - 1; - const availableWidth = Math.max(1, width - visibleWidth(indicator)); - const fittedLine = truncateToWidth(visibleLines[lastIndex] ?? "", availableWidth); - visibleLines[lastIndex] = `${fittedLine}${indicator}`; - return visibleLines; + return lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows); } + if (wasAtBottom) { + // The previous render may have reached the bottom after converging + // away the bottom indicator. Re-anchor before recomputing indicators + // so a timer repaint cannot resurrect a stale `▼ more` row. + this.#scrollOffset = Math.max(0, lines.length - (this.#maxRows - 1)); + } let showTopIndicator = this.#scrollOffset > 0; - let showBottomIndicator = true; + let showBottomIndicator = !wasAtBottom; let contentRows = 1; let maxScrollOffset = 0; @@ -234,12 +274,14 @@ class FocusAwareList extends Container { ); const focusedWrappedSegments = wrapTextWithAnsi(focusedLabel, availableLabelWidth); - // Reserve one row for the option position marker only when the focused - // block itself must be compacted. Moderate focused labels keep the legacy - // wrap-focused behavior and spend the full viewport on label context. + // Reserve one row when a non-full focused block and its sibling window + // cannot both fit. A full focused block keeps its existing priority. const totalOptions = this.#options.length; const mustCompactFocused = focusedWrappedSegments.length > this.#maxVisibleRows; - const positionMarkerSlot = mustCompactFocused && totalOptions > 1 ? 1 : 0; + const mustMarkClippedSiblings = + focusedWrappedSegments.length < this.#maxVisibleRows && + focusedWrappedSegments.length + totalOptions - 1 > this.#maxVisibleRows; + const positionMarkerSlot = (mustCompactFocused || mustMarkClippedSiblings) && totalOptions > 1 ? 1 : 0; const focusedBudget = Math.max(1, this.#maxVisibleRows - positionMarkerSlot); const focusedSegments = this.#capFocusedSegments(focusedWrappedSegments, focusedBudget, availableLabelWidth); const focusedRows = Math.max(1, focusedSegments.length); @@ -296,7 +338,7 @@ class FocusAwareList extends Container { if (rows.length <= budget) return rows; if (budget === 1) { - return [truncateToWidth(`… ${rows.length - 1} wrapped rows omitted …`, availableLabelWidth)]; + return [truncateToWidth(rows[0] ?? "", availableLabelWidth)]; } if (budget === 2) { @@ -347,6 +389,7 @@ export class HookSelectorComponent extends Container { #wrapFocused: boolean; #outline: boolean; #scrollTitleRows: number | undefined; + #inlineEditorMaxHeight: number | undefined; #customInput: { optionLabel: string; onSubmit: (text: string) => void } | undefined; #clarificationInput: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined; #activeInput: { onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined; @@ -357,6 +400,10 @@ export class HookSelectorComponent extends Container { #tui: TUI | undefined; #autocompleteProvider: AutocompleteProvider | undefined; #acceleratorMap: Readonly> | undefined; + #inlineAutocompleteMaxVisible: number | undefined; + #compactInlineInput: boolean; + #titleSpacer: Spacer; + #inputSpacer: Spacer; constructor( title: string, options: string[], @@ -368,7 +415,6 @@ export class HookSelectorComponent extends Container { this.#options = options; this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1); - this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12); this.#onSelectCallback = onSelect; this.#onCancelCallback = onCancel; this.#baseTitle = title; @@ -382,13 +428,21 @@ export class HookSelectorComponent extends Container { this.#tui = opts?.tui; this.#autocompleteProvider = opts?.autocompleteProvider; this.#acceleratorMap = opts?.acceleratorMap; + this.#inlineAutocompleteMaxVisible = + opts?.inlineAutocompleteMaxVisible === undefined + ? undefined + : Math.max(3, Math.floor(opts.inlineAutocompleteMaxVisible)); + this.#compactInlineInput = opts?.compactInlineInput === true; this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); const scrollTitleRows = opts?.scrollTitleRows === undefined ? undefined : Math.max(1, Math.floor(opts.scrollTitleRows)); + this.#maxVisible = Math.max(scrollTitleRows === undefined ? 3 : 1, opts?.maxVisible ?? 12); this.#scrollTitleRows = scrollTitleRows; + this.#inlineEditorMaxHeight = + opts?.inlineEditorMaxHeight === undefined ? undefined : Math.max(1, Math.floor(opts.inlineEditorMaxHeight)); if (scrollTitleRows === undefined) { this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) }); } else { @@ -396,13 +450,20 @@ export class HookSelectorComponent extends Container { this.#titleComponent = this.#scrollableTitle; } this.addChild(this.#titleComponent); - this.addChild(new Spacer(1)); + this.#titleSpacer = new Spacer(1); + this.addChild(this.#titleSpacer); if (opts?.timeout && opts.timeout > 0 && opts.tui) { this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), + s => { + if (this.#scrollableTitle) { + this.#scrollableTitle.setText(`${this.#baseTitle} (${s}s)`, false); + } else { + this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`); + } + }, () => { opts?.onTimeout?.(); // Auto-select current option on timeout (typically the first/recommended option) @@ -431,7 +492,8 @@ export class HookSelectorComponent extends Container { } this.#inputArea = new Container(); this.addChild(this.#inputArea); - this.addChild(new Spacer(1)); + this.#inputSpacer = new Spacer(1); + this.addChild(this.#inputSpacer); this.#baseHelpText = opts?.helpText ?? "up/down navigate enter select esc cancel"; this.#helpTextComponent = new Text(theme.fg("dim", this.#baseHelpText), 1, 0); this.addChild(this.#helpTextComponent); @@ -444,6 +506,38 @@ export class HookSelectorComponent extends Container { hasActiveInlineInput(): boolean { return this.#inlineEditor !== undefined; } + /** + * Update the bounded selector's title/list row budgets after a terminal resize. + * The existing title scroll offset, option focus, and inline editor remain intact. + */ + setLayoutBudget( + maxVisible: number, + scrollTitleRows: number, + inlineEditorMaxHeight = this.#inlineEditorMaxHeight, + inlineAutocompleteMaxVisible = this.#inlineAutocompleteMaxVisible, + compactInlineInput = this.#compactInlineInput, + ): void { + if (!this.#scrollableTitle) return; + const titleRows = Math.max(1, Math.floor(scrollTitleRows)); + this.#maxVisible = Math.max(1, Math.floor(maxVisible)); + this.#scrollTitleRows = titleRows; + this.#inlineEditorMaxHeight = + inlineEditorMaxHeight === undefined ? undefined : Math.max(1, Math.floor(inlineEditorMaxHeight)); + this.#inlineAutocompleteMaxVisible = + inlineAutocompleteMaxVisible === undefined ? undefined : Math.max(3, Math.floor(inlineAutocompleteMaxVisible)); + this.#compactInlineInput = compactInlineInput; + if (this.#inlineEditor) { + this.#inlineEditor.setMaxHeight(this.#inlineEditorMaxHeight); + if (this.#inlineAutocompleteMaxVisible !== undefined) { + this.#inlineEditor.setAutocompleteMaxVisible(this.#inlineAutocompleteMaxVisible); + } + this.#inlineEditor.setAutocompleteProvider(this.#compactInlineInput ? undefined : this.#autocompleteProvider); + } + this.#setCompactInputSpacing(this.#inlineEditor !== undefined && this.#compactInlineInput); + this.#scrollableTitle.setMaxRows(titleRows); + this.#updateList(); + this.invalidate(); + } #updateList(): void { if (this.#wrapFocused && this.#focusAwareList) { @@ -489,21 +583,24 @@ export class HookSelectorComponent extends Container { // Reset countdown on any interaction this.#countdown?.reset(); - if (this.#scrollTitleRows !== undefined && matchesKey(keyData, "pageUp")) { - this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows); - return; - } - if (this.#scrollTitleRows !== undefined && matchesKey(keyData, "pageDown")) { - this.#scrollableTitle?.scrollBy(this.#scrollTitleRows); - return; - } - if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+u")) { - this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows); - return; - } - if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+d")) { - this.#scrollableTitle?.scrollBy(this.#scrollTitleRows); - return; + if (this.#scrollTitleRows !== undefined && !this.#inlineEditor?.isAutocompleteOpen()) { + const titlePageRows = Math.max(1, this.#scrollTitleRows - 2); + if (matchesKey(keyData, "pageUp")) { + this.#scrollableTitle?.scrollBy(-titlePageRows); + return; + } + if (matchesKey(keyData, "pageDown")) { + this.#scrollableTitle?.scrollBy(titlePageRows); + return; + } + if (!this.#inlineEditor && matchesKey(keyData, "ctrl+u")) { + this.#scrollableTitle?.scrollBy(-titlePageRows); + return; + } + if (!this.#inlineEditor && matchesKey(keyData, "ctrl+d")) { + this.#scrollableTitle?.scrollBy(titlePageRows); + return; + } } if (this.#inlineEditor) { this.#handleInputModeKey(keyData, this.#inlineEditor); @@ -573,6 +670,10 @@ export class HookSelectorComponent extends Container { editor.handleInput(keyData); } + #setCompactInputSpacing(compact: boolean): void { + this.#titleSpacer.setLines(compact ? 0 : 1); + this.#inputSpacer.setLines(compact ? 0 : 1); + } #enterInputMode(input: { onSubmit: (text: string) => void; allowEmpty?: boolean }): void { if (this.#inlineEditor) return; this.#activeInput = input; @@ -581,28 +682,37 @@ export class HookSelectorComponent extends Container { if (this.#countdown) { this.#countdown.dispose(); this.#countdown = undefined; - this.#titleComponent.setText(this.#baseTitle); + if (this.#scrollableTitle) { + this.#scrollableTitle.setText(this.#baseTitle, false); + } else { + this.#titleComponent.setText(this.#baseTitle); + } } const editor = new Editor(getEditorTheme()); editor.setBorderVisible(false); editor.setPromptGutter("> "); editor.disableSubmit = true; + editor.setMaxHeight(this.#inlineEditorMaxHeight); + if (this.#inlineAutocompleteMaxVisible !== undefined) { + editor.setAutocompleteMaxVisible(this.#inlineAutocompleteMaxVisible); + } // Mark the inline editor focused only when mirroring the app's hardware-cursor // mode, so it emits CURSOR_MARKER at the input caret for IME preedit anchoring // without changing legacy non-hardware-cursor layout. const useTerminalCursor = this.#tui?.getShowHardwareCursor() ?? false; editor.focused = useTerminalCursor; editor.setUseTerminalCursor(useTerminalCursor); - if (this.#autocompleteProvider) { + if (this.#autocompleteProvider && !this.#compactInlineInput) { editor.setAutocompleteProvider(this.#autocompleteProvider); } this.#inlineEditor = editor; - this.#inputArea.addChild(new Spacer(1)); + this.#setCompactInputSpacing(this.#compactInlineInput); + if (!this.#compactInlineInput) this.#inputArea.addChild(new Spacer(1)); this.#inputArea.addChild(editor); const helpText = this.#scrollTitleRows === undefined ? "enter submit esc back to options ctrl+g external editor" - : "enter submit esc back to options PgUp/PgDn: question · Wheel: transcript"; + : "enter submit esc back to options ctrl+g external editor PgUp/PgDn: question · Wheel: transcript"; this.#helpTextComponent.setText(theme.fg("dim", helpText)); this.invalidate(); } @@ -612,6 +722,7 @@ export class HookSelectorComponent extends Container { this.#inlineEditor = undefined; this.#activeInput = undefined; this.#inputArea.clear(); + this.#setCompactInputSpacing(false); this.#helpTextComponent.setText(theme.fg("dim", this.#baseHelpText)); this.invalidate(); } diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 4b7cf34e54..65811565f6 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -1,6 +1,14 @@ import { ThinkingLevel } from "@gajae-code/agent-core"; -import type { Component, OverlayHandle, TUI } from "@gajae-code/tui"; -import { Container, Spacer, Text } from "@gajae-code/tui"; +import { + type Component, + Container, + type OverlayHandle, + replaceTabs, + Spacer, + Text, + type TUI, + wrapTextWithAnsi, +} from "@gajae-code/tui"; import { logger } from "@gajae-code/utils"; import { KeybindingsManager } from "../../config/keybindings"; import type { @@ -39,7 +47,15 @@ import { prepareTranscriptRebuild } from "../utils/ui-helpers"; const MAX_WIDGET_LINES = 10; const HOOK_SELECTOR_CHROME_ROWS = 7; const HOOK_SELECTOR_OUTLINE_ROWS = 2; -const HOOK_SELECTOR_INLINE_INPUT_ROWS = 2; +const HOOK_SELECTOR_INLINE_EDITOR_ROWS = 2; +const HOOK_SELECTOR_INLINE_AUTOCOMPLETE_ROWS = 6; +const HOOK_SELECTOR_INLINE_INPUT_ROWS = 1 + HOOK_SELECTOR_INLINE_EDITOR_ROWS + HOOK_SELECTOR_INLINE_AUTOCOMPLETE_ROWS; +const HOOK_SELECTOR_INLINE_COMPACT_EDITOR_ROWS = 1; +const HOOK_SELECTOR_INLINE_COMPACT_AUTOCOMPLETE_MAX_VISIBLE = 3; +const HOOK_SELECTOR_INLINE_COMPACT_AUTOCOMPLETE_ROWS = 4; +const HOOK_SELECTOR_INLINE_COMPACT_INPUT_ROWS = + 1 + HOOK_SELECTOR_INLINE_COMPACT_EDITOR_ROWS + HOOK_SELECTOR_INLINE_COMPACT_AUTOCOMPLETE_ROWS; +const HOOK_SELECTOR_INLINE_COMPACT_CHROME_ROWS = 2; const EXTENSION_ACTION_MUTATIONS: ReadonlySet = new Set([ "sendMessage", @@ -91,6 +107,7 @@ export class ExtensionUiController { #activeHookCustomOverlay?: OverlayHandle; #activeHookCustomCancel?: () => void; + #hookSelectorResizeHandler?: () => void; constructor(private ctx: InteractiveModeContext) {} #clearActiveHookCustom(): void { @@ -364,6 +381,11 @@ export class ExtensionUiController { #restoreComposerEditor(): void { this.ctx.restoreComposer(); } + #removeHookSelectorResizeHandler(): void { + if (!this.#hookSelectorResizeHandler) return; + process.stdout.removeListener("resize", this.#hookSelectorResizeHandler); + this.#hookSelectorResizeHandler = undefined; + } #isStopped(): boolean { return this.ctx.isStopped?.() === true; @@ -1165,19 +1187,75 @@ export class ExtensionUiController { dialogOptions?.signal, ); const requestedTitleRows = dialogOptions?.scrollTitleRows; - const baseMaxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); - const scrollOptionRows = Math.max(1, Math.min(baseMaxVisible, options.length)); - const maxVisible = - requestedTitleRows === undefined ? baseMaxVisible : Math.min(15, Math.max(3, scrollOptionRows + 1)); const listChromeRows = dialogOptions?.outline === true ? HOOK_SELECTOR_OUTLINE_ROWS : 0; // Reserve rows for the inline custom-input editor so opening it doesn't // push the scrollable title past the viewport into terminal scrollback. - const inlineInputRows = - dialogOptions?.customInput || dialogOptions?.clarificationInput ? HOOK_SELECTOR_INLINE_INPUT_ROWS : 0; - const availableTitleRows = - this.ctx.ui.terminal.rows - scrollOptionRows - listChromeRows - inlineInputRows - HOOK_SELECTOR_CHROME_ROWS; - const scrollTitleRows = - requestedTitleRows === undefined ? undefined : Math.max(1, Math.min(requestedTitleRows, availableTitleRows)); + const hasInlineInput = + dialogOptions?.customInput !== undefined || dialogOptions?.clarificationInput !== undefined; + const inlineInputRows = hasInlineInput ? HOOK_SELECTOR_INLINE_INPUT_ROWS : 0; + const helpText = dialogOptions?.helpText ?? "up/down navigate enter select esc cancel"; + const inlineInputHelpText = + requestedTitleRows === undefined + ? "enter submit esc back to options ctrl+g external editor" + : "enter submit esc back to options ctrl+g external editor PgUp/PgDn: question · Wheel: transcript"; + const computeBudget = (): { + maxVisible: number; + scrollTitleRows: number | undefined; + inlineEditorMaxHeight: number | undefined; + inlineAutocompleteMaxVisible: number | undefined; + compactInlineInput: boolean; + } => { + const baseMaxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); + const scrollOptionRows = Math.max(1, Math.min(baseMaxVisible, options.length)); + const helpWidth = Math.max(1, this.ctx.ui.terminal.columns - 2); + const helpTextRows = Math.max( + wrapTextWithAnsi(replaceTabs(helpText), helpWidth).length, + hasInlineInput ? wrapTextWithAnsi(replaceTabs(inlineInputHelpText), helpWidth).length : 0, + 1, + ); + const baseChromeRows = HOOK_SELECTOR_CHROME_ROWS - 1 + helpTextRows; + const compactInlineInput = + requestedTitleRows !== undefined && + hasInlineInput && + this.ctx.ui.terminal.rows < baseChromeRows + listChromeRows + inlineInputRows + 2; + const chromeRows = baseChromeRows - (compactInlineInput ? HOOK_SELECTOR_INLINE_COMPACT_CHROME_ROWS : 0); + const effectiveInlineInputRows = compactInlineInput + ? HOOK_SELECTOR_INLINE_COMPACT_INPUT_ROWS + : inlineInputRows; + const maxVisible = + requestedTitleRows === undefined + ? baseMaxVisible + : Math.max( + 1, + Math.min( + 15, + scrollOptionRows, + this.ctx.ui.terminal.rows - listChromeRows - effectiveInlineInputRows - chromeRows - 1, + ), + ); + const availableTitleRows = + this.ctx.ui.terminal.rows - maxVisible - listChromeRows - effectiveInlineInputRows - chromeRows; + const scrollTitleRows = + requestedTitleRows === undefined + ? undefined + : Math.max(1, Math.min(requestedTitleRows, availableTitleRows)); + return { + maxVisible, + scrollTitleRows, + inlineEditorMaxHeight: + requestedTitleRows !== undefined && hasInlineInput + ? compactInlineInput + ? HOOK_SELECTOR_INLINE_COMPACT_EDITOR_ROWS + : HOOK_SELECTOR_INLINE_EDITOR_ROWS + : undefined, + inlineAutocompleteMaxVisible: compactInlineInput + ? HOOK_SELECTOR_INLINE_COMPACT_AUTOCOMPLETE_MAX_VISIBLE + : undefined, + compactInlineInput, + }; + }; + const { maxVisible, scrollTitleRows, inlineEditorMaxHeight, inlineAutocompleteMaxVisible, compactInlineInput } = + computeBudget(); ringTerminalBell(classifyHookSelectorBellEvent(title)); @@ -1213,8 +1291,6 @@ export class ExtensionUiController { timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, tui: this.ctx.ui, - // Share the main prompt editor's autocomplete provider so the - // inline "Other (type your own)" editor supports `@` file links. autocompleteProvider: dialogOptions?.customInput || dialogOptions?.clarificationInput ? this.ctx.editor.getAutocompleteProvider() @@ -1223,6 +1299,9 @@ export class ExtensionUiController { wrapFocused: dialogOptions?.wrapFocused, scrollTitleRows, maxVisible, + inlineEditorMaxHeight, + inlineAutocompleteMaxVisible, + compactInlineInput, customInput: dialogOptions?.customInput ? { optionLabel: dialogOptions.customInput.optionLabel, @@ -1255,6 +1334,24 @@ export class ExtensionUiController { this.ctx.editorContainer.addChild(this.ctx.hookSelector); this.ctx.ui.setFocus(this.ctx.hookSelector); this.ctx.ui.requestRender(); + if (requestedTitleRows !== undefined) { + this.#removeHookSelectorResizeHandler(); + const resizeHandler = () => { + const selector = this.ctx.hookSelector; + const nextBudget = computeBudget(); + if (!selector || nextBudget.scrollTitleRows === undefined) return; + selector.setLayoutBudget( + nextBudget.maxVisible, + nextBudget.scrollTitleRows, + nextBudget.inlineEditorMaxHeight, + nextBudget.inlineAutocompleteMaxVisible, + nextBudget.compactInlineInput, + ); + this.ctx.ui.requestRender(); + }; + this.#hookSelectorResizeHandler = resizeHandler; + process.stdout.on("resize", resizeHandler); + } attachAbort(); return promise; } @@ -1263,6 +1360,7 @@ export class ExtensionUiController { * Hide the hook selector. */ hideHookSelector(): void { + this.#removeHookSelectorResizeHandler(); this.ctx.hookSelector?.dispose(); this.ctx.hookSelector = undefined; if (this.#isStopped()) return; @@ -1509,6 +1607,7 @@ export class ExtensionUiController { } dispose(): void { + this.#removeHookSelectorResizeHandler(); this.#extensionErrorUnsubscribe?.(); this.#extensionErrorUnsubscribe = undefined; this.#activeHookCustomCancel?.(); diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 0a36d1d1ea..e377f36651 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -1613,7 +1613,7 @@ export class AskTool implements AgentTool { signal, initialSelection, navigation: options?.navigation, - scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined, + scrollTitleRows: DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS, otherOptionLabel, autoSelectOnTimeout: !intentContract(q.deepInterview) && !intentReview(q.deepInterview), clarificationOptionLabel, diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts index f029d37cef..f15daafb1c 100644 --- a/packages/coding-agent/test/hook-editor.test.ts +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -46,7 +46,7 @@ type TestContext = InteractiveModeContext & { }; }; -function createControllerContext() { +function createControllerContext(rows = 30, columns = 120) { const editor = { id: "core-editor" }; const editorContainer = { children: [] as unknown[], @@ -68,7 +68,7 @@ function createControllerContext() { setFocus: vi.fn(), start: vi.fn(), stop: vi.fn(), - terminal: { columns: 120, rows: 30, write: vi.fn() }, + terminal: { columns, rows, write: vi.fn() }, } as unknown as TestContext["ui"] & { setFocus: ReturnType; requestRender: ReturnType; @@ -428,7 +428,312 @@ describe("ExtensionUiController hook editor abort", () => { expect(await promise).toBe("Alpha"); expect(ui.terminal.write).not.toHaveBeenCalledWith("\x1b[?1000l\x1b[?1006l"); }); + it("reserves the expanded option list when bounding a selector title", async () => { + const { ctx } = createControllerContext(20); + const controller = new ExtensionUiController(ctx); + Object.assign(ctx.editor, { getAutocompleteProvider: () => undefined }); + Object.assign(ctx.ui, { getShowHardwareCursor: () => false }); + const options = [...Array.from({ length: 8 }, (_, index) => `Choice ${index + 1}`), "Other"]; + const abortController = new AbortController(); + const promise = controller.showHookSelector( + Array.from({ length: 40 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + options, + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + customInput: { optionLabel: "Other", onSubmit: () => {} }, + signal: abortController.signal, + }, + ); + + for (let index = 0; index < options.length - 1; index++) ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\n"); + const rendered = Bun.stripANSI(ctx.hookSelector!.render(80).join("\n")); + expect(rendered.split("\n").length).toBeLessThanOrEqual(20); + expect(rendered).toContain("Prompt row 1"); + expect(rendered).toContain("Other"); + expect(rendered).toContain("> "); + expect(rendered).toContain("enter submit"); + abortController.abort(); + expect(await promise).toBeUndefined(); + }); + it("bounds multiline inline input and autocomplete within the scroll-title viewport", async () => { + const { ctx } = createControllerContext(20); + Object.assign(ctx.ui, { getShowHardwareCursor: () => false }); + const submitted: string[] = []; + const autocompleteProvider = { + async getSuggestions(lines: string[], cursorLine: number, cursorCol: number) { + const prefix = (lines[cursorLine] ?? "").slice(0, cursorCol); + if (!prefix.endsWith("@")) return null; + return { + prefix: "@", + items: Array.from({ length: 8 }, (_, index) => ({ + value: `@file${index}`, + label: `file${index}`, + })), + }; + }, + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: { value: string }, + prefix: string, + ) { + const line = lines[cursorLine] ?? ""; + const start = cursorCol - prefix.length; + const nextLine = line.slice(0, start) + item.value + line.slice(cursorCol); + return { + lines: lines.map((value, index) => (index === cursorLine ? nextLine : value)), + cursorLine, + cursorCol: start + item.value.length, + }; + }, + }; + Object.assign(ctx.editor, { getAutocompleteProvider: () => autocompleteProvider }); + const controller = new ExtensionUiController(ctx); + const promise = controller.showHookSelector( + Array.from({ length: 40 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + ["Alpha", "Beta", "Other"], + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + customInput: { optionLabel: "Other", onSubmit: text => submitted.push(text) }, + }, + ); + + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\n"); + ctx.hookSelector!.handleInput("\x1b[200~line1\nline2\nline3\n\x1b[201~"); + ctx.hookSelector!.handleInput("@"); + await Bun.sleep(150); + + const rendered = Bun.stripANSI(ctx.hookSelector!.render(120).join("\n")); + expect(rendered.split("\n").length).toBeLessThanOrEqual(20); + expect(rendered).toContain("line3"); + expect(rendered).toContain("file0"); + + ctx.hookSelector!.handleInput("\r"); + ctx.hookSelector!.handleInput("\r"); + expect(await promise).toBe("Other"); + expect(submitted).toEqual(["line1\nline2\nline3\n@file0"]); + }); + + it("accounts for wrapped scroll help in the title viewport budget", async () => { + const { ctx } = createControllerContext(20, 40); + const controller = new ExtensionUiController(ctx); + const abortController = new AbortController(); + const promise = controller.showHookSelector( + Array.from({ length: 40 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + ["Alpha", "Beta", "Gamma"], + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + helpText: "↑/↓ select enter esc PgUp/PgDn/Ctrl+u/d: question · Wheel: transcript", + signal: abortController.signal, + }, + ); + + const rendered = Bun.stripANSI(ctx.hookSelector!.render(40).join("\n")); + expect(rendered.split("\n")).toHaveLength(20); + expect(rendered).toContain("Prompt row 1"); + expect(rendered).toContain("Alpha"); + expect(rendered).toContain("Gamma"); + expect(rendered).toContain("PgUp/PgDn/Ctrl+u/d: question"); + abortController.abort(); + expect(await promise).toBeUndefined(); + }); + it("accounts for wrapped inline-input help in the title viewport budget", async () => { + const { ctx } = createControllerContext(20, 19); + Object.assign(ctx.editor, { getAutocompleteProvider: () => undefined }); + Object.assign(ctx.ui, { getShowHardwareCursor: () => false }); + const controller = new ExtensionUiController(ctx); + const abortController = new AbortController(); + const promise = controller.showHookSelector( + Array.from({ length: 40 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + ["A deliberately long focused option that wraps", "Beta", "Other"], + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + helpText: "↑/↓ select enter esc PgUp/PgDn/Ctrl+u/d: question · Wheel: transcript", + customInput: { optionLabel: "Other", onSubmit: () => {} }, + signal: abortController.signal, + }, + ); + const optionsRendered = Bun.stripANSI(ctx.hookSelector!.render(19).join("\n")); + expect(optionsRendered).toContain("A deliberately"); + + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\n"); + const rendered = Bun.stripANSI(ctx.hookSelector!.render(19).join("\n")); + expect(rendered.split("\n").length).toBeLessThanOrEqual(20); + expect(rendered).toContain("> "); + expect(rendered).toContain("ctrl+g external"); + abortController.abort(); + expect(await promise).toBeUndefined(); + }); + it("keeps compact inline input within the 20-row selector viewport", async () => { + const { ctx } = createControllerContext(20, 19); + Object.assign(ctx.ui, { getShowHardwareCursor: () => false }); + const autocompleteProvider = { + async getSuggestions(lines: string[], cursorLine: number, cursorCol: number) { + const prefix = (lines[cursorLine] ?? "").slice(0, cursorCol); + if (!prefix.endsWith("@")) return null; + return { + prefix: "@", + items: Array.from({ length: 8 }, (_, index) => ({ + value: `@file${index}`, + label: `file${index}`, + })), + }; + }, + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: { value: string }, + prefix: string, + ) { + const line = lines[cursorLine] ?? ""; + const start = cursorCol - prefix.length; + const nextLine = line.slice(0, start) + item.value + line.slice(cursorCol); + return { + lines: lines.map((value, index) => (index === cursorLine ? nextLine : value)), + cursorLine, + cursorCol: start + item.value.length, + }; + }, + }; + Object.assign(ctx.editor, { getAutocompleteProvider: () => autocompleteProvider }); + const controller = new ExtensionUiController(ctx); + const abortController = new AbortController(); + const promise = controller.showHookSelector( + Array.from({ length: 40 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + ["Alpha", "Beta", "Other"], + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + helpText: "↑/↓ select enter esc PgUp/PgDn/Ctrl+u/d: question · Wheel: transcript", + customInput: { optionLabel: "Other", onSubmit: () => {} }, + signal: abortController.signal, + }, + ); + + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\n"); + ctx.hookSelector!.handleInput("@"); + await Bun.sleep(150); + + const rendered = Bun.stripANSI(ctx.hookSelector!.render(19).join("\n")); + expect(rendered.split("\n").length).toBeLessThanOrEqual(20); + expect(rendered).not.toContain("file0"); + + Object.assign(ctx.ui.terminal, { columns: 120 }); + process.stdout.emit("resize"); + ctx.hookSelector!.handleInput("\x7f"); + ctx.hookSelector!.handleInput("@"); + await Bun.sleep(150); + const expanded = Bun.stripANSI(ctx.hookSelector!.render(120).join("\n")); + expect(expanded).toContain("file0"); + expect(expanded.split("\n").length).toBeLessThanOrEqual(20); + + abortController.abort(); + expect(await promise).toBeUndefined(); + }); + it("recomputes the bounded selector budget when the terminal shrinks", async () => { + const { ctx } = createControllerContext(30); + Object.assign(ctx.editor, { getAutocompleteProvider: () => undefined }); + Object.assign(ctx.ui, { getShowHardwareCursor: () => false }); + const controller = new ExtensionUiController(ctx); + const abortController = new AbortController(); + const promise = controller.showHookSelector( + Array.from({ length: 40 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + ["Alpha", "Beta", "Other"], + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + customInput: { optionLabel: "Other", onSubmit: () => {} }, + signal: abortController.signal, + }, + ); + + const beforeResize = Bun.stripANSI(ctx.hookSelector!.render(80).join("\n")); + expect(beforeResize.split("\n").length).toBeGreaterThan(20); + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\x1b[B"); + ctx.hookSelector!.handleInput("\n"); + for (const character of "draft") ctx.hookSelector!.handleInput(character); + + Object.assign(ctx.ui.terminal, { rows: 20 }); + process.stdout.emit("resize"); + + const afterResize = Bun.stripANSI(ctx.hookSelector!.render(80).join("\n")); + expect(afterResize.split("\n").length).toBeLessThanOrEqual(20); + expect(afterResize).toContain("Prompt row 1"); + expect(afterResize).toContain("Other"); + expect(afterResize).toContain("draft"); + expect(afterResize).toContain("enter submit"); + + abortController.abort(); + expect(await promise).toBeUndefined(); + }); + it("keeps the bottom title line anchored when resize leaves a one-row title", async () => { + const { ctx } = createControllerContext(30); + const controller = new ExtensionUiController(ctx); + const abortController = new AbortController(); + const promise = controller.showHookSelector( + Array.from({ length: 20 }, (_, index) => `Prompt row ${index + 1}`).join("\n"), + ["Alpha", "Beta", "Gamma"], + { + outline: true, + wrapFocused: true, + scrollTitleRows: Number.MAX_SAFE_INTEGER, + signal: abortController.signal, + }, + ); + + ctx.hookSelector!.render(80); + for (let index = 0; index < 4; index++) ctx.hookSelector!.handleInput("\x1b[6~"); + const beforeResize = Bun.stripANSI(ctx.hookSelector!.render(80).join("\n")); + expect(beforeResize).toContain("Prompt row 20"); + + Object.assign(ctx.ui.terminal, { rows: 12 }); + process.stdout.emit("resize"); + + const afterResize = Bun.stripANSI(ctx.hookSelector!.render(80).join("\n")); + expect(afterResize.split("\n").length).toBeLessThanOrEqual(12); + expect(afterResize).toContain("Prompt row 20"); + + abortController.abort(); + expect(await promise).toBeUndefined(); + }); + it("removes the bounded selector resize listener when disposed", () => { + const { ctx } = createControllerContext(); + const controller = new ExtensionUiController(ctx); + const initialListeners = process.stdout.listenerCount("resize"); + Object.assign(ctx, { + hookWidgetContainerAbove: { detachAll: vi.fn() }, + hookWidgetContainerBelow: { detachAll: vi.fn() }, + }); + + void controller.showHookSelector("Pick one", ["Alpha", "Beta"], { + scrollTitleRows: Number.MAX_SAFE_INTEGER, + }); + + expect(process.stdout.listenerCount("resize")).toBe(initialListeners + 1); + controller.dispose(); + expect(process.stdout.listenerCount("resize")).toBe(initialListeners); + }); it("restores the composer via the pet-aware restoreComposer when available", async () => { const { ctx, editor, editorContainer, ui } = createControllerContext(); // Simulate InteractiveMode's pet-aware restore: re-mounts the framed editor diff --git a/packages/coding-agent/test/hook-selector-inline-input.test.ts b/packages/coding-agent/test/hook-selector-inline-input.test.ts index 660d414d98..98ce5bb556 100644 --- a/packages/coding-agent/test/hook-selector-inline-input.test.ts +++ b/packages/coding-agent/test/hook-selector-inline-input.test.ts @@ -23,7 +23,12 @@ interface Callbacks { submitted: string[]; } -function createSelector(opts?: { scrollTitleRows?: number; autocompleteProvider?: AutocompleteProvider; tui?: TUI }): { +function createSelector(opts?: { + scrollTitleRows?: number; + autocompleteProvider?: AutocompleteProvider; + tui?: TUI; + timeout?: number; +}): { component: HookSelectorComponent; calls: Callbacks; } { @@ -41,6 +46,7 @@ function createSelector(opts?: { scrollTitleRows?: number; autocompleteProvider? scrollTitleRows: opts?.scrollTitleRows, autocompleteProvider: opts?.autocompleteProvider, tui: opts?.tui, + timeout: opts?.timeout, }, ); return { component, calls }; @@ -256,6 +262,41 @@ describe("HookSelectorComponent inline custom input", () => { expect(after).not.toContain("Deep Interview question body"); expect(after).toContain("esc back to options"); }); + it("preserves title scroll position when entering input after a countdown reset", () => { + const { component } = createSelector({ + scrollTitleRows: 2, + timeout: 60_000, + tui: { requestRender() {}, getShowHardwareCursor: () => false } as TUI, + }); + + renderText(component); + component.handleInput("\x1b[6~"); + moveToOther(component); + component.handleInput("\r"); + + expect(renderText(component)).not.toContain("Deep Interview question body"); + component.dispose(); + }); + it("keeps an open autocomplete dropdown ahead of title paging", async () => { + const { component, calls } = createSelector({ scrollTitleRows: 2, autocompleteProvider: new AtFileProvider() }); + moveToOther(component); + component.handleInput("\r"); + component.handleInput("@"); + await Bun.sleep(0); + + component.handleInput("\x1b[6~"); + component.handleInput("\r"); + + expect(renderText(component)).toContain("@src/app.ts"); + expect(calls.submitted).toEqual([]); + }); + it("retains the external-editor hint with scrollable inline input", () => { + const { component } = createSelector({ scrollTitleRows: 2 }); + moveToOther(component); + component.handleInput("\r"); + + expect(renderText(component)).toContain("ctrl+g external editor"); + }); it("opens @ file autocomplete in the inline input and applies it with enter", async () => { const { component, calls } = createSelector({ autocompleteProvider: new AtFileProvider() }); diff --git a/packages/coding-agent/test/hook-selector-overflow.test.ts b/packages/coding-agent/test/hook-selector-overflow.test.ts index db36863465..c7968caa3c 100644 --- a/packages/coding-agent/test/hook-selector-overflow.test.ts +++ b/packages/coding-agent/test/hook-selector-overflow.test.ts @@ -1,7 +1,7 @@ -import { beforeAll, describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it, vi } from "bun:test"; import { HookSelectorComponent } from "@gajae-code/coding-agent/modes/components/hook-selector"; import { getThemeByName, setThemeInstance, theme } from "@gajae-code/coding-agent/modes/theme/theme"; -import { visibleWidth } from "@gajae-code/tui"; +import { type TUI, visibleWidth } from "@gajae-code/tui"; // ============================================================================= // Helpers shared across required tests. @@ -225,7 +225,30 @@ describe("HookSelectorComponent", () => { // Marker is present because the window omits at least one option. expect(rendered).toContain("(3/6)"); }); + it("reserves a position marker when the option window is clipped", () => { + const options = Array.from({ length: 9 }, (_, index) => `option-${index + 1}`); + const rendered = renderStripped( + 120, + { outline: true, initialIndex: 3, maxVisible: 7, wrapFocused: true }, + options, + ); + + expect(rendered).toContain("(4/9)"); + const visibleOptions = options.filter(option => rendered.includes(option)); + expect(visibleOptions).toHaveLength(6); + }); + + it("reserves a position marker when focused wrapping clips siblings", () => { + const rendered = renderStripped(24, { outline: true, initialIndex: 1, maxVisible: 3, wrapFocused: true }, [ + "above option", + "focused option wraps across rows", + "below option", + ]); + expect(rendered).toContain("(2/3)"); + expect(rendered).not.toContain("above option"); + expect(rendered).not.toContain("below option"); + }); it("Required Test 6 — non-outline parity (wrapFocused:true, outline:false)", () => { const longLabel = "Alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango"; @@ -358,6 +381,45 @@ describe("HookSelectorComponent", () => { expect(rendered).toContain("▼ more"); }); + it("caps a wrapped ordinary-ask premise at a narrow width", () => { + const title = + "Ordinary ask premise: review migration constraints, compatibility risks, rollback plans, and user-visible behavior before selecting an option."; + const component = new HookSelectorComponent( + title, + ["answer-a", "answer-b"], + () => {}, + () => {}, + { + outline: true, + wrapFocused: true, + scrollTitleRows: 3, + maxVisible: 2, + helpText: "↑/↓ select enter esc PgUp/PgDn/Ctrl+u/d: question · Wheel: transcript", + }, + ); + + const renderedRows = component.render(32); + const rendered = Bun.stripANSI(renderedRows.join("\n")); + const titleRows = rendered + .split("\n") + .filter(line => + ["Ordinary", "premise", "constraints", "compatibility", "rollback", "behavior"].some(token => + line.includes(token), + ), + ); + + expect(renderedRows.every(line => visibleWidth(line) <= 32)).toBe(true); + expect(titleRows.length).toBeGreaterThan(0); + expect(titleRows.length).toBeLessThanOrEqual(3); + expect(rendered).toContain("Ordinary ask premise"); + expect(rendered).toContain("answer-a"); + expect(rendered).toContain("answer-b"); + expect(rendered).toContain("▼ more"); + + for (let page = 0; page < 10; page++) component.handleInput("\x1b[6~"); + expect(Bun.stripANSI(component.render(32).join("\n"))).toContain("behavior before selecting"); + }); + it("uses selector-local PageUp/PageDown for title scrolling without moving option focus", () => { const title = Array.from({ length: 8 }, (_, index) => `Question segment ${index + 1}`).join("\n\n"); let selected: string | undefined; @@ -376,7 +438,7 @@ describe("HookSelectorComponent", () => { expect(initial).not.toContain("Question segment 8"); expect(initial).toContain("first-choice"); - for (let i = 0; i < 8; i++) component.handleInput("\x1b[6~"); + for (let i = 0; i < 16; i++) component.handleInput("\x1b[6~"); const afterPageDown = Bun.stripANSI(component.render(56).join("\n")); expect(afterPageDown).not.toContain("Question segment 1"); expect(afterPageDown).toContain("Question segment 8"); @@ -385,10 +447,107 @@ describe("HookSelectorComponent", () => { component.handleInput("\n"); expect(selected).toBe("first-choice"); - for (let i = 0; i < 8; i++) component.handleInput("\x1b[5~"); + for (let i = 0; i < 16; i++) component.handleInput("\x1b[5~"); const afterPageUp = Bun.stripANSI(component.render(56).join("\n")); expect(afterPageUp).toContain("Question segment 1"); }); + it("keeps the visible premise anchored across title reflow", () => { + const title = Array.from( + { length: 8 }, + (_, index) => `Premise segment ${index + 1} has a distinct marker for width reflow.`, + ).join("\n\n"); + const component = new HookSelectorComponent( + title, + ["first-choice", "second-choice"], + () => {}, + () => {}, + { outline: true, wrapFocused: true, scrollTitleRows: 4, maxVisible: 2 }, + ); + + component.render(80); + component.handleInput("\x1b[6~"); + const wide = Bun.stripANSI(component.render(80).join("\n")); + expect(wide).toContain("Premise segment 2"); + expect(wide).toContain("first-choice"); + + const narrow = Bun.stripANSI(component.render(30).join("\n")); + expect(narrow).toContain("Premise segment 2"); + expect(narrow).toContain("first-choice"); + + const wideAgain = Bun.stripANSI(component.render(80).join("\n")); + expect(wideAgain).toContain("Premise segment 2"); + expect(wideAgain).toContain("first-choice"); + }); + it("pages through every premise row despite overflow indicators", () => { + const title = Array.from({ length: 20 }, (_, index) => `Premise row ${index + 1}`).join("\n"); + const component = new HookSelectorComponent( + title, + ["first-choice", "second-choice"], + () => {}, + () => {}, + { outline: true, wrapFocused: true, scrollTitleRows: 10, maxVisible: 2 }, + ); + const seenRows = new Set(); + + for (let page = 0; page < 4; page++) { + const renderedRows = new Set( + Bun.stripANSI(component.render(56).join("\n")) + .split("\n") + .map(line => line.trim()), + ); + for (let row = 1; row <= 20; row++) { + const premiseRow = `Premise row ${row}`; + if (renderedRows.has(premiseRow)) seenRows.add(premiseRow); + } + component.handleInput("\x1b[6~"); + } + + expect([...seenRows]).toEqual(Array.from({ length: 20 }, (_, index) => `Premise row ${index + 1}`)); + }); + it("keeps each premise line intact in a one-row title viewport", () => { + const component = new HookSelectorComponent( + "Premise content 1\nPremise content 2", + ["first-choice", "second-choice"], + () => {}, + () => {}, + { outline: true, wrapFocused: true, scrollTitleRows: 1, maxVisible: 1 }, + ); + + expect(Bun.stripANSI(component.render(24).join("\n"))).toContain("Premise content 1"); + component.handleInput("\x1b[6~"); + expect(Bun.stripANSI(component.render(24).join("\n"))).toContain("Premise content 2"); + }); + it("keeps the bottom premise page stable across a countdown repaint", () => { + vi.useFakeTimers(); + const title = Array.from({ length: 20 }, (_, index) => `Timed premise row ${index + 1}`).join("\n"); + const tui = { requestRender() {} } as unknown as TUI; + const component = new HookSelectorComponent( + title, + ["first-choice", "second-choice"], + () => {}, + () => {}, + { outline: true, wrapFocused: true, scrollTitleRows: 10, maxVisible: 2, timeout: 60_000, tui }, + ); + + try { + component.render(56); + component.handleInput("\x1b[6~"); + component.render(56); + component.handleInput("\x1b[6~"); + + const beforeTick = Bun.stripANSI(component.render(56).join("\n")); + expect(beforeTick).toContain("Timed premise row 20"); + + vi.advanceTimersByTime(1_000); + + const afterTick = Bun.stripANSI(component.render(56).join("\n")); + expect(afterTick).toContain("Timed premise row 20"); + expect(afterTick).not.toContain("▼ more"); + } finally { + component.dispose(); + vi.useRealTimers(); + } + }); it("uses selector-local Ctrl+u/Ctrl+d aliases for title scrolling without moving option focus", () => { const title = Array.from({ length: 8 }, (_, index) => `Ctrl segment ${index + 1}`).join("\n\n"); let selected: string | undefined; @@ -406,7 +565,7 @@ describe("HookSelectorComponent", () => { expect(initial).toContain("Ctrl segment 1"); expect(initial).not.toContain("Ctrl segment 8"); - for (let i = 0; i < 8; i++) component.handleInput("\x04"); + for (let i = 0; i < 16; i++) component.handleInput("\x04"); const afterCtrlD = Bun.stripANSI(component.render(56).join("\n")); expect(afterCtrlD).not.toContain("Ctrl segment 1"); expect(afterCtrlD).toContain("Ctrl segment 8"); @@ -415,7 +574,7 @@ describe("HookSelectorComponent", () => { component.handleInput("\n"); expect(selected).toBe("first-choice"); - for (let i = 0; i < 8; i++) component.handleInput("\x15"); + for (let i = 0; i < 16; i++) component.handleInput("\x15"); const afterCtrlU = Bun.stripANSI(component.render(56).join("\n")); expect(afterCtrlU).toContain("Ctrl segment 1"); }); diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 7d636c72dc..6e08e8f9f0 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -2382,11 +2382,14 @@ describe("AskTool deep-interview rendering middleware", () => { expect(recorder).not.toHaveBeenCalled(); }); - it("leaves non-deep-interview selector prompts without scroll-title opt-in", async () => { + it("opts ordinary selector prompts into local prompt scrolling", async () => { const tool = new AskTool(createSession()); const select = vi.fn( - async (_prompt: string, options: string[], _dialogOptions?: { scrollTitleRows?: number; helpText?: string }) => - options[0], + async ( + _prompt: string, + options: string[], + _dialogOptions?: { scrollTitleRows?: number; helpText?: string; outline?: boolean; wrapFocused?: boolean }, + ) => options[0], ); const context = createContext({ select }); @@ -2407,8 +2410,11 @@ describe("AskTool deep-interview rendering middleware", () => { ); const dialogOptions = select.mock.calls[0]?.[2]; - expect(dialogOptions?.scrollTitleRows).toBeUndefined(); - expect(dialogOptions?.helpText).not.toContain("scroll question"); + expect(dialogOptions?.scrollTitleRows).toBe(Number.MAX_SAFE_INTEGER); + expect(dialogOptions?.helpText).toContain("PgUp/PgDn/Ctrl+u/d: question"); + expect(dialogOptions?.helpText).toContain("Wheel: transcript"); + expect(dialogOptions?.outline).toBe(true); + expect(dialogOptions?.wrapFocused).toBe(true); }); it("recognizes topology questions even when the agent prepends an intro", async () => { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 6de3540469..41c4175c74 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -522,8 +522,13 @@ export class Editor implements Component, Focusable { this.#disposeTabWidthListener = undefined; } - setAutocompleteProvider(provider: AutocompleteProvider): void { + setAutocompleteProvider(provider: AutocompleteProvider | undefined): void { this.#autocompleteProvider = provider; + if (provider === undefined) { + this.#cancelAutocomplete(); + this.onAutocompleteUpdate?.(); + } + this.invalidate(); } getAutocompleteProvider(): AutocompleteProvider | undefined {