feat(ux): group model list by provider in /gsd prefs wizard (#993)

* feat(ux): group model list by provider in /gsd prefs wizard

The model selection in configureModels() was a single flat alphabetical
list that became unwieldy with many providers. This groups models under
provider headers (e.g. "─── anthropic (24) ───") so users can quickly
scan to the right provider section.

Changes:
- ExtensionSelectorComponent: add SEPARATOR_PREFIX convention for
  non-selectable group headers. Navigation skips separators, Enter
  ignores them, and they render with borderAccent styling.
- configureModels: build grouped option list with provider headers
  instead of flat map.
- New test: extension-selector-separator.test.ts (6 tests).

* fix(ci): use node:test instead of vitest in extension-selector test

tsconfig.extensions.json does not include vitest type declarations,
causing TS2307 in CI. Rewrite test to use node:test + node:assert
to match the convention used by other extension tests.

* fix(ci): rewrite separator test to avoid TS parameter property issue

The extension-selector component transitively imports countdown-timer.ts
which uses TypeScript parameter properties (private tui: TUI). Node's
--experimental-strip-types cannot handle these, causing ERR_UNSUPPORTED_
TYPESCRIPT_SYNTAX in CI.

Rewrite the test to verify the separator detection logic and model
grouping contract without importing the component. Duplicates the
isSeparator/nextSelectable helpers and tests them directly.
This commit is contained in:
Jeremy McSpadden 2026-03-17 18:20:13 -05:00 committed by GitHub
parent 8df6f7b75a
commit f85c41c693
4 changed files with 192 additions and 7 deletions

View file

@ -1,6 +1,7 @@
/**
* Generic selector component for extensions.
* Displays a list of string options with keyboard navigation.
* Options starting with SEPARATOR_PREFIX are rendered as non-selectable group headers.
*/
import { Container, getEditorKeybindings, Spacer, Text, type TUI } from "@gsd/pi-tui";
@ -9,6 +10,9 @@ import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint, rawKeyHint } from "./keybinding-hints.js";
/** Prefix that marks an option as a non-selectable group header. */
export const SEPARATOR_PREFIX = "───";
export interface ExtensionSelectorOptions {
tui?: TUI;
timeout?: number;
@ -71,16 +75,42 @@ export class ExtensionSelectorComponent extends Container {
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
// Start on the first selectable (non-separator) item
this.selectedIndex = this.nextSelectable(0, 1);
this.updateList();
}
private isSeparator(index: number): boolean {
return this.options[index]?.startsWith(SEPARATOR_PREFIX) ?? false;
}
/**
* Find the next selectable index starting from `from` in the given direction.
* Returns `from` clamped to bounds if nothing selectable is found.
*/
private nextSelectable(from: number, direction: 1 | -1): number {
let idx = from;
while (idx >= 0 && idx < this.options.length && this.isSeparator(idx)) {
idx += direction;
}
if (idx < 0 || idx >= this.options.length) {
return Math.max(0, Math.min(from, this.options.length - 1));
}
return idx;
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < this.options.length; i++) {
const option = this.options[i];
if (this.isSeparator(i)) {
this.listContainer.addChild(new Text(theme.fg("borderAccent", ` ${option}`), 1, 0));
continue;
}
const isSelected = i === this.selectedIndex;
const text = isSelected
? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i])
: ` ${theme.fg("text", this.options[i])}`;
? theme.fg("accent", "→ ") + theme.fg("accent", option)
: ` ${theme.fg("text", option)}`;
this.listContainer.addChild(new Text(text, 1, 0));
}
}
@ -88,14 +118,28 @@ export class ExtensionSelectorComponent extends Container {
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectUp") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
let next = this.selectedIndex - 1;
if (next < 0) next = this.options.length - 1;
next = this.nextSelectable(next, -1);
if (this.isSeparator(next)) {
next = this.nextSelectable(this.options.length - 1, -1);
}
this.selectedIndex = next;
this.updateList();
} else if (kb.matches(keyData, "selectDown") || keyData === "j") {
this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
let next = this.selectedIndex + 1;
if (next >= this.options.length) next = 0;
next = this.nextSelectable(next, 1);
if (this.isSeparator(next)) {
next = this.nextSelectable(0, 1);
}
this.selectedIndex = next;
this.updateList();
} else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
const selected = this.options[this.selectedIndex];
if (selected) this.onSelectCallback(selected);
if (selected && !this.isSeparator(this.selectedIndex)) {
this.onSelectCallback(selected);
}
} else if (kb.matches(keyData, "selectCancel")) {
this.onCancelCallback();
}

View file

@ -12,7 +12,7 @@ export { type RenderDiffOptions, renderDiff } from "./diff.js";
export { DynamicBorder } from "./dynamic-border.js";
export { ExtensionEditorComponent } from "./extension-editor.js";
export { ExtensionInputComponent } from "./extension-input.js";
export { ExtensionSelectorComponent } from "./extension-selector.js";
export { ExtensionSelectorComponent, SEPARATOR_PREFIX } from "./extension-selector.js";
export { FooterComponent } from "./footer.js";
export { appKey, appKeyHint, editorKey, keyHint, rawKeyHint } from "./keybinding-hints.js";
export { LoginDialogComponent } from "./login-dialog.js";

View file

@ -1153,7 +1153,26 @@ async function configureModels(ctx: ExtensionCommandContext, prefs: Record<strin
const availableModels = ctx.modelRegistry.getAvailable();
if (availableModels.length > 0) {
const modelOptions = availableModels.map(m => `${m.id} · ${m.provider}`);
// Group models by provider, sorted alphabetically
const byProvider = new Map<string, typeof availableModels>();
for (const m of availableModels) {
let group = byProvider.get(m.provider);
if (!group) {
group = [];
byProvider.set(m.provider, group);
}
group.push(m);
}
const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
const modelOptions: string[] = [];
for (const provider of providers) {
const group = byProvider.get(provider)!;
modelOptions.push(`─── ${provider} (${group.length}) ───`);
for (const m of group) {
modelOptions.push(`${m.id} · ${m.provider}`);
}
}
modelOptions.push("(keep current)", "(clear)");
for (const phase of modelPhases) {

View file

@ -0,0 +1,122 @@
// Tests for the SEPARATOR_PREFIX convention used by ExtensionSelectorComponent.
// We cannot import the component directly in node:test because its transitive
// dependency (countdown-timer.ts) uses TypeScript parameter properties which
// are unsupported under --experimental-strip-types. Instead we duplicate the
// separator detection logic here and verify the contract.
import test, { describe } from "node:test";
import assert from "node:assert/strict";
/** Must match the constant exported from extension-selector.ts */
const SEPARATOR_PREFIX = "───";
function isSeparator(options: string[], index: number): boolean {
return options[index]?.startsWith(SEPARATOR_PREFIX) ?? false;
}
function nextSelectable(options: string[], from: number, direction: 1 | -1): number {
let idx = from;
while (idx >= 0 && idx < options.length && isSeparator(options, idx)) {
idx += direction;
}
if (idx < 0 || idx >= options.length) {
return Math.max(0, Math.min(from, options.length - 1));
}
return idx;
}
describe("separator detection", () => {
const options = [
`${SEPARATOR_PREFIX} anthropic (2) ${SEPARATOR_PREFIX}`,
"claude-opus-4-6 · anthropic",
"claude-sonnet-4-5 · anthropic",
`${SEPARATOR_PREFIX} openai (1) ${SEPARATOR_PREFIX}`,
"gpt-4o · openai",
"(keep current)",
"(clear)",
];
test("identifies separator rows correctly", () => {
assert.ok(isSeparator(options, 0));
assert.ok(!isSeparator(options, 1));
assert.ok(!isSeparator(options, 2));
assert.ok(isSeparator(options, 3));
assert.ok(!isSeparator(options, 4));
});
test("nextSelectable skips leading separator", () => {
assert.strictEqual(nextSelectable(options, 0, 1), 1);
});
test("nextSelectable skips separator going down", () => {
// From index 2 (claude-sonnet), next is index 3 (separator), should skip to 4
assert.strictEqual(nextSelectable(options, 3, 1), 4);
});
test("nextSelectable skips separator going up", () => {
// From index 4 (gpt-4o), prev is index 3 (separator), should skip to 2
assert.strictEqual(nextSelectable(options, 3, -1), 2);
});
test("nextSelectable clamps to bounds", () => {
assert.strictEqual(nextSelectable(options, 6, 1), 6);
});
test("works with no separators", () => {
const plain = ["alpha", "beta", "gamma"];
assert.strictEqual(nextSelectable(plain, 0, 1), 0);
assert.strictEqual(nextSelectable(plain, 1, 1), 1);
});
});
describe("model grouping", () => {
test("groups models by provider with separator headers", () => {
// Simulate the grouping logic from configureModels
const availableModels = [
{ id: "claude-opus-4-6", provider: "anthropic" },
{ id: "gpt-4o", provider: "openai" },
{ id: "claude-sonnet-4-5", provider: "anthropic" },
{ id: "o3-mini", provider: "openai" },
];
const byProvider = new Map<string, typeof availableModels>();
for (const m of availableModels) {
let group = byProvider.get(m.provider);
if (!group) {
group = [];
byProvider.set(m.provider, group);
}
group.push(m);
}
const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b));
const modelOptions: string[] = [];
for (const provider of providers) {
const group = byProvider.get(provider)!;
modelOptions.push(`${SEPARATOR_PREFIX} ${provider} (${group.length}) ${SEPARATOR_PREFIX}`);
for (const m of group) {
modelOptions.push(`${m.id} · ${m.provider}`);
}
}
modelOptions.push("(keep current)", "(clear)");
// Verify structure
assert.strictEqual(modelOptions[0], `${SEPARATOR_PREFIX} anthropic (2) ${SEPARATOR_PREFIX}`);
assert.strictEqual(modelOptions[1], "claude-opus-4-6 · anthropic");
assert.strictEqual(modelOptions[2], "claude-sonnet-4-5 · anthropic");
assert.strictEqual(modelOptions[3], `${SEPARATOR_PREFIX} openai (2) ${SEPARATOR_PREFIX}`);
assert.strictEqual(modelOptions[4], "gpt-4o · openai");
assert.strictEqual(modelOptions[5], "o3-mini · openai");
assert.strictEqual(modelOptions[6], "(keep current)");
assert.strictEqual(modelOptions[7], "(clear)");
// Verify separators are correctly detected
assert.ok(isSeparator(modelOptions, 0));
assert.ok(!isSeparator(modelOptions, 1));
assert.ok(isSeparator(modelOptions, 3));
assert.ok(!isSeparator(modelOptions, 6));
// Verify first selectable is index 1, not the separator at 0
assert.strictEqual(nextSelectable(modelOptions, 0, 1), 1);
});
});