From 4276b854c19a3d4e16635b97e0ec0c80687a4b20 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 27 Jun 2026 14:08:16 +0000 Subject: [PATCH] fix(tui): capped ask other editor context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounded the editor title rendered while typing a custom 'Other' answer: window options around the selected/first/checked rows, flatten and truncate descriptions to a single line, and emit '… N more options …' markers for dropped runs. Refs #3660 --- packages/coding-agent/src/tools/ask.ts | 64 +++++++++++++++++++- packages/coding-agent/test/tools/ask.test.ts | 62 +++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 1fbb94b0f..28a77b03b 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -142,19 +142,75 @@ interface CustomInputContext { markableCount: number; } +/** Hard caps for the editor title rendered while the user types an `Other` + * custom answer. Without these the title is one `Text` child stacked above the + * prompt editor (no `maxVisible` windowing), so a question with many options + * or paragraph-long descriptions hides the input row and hint. */ +const MAX_CUSTOM_INPUT_OPTION_ROWS = 8; +const MAX_CUSTOM_INPUT_DESCRIPTION_CHARS = 120; + function getSelectOptionDescription(option: ExtensionUISelectItem): string | undefined { return typeof option === "string" ? undefined : option.description; } +function truncateCustomInputDescription(text: string): string { + const flattened = text.replace(/\s+/g, " ").trim(); + if (flattened.length <= MAX_CUSTOM_INPUT_DESCRIPTION_CHARS) return flattened; + return `${flattened.slice(0, MAX_CUSTOM_INPUT_DESCRIPTION_CHARS - 1).trimEnd()}…`; +} + +/** Window the option list around must-keep rows so the title stays bounded. + * Must-keep = the selected `Other` row, the first option (anchor), and every + * user-checked row. Remaining budget fills from the start. Gaps between kept + * indices are reported so the renderer can drop a `… N more options …` + * marker, preserving the "more available" signal. */ +function pickCustomInputOptionWindow( + total: number, + selectedIndex: number, + checked: ReadonlySet, +): { indices: number[]; gapBefore: Map } { + if (total === 0) return { indices: [], gapBefore: new Map() }; + if (total <= MAX_CUSTOM_INPUT_OPTION_ROWS) { + return { + indices: Array.from({ length: total }, (_, i) => i), + gapBefore: new Map(), + }; + } + const keep = new Set(); + if (selectedIndex >= 0 && selectedIndex < total) keep.add(selectedIndex); + keep.add(0); + for (const i of checked) { + if (i >= 0 && i < total) keep.add(i); + } + for (let i = 0; i < total && keep.size < MAX_CUSTOM_INPUT_OPTION_ROWS; i++) { + keep.add(i); + } + const indices = [...keep].sort((a, b) => a - b); + const gapBefore = new Map(); + let prev = -1; + for (const idx of indices) { + if (idx > prev + 1) gapBefore.set(idx, idx - prev - 1); + prev = idx; + } + if (prev < total - 1) gapBefore.set(total, total - 1 - prev); + return { indices, gapBefore }; +} + function formatCustomInputTitle( question: string, options: ExtensionUISelectItem[], context: CustomInputContext, ): string { const selectedIndex = options.findIndex(option => getSelectOptionLabel(option) === OTHER_OPTION); - const lines = [question, ""]; const checked = new Set(context.checkedIndices ?? []); - for (let index = 0; index < options.length; index++) { + const window = pickCustomInputOptionWindow(options.length, selectedIndex, checked); + const lines: string[] = [question, ""]; + const emitGap = (count: number) => { + lines.push(` … ${count} more option${count === 1 ? "" : "s"} …`); + }; + for (const index of window.indices) { + const gap = window.gapBefore.get(index); + if (gap !== undefined) emitGap(gap); const option = options[index]!; const label = getSelectOptionLabel(option); const isSelected = index === selectedIndex; @@ -169,8 +225,10 @@ function formatCustomInputTitle( : " "; lines.push(prefix + label); const description = getSelectOptionDescription(option); - if (description) lines.push(` ${description}`); + if (description) lines.push(` ${truncateCustomInputDescription(description)}`); } + const trailingGap = window.gapBefore.get(options.length); + if (trailingGap !== undefined) emitGap(trailingGap); lines.push("", "Enter your response:"); return lines.join("\n"); } diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index df0710b4a..8f689832d 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -589,6 +589,68 @@ describe("AskTool custom input", () => { expect(title).toContain("Enter your response:"); }); + it("caps Other editor context for long option lists with long descriptions", async () => { + const tool = new AskTool(createSession()); + const editor = vi.fn(async (_title: string) => "custom"); + const longDescription = "x".repeat(400); + const optionCount = 20; + const options = Array.from({ length: optionCount }, (_, i) => ({ + label: `option-${i}`, + description: longDescription, + })); + const questions = [{ id: "pick", question: "Pick one", options }]; + const context = createContext({ + select: async () => "Other (type your own)", + editor, + }); + + await tool.execute("call-editor-cap", { questions }, undefined, undefined, context); + + const title = editor.mock.calls[0]?.[0] ?? ""; + const lineCount = title.split("\n").length; + // Cap is 8 option rows + their (single-line) descriptions + chrome; far below + // 20 options × (label + multi-line description) the unbounded path would emit. + expect(lineCount).toBeLessThanOrEqual(22); + expect(title).toContain("Pick one"); + expect(title).toContain("option-0"); + expect(title).toContain("Other (type your own)"); + expect(title).toContain("more option"); + expect(title).toContain("Enter your response:"); + // Descriptions are flattened to a single line and truncated. + expect(title).not.toContain("x".repeat(400)); + // Every option-row description must fit on one line. + for (const line of title.split("\n")) { + expect(line.length).toBeLessThanOrEqual(160); + } + }); + + it("keeps user-checked options visible in capped multi-select context", async () => { + const tool = new AskTool(createSession()); + const editor = vi.fn(async (_title: string) => "custom"); + const options = Array.from({ length: 20 }, (_, i) => ({ label: `opt-${i}` })); + const questions = [{ id: "pick", question: "Multi pick", options, multi: true }]; + let call = 0; + const context = createContext({ + select: async (_prompt, opts) => { + call += 1; + if (call === 1) return selectItemLabel(opts.find(o => selectItemLabel(o) === "opt-12")); + if (call === 2) return selectItemLabel(opts.find(o => selectItemLabel(o) === "opt-17")); + return "Other (type your own)"; + }, + editor, + }); + + await tool.execute("call-editor-cap-multi", { questions }, undefined, undefined, context); + + const title = editor.mock.calls[0]?.[0] ?? ""; + // Checked options must survive the window so the user sees what they had + // already toggled before switching to Other. + expect(title).toContain("opt-12"); + expect(title).toContain("opt-17"); + expect(title).toContain("Other (type your own)"); + expect(title).toContain("more option"); + }); + it("returns to the option selector when custom input is dismissed in single-question flow", async () => { const tool = new AskTool(createSession()); const abort = vi.fn();