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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user