From 3b57ed7e278b63580eacd175786fbeb5b2fd70d6 Mon Sep 17 00:00:00 2001 From: Christian Tietze Date: Wed, 4 Nov 2026 10:15:27 +0100 Subject: [PATCH] fix: limit selector window height (#11072) With this PR, we stop rendering every option. Instead, we render a centered, clamped window of up to 10 options and show `(selection/total)` when the list overflows. This keeps the selected option visible in long extension-provided lists without changing navigation, keybindings, callbacks, countdowns, or short-list rendering. Fixes #6688. ## Merge Danger **Door:** Two-way The change is localized to `ExtensionSelectorComponent.updateList()` and can be reverted without migration or persisted-state concerns. **Blast Radius:** Low Only extension selectors with more than 10 options change appearance; they now render a window and position indicator instead of every option. --------- Co-authored-by: Pi via openai-codex (gpt-5.6-sol) --- .../components/extension-selector.ts | 16 +++- .../test/extension-selector.test.ts | 82 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/extension-selector.test.ts diff --git a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts index ed49c04a8..e6e40f923 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -84,13 +84,27 @@ export class ExtensionSelectorComponent extends Container { private updateList(): void { this.listContainer.clear(); - for (let i = 0; i < this.options.length; i++) { + + const maxVisible = 10; + // Center the window on the selection, then clamp it to the list boundaries. + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.options.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, this.options.length); + + for (let i = startIndex; i < endIndex; i++) { const isSelected = i === this.selectedIndex; const text = isSelected ? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i]) : ` ${theme.fg("text", this.options[i])}`; this.listContainer.addChild(new Text(text, 1, 0)); } + + if (startIndex > 0 || endIndex < this.options.length) { + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.options.length})`); + this.listContainer.addChild(new Text(scrollInfo, 0, 0)); + } } handleInput(keyData: string): void { diff --git a/packages/coding-agent/test/extension-selector.test.ts b/packages/coding-agent/test/extension-selector.test.ts new file mode 100644 index 000000000..df5740f64 --- /dev/null +++ b/packages/coding-agent/test/extension-selector.test.ts @@ -0,0 +1,82 @@ +import { setKeybindings } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { ExtensionSelectorComponent } from "../src/modes/interactive/components/extension-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +function render(selector: ExtensionSelectorComponent): string { + return stripAnsi(selector.render(80).join("\n")); +} + +function visibleOptions(output: string): string[] { + return output + .split("\n") + .filter((line) => line.includes("option-")) + .map((line) => line.trim().replace(/^→ /, "")); +} + +describe("extension selector", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + // #6688: long extension selectors must keep the selected option visible. + it("windows long option lists around the selection", () => { + const options = Array.from({ length: 15 }, (_, index) => `option-${index + 1}`); + const selector = new ExtensionSelectorComponent( + "Choose", + options, + () => {}, + () => {}, + ); + + let output = render(selector); + expect(visibleOptions(output)).toEqual(options.slice(0, 10)); + expect(output).toContain("→ option-1"); + expect(output).toContain(" (1/15)"); + + for (let i = 0; i < 7; i++) selector.handleInput("\x1b[B"); + output = render(selector); + expect(visibleOptions(output)).toEqual(options.slice(2, 12)); + expect(output).toContain("→ option-8"); + expect(output).toContain(" (8/15)"); + + for (let i = 0; i < 7; i++) selector.handleInput("\x1b[B"); + output = render(selector); + expect(visibleOptions(output)).toEqual(options.slice(5, 15)); + expect(output).toContain("→ option-15"); + expect(output).toContain(" (15/15)"); + }); + + it("renders short and empty option lists without a scroll indicator", () => { + const shortOptions = ["option-1", "option-2", "option-3"]; + const shortOutput = render( + new ExtensionSelectorComponent( + "Choose", + shortOptions, + () => {}, + () => {}, + ), + ); + expect(visibleOptions(shortOutput)).toEqual(shortOptions); + expect(shortOutput).toContain("→ option-1"); + expect(shortOutput).not.toContain("(1/3)"); + + const emptyOutput = render( + new ExtensionSelectorComponent( + "Choose", + [], + () => {}, + () => {}, + ), + ); + expect(emptyOutput).toContain("Choose"); + expect(emptyOutput).toContain("↑↓ navigate"); + expect(emptyOutput).not.toContain("→"); + }); +});