fix(tui): scroll the /model Roles view so clipped rows stay reachable

The Roles panel renderer looped from the first row with a hard height cap and no scroll offset, so roles and model-keyed fallback chains past the visible height were never drawn and unreachable by keyboard, with no truncation indicator. Unlike the sibling provider list (model-browser windows via #windowStart/#ensureSelectedVisible), the Roles panel had no equivalent.

Window #renderRolesView around #roleIndex via #ensureRoleVisible, offset mouse hit-testing by the scroll start bounded to the visible count, and draw an up/down '+N more' hint when the list is clipped.

Fixes #8817
This commit is contained in:
roboomp
2026-08-19 09:52:41 +00:00
parent d94bdfa1bb
commit aed63e7bd5
3 changed files with 69 additions and 5 deletions
+31 -1
View File
@@ -92,12 +92,13 @@ function createHub(options: {
registry?: RegistryOverrides;
hub?: ModelHubOptions;
callbacks?: Partial<ModelHubCallbacks>;
terminalRows?: number;
}): HubHarness {
installTestTheme();
const modelsFn = typeof options.models === "function" ? options.models : () => options.models as Model[];
const settings = options.settings ?? Settings.isolated({});
const registry = makeRegistry(modelsFn, options.registry);
const ui = { requestRender: vi.fn(), terminal: { rows: 40 } } as unknown as TUI;
const ui = { requestRender: vi.fn(), terminal: { rows: options.terminalRows ?? 40 } } as unknown as TUI;
const onAssign = vi.fn();
const onUnassign = vi.fn();
const onLoginRequest = vi.fn();
@@ -709,6 +710,35 @@ describe("ModelHub", () => {
expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]);
});
test("windows the roles list so model-keyed chains past the panel height stay reachable", () => {
const settings = Settings.isolated({
// Model-keyed chains sort alphabetically; the unique tail key lands last.
"retry.fallbackChains": {
"aa-provider/head-chain": ["x/y"],
"mm-provider/mid-chain": ["x/y"],
"zz-provider/tail-chain-marker": ["x/y"],
},
});
// A short terminal makes the built-in roles alone fill the panel, so the
// model-keyed chains that follow the separator land below the fold. The
// chain keys are not available models, so no role auto-assignment leaks
// their names into the visible role rows.
const { hub } = createHub({ models: [makeModel("test", "solo")], settings, terminalRows: 16 });
enterRolesView(hub);
const top = normalize(hub.render(120));
// The alphabetically last model-keyed chain is clipped, but the panel
// now advertises the hidden rows instead of dropping them silently.
expect(top).not.toContain("tail-chain-marker");
expect(top).toContain("more");
// Wrapping up from the top row lands on the trailing "+ New fallback…"
// row; the window scrolls to the bottom and reveals the clipped chain.
hub.handleInput(UP);
const bottom = normalize(hub.render(120));
expect(bottom).toContain("tail-chain-marker");
});
test("clicking a roles row hits the row under the pointer", () => {
const a = makeModel("test", "model-a");
const { hub } = createHub({ models: [a], scoped: true });