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
+1
View File
@@ -13,6 +13,7 @@
### Fixed
- Fixed Claude Code marketplace plugins ignoring the `enabledPlugins` switch in `~/.claude/settings.json` and `.claude/settings(.local).json`: a plugin turned off for a project no longer loads there, and a local-scope install enabled for a project loads even when its recorded `projectPath` is a different directory
- Fixed the `/model` Roles panel silently dropping roles and model-keyed fallback chains that fell past the visible panel height: the list had no scroll window, so entries below the cutoff were unreachable with no indication anything was missing. The panel now windows around the cursor like the provider list and shows an `↑/↓ N more` hint when rows are clipped ([#8817](https://github.com/can1357/oh-my-pi/issues/8817)).
- Fixed task and eval subagents discovering newly added agent definitions while resolving their role aliases from stale startup settings. Subagent preflight now atomically reloads persisted settings before agent discovery while preserving live runtime overrides.
- Fixed images returned by tools mounted under `xd://` rendering only as file links instead of inline terminal graphics.
- Resume Cursor idle-stall turns after completed MCP/todo tool results. The watchdog already closes the Connect stream, so unmarked blocks no longer need the `exec-resolved` marker to continue.
@@ -207,6 +207,10 @@ export class ModelHubComponent implements Component {
#rolesRows: RolesRow[] = [];
#roleIndex = 0;
#roleHover: number | null = null;
/** First roles row drawn in the scroll window; follows the cursor and clamps to the list. */
#roleScrollStart = 0;
/** Roles rows actually drawn this frame; bounds mouse hit-testing to the visible window. */
#rolesVisibleCount = 0;
#assigning: AssignTarget | null = null;
#strip: StripState | null = null;
@@ -1302,6 +1306,15 @@ export class ModelHubComponent implements Component {
}
}
/** Scroll `#roleScrollStart` just enough to keep `#roleIndex` inside a window of `viewHeight` rows, clamped to the list. */
#ensureRoleVisible(viewHeight: number, total: number): number {
if (viewHeight <= 0) return 0;
let start = this.#roleScrollStart;
if (this.#roleIndex < start) start = this.#roleIndex;
else if (this.#roleIndex >= start + viewHeight) start = this.#roleIndex - viewHeight + 1;
return Math.max(0, Math.min(start, Math.max(0, total - viewHeight)));
}
/** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
#stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
const wrap = options.wrap ?? true;
@@ -1473,7 +1486,8 @@ export class ModelHubComponent implements Component {
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
if (overBody && entry.kind === "roles" && this.#assigning === null) {
const roleLine = bodyLine - this.#rolesRowStart;
this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
this.#roleHover =
roleLine >= 0 && roleLine < this.#rolesVisibleCount ? roleLine + this.#roleScrollStart : null;
} else {
this.#roleHover = null;
if (overBody && this.#isBrowserView(entry)) {
@@ -1508,8 +1522,9 @@ export class ModelHubComponent implements Component {
if (overBody) {
if (entry.kind === "roles" && this.#assigning === null) {
this.#focus = "list";
const roleLine = bodyLine - this.#rolesRowStart;
if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
const listLine = bodyLine - this.#rolesRowStart;
if (listLine >= 0 && listLine < this.#rolesVisibleCount) {
const roleLine = listLine + this.#roleScrollStart;
const rowDef = this.#rolesRows[roleLine];
if (rowDef && rowDef.kind !== "separator") {
if (roleLine === this.#roleIndex) {
@@ -1718,7 +1733,16 @@ export class ModelHubComponent implements Component {
const cycleOrder = this.#cycleOrder();
const listFocused = this.#focus === "list";
for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
// Window the list around the cursor so entries past the panel height stay
// reachable; the trailing indicator line steals one row when clipped.
const total = this.#rolesRows.length;
const capacity = Math.max(0, rows - 2 - this.#rolesRowStart);
const overflow = total > capacity;
const viewHeight = overflow ? Math.max(0, capacity - 1) : capacity;
this.#roleScrollStart = this.#ensureRoleVisible(viewHeight, total);
const endIndex = Math.min(this.#roleScrollStart + viewHeight, total);
this.#rolesVisibleCount = Math.max(0, endIndex - this.#roleScrollStart);
for (let i = this.#roleScrollStart; i < endIndex; i++) {
const rowDef = this.#rolesRows[i];
if (!rowDef) continue;
const selected = i === this.#roleIndex;
@@ -1802,6 +1826,15 @@ export class ModelHubComponent implements Component {
lines.push(line);
}
if (overflow) {
const hiddenAbove = this.#roleScrollStart;
const hiddenBelow = total - endIndex;
const parts: string[] = [];
if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} more`);
if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} more`);
lines.push(truncateToWidth(theme.fg("dim", ` ${parts.join(" ")}`), width));
}
// Live preview of the quick-switch cycle, rendered with the exact
// segment track the ctrl+p status uses; the selected role's chip fills.
while (lines.length < rows - 1) lines.push("");
+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 });