fix(tui): capped ask other editor context

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
This commit is contained in:
roboomp
2026-06-27 14:08:16 +00:00
parent 1c6d3ca5a8
commit 4276b854c1
2 changed files with 123 additions and 3 deletions
+61 -3
View File
@@ -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<number>,
): { indices: number[]; gapBefore: Map<number, number> } {
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<number>();
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<number, number>();
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");
}
@@ -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();