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) <noreply@openai-codex>
This commit is contained in:
Christian Tietze 2026-11-04 10:15:27 +01:00 • committed by GitHub
parent f6278a472e
commit 3b57ed7e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 97 additions and 1 deletions

View file

@ -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 {

View file

@ -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("→");
});
});