diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fdd5ae758..f919c4ca3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 2d97f317f..30c97b92e 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -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(""); diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index cf2f34ef2..7816f28e7 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -92,12 +92,13 @@ function createHub(options: { registry?: RegistryOverrides; hub?: ModelHubOptions; callbacks?: Partial; + 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 });