From a0dcb8ae20ec5258d6b32d24209d2b8ab41d90a9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 12 Jul 2026 01:06:22 +0200 Subject: [PATCH] feat(ui): enabled performance monitoring and viewport navigation - Integrated performance statistics (TPS/TTFT) into ModelBrowser and ModelHub components with adaptive visibility. - Decoupled mouse wheel behavior from selection logic to enable smoother viewport panning. - Improved UI navigation by preventing unwanted wrapping in role indexes and adding viewport snapping logic. - Added comprehensive test suites to verify performance display rendering and corrected input interaction behavior. --- .../src/modes/components/advisor-config.ts | 4 +- .../src/modes/components/model-browser.ts | 130 ++++++++++++++---- .../src/modes/components/model-hub.ts | 58 +++++--- .../coding-agent/test/model-browser.test.ts | 44 +++++- packages/coding-agent/test/model-hub.test.ts | 79 +++++++++++ 5 files changed, 267 insertions(+), 48 deletions(-) diff --git a/packages/coding-agent/src/modes/components/advisor-config.ts b/packages/coding-agent/src/modes/components/advisor-config.ts index 7f7358654..6543a7aae 100644 --- a/packages/coding-agent/src/modes/components/advisor-config.ts +++ b/packages/coding-agent/src/modes/components/advisor-config.ts @@ -461,7 +461,8 @@ export class AdvisorConfigOverlayComponent implements Component { } #showModelPicker(index: number): void { - const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; + const storage = this.#settings.getStorage(); + const mruOrder = storage?.getModelUsageOrder() ?? []; let models: ReadonlyArray; if (this.#scopedModels.length > 0) { models = this.#scopedModels.map(scoped => scoped.model); @@ -477,6 +478,7 @@ export class AdvisorConfigOverlayComponent implements Component { const picker = new ModelBrowser(this.#settings, {}); picker.setMruOrder(mruOrder); + picker.setPerfStats(storage?.getModelPerf() ?? new Map()); picker.setItems(items); picker.onActivate = item => { const efforts = getSupportedEfforts(item.model); diff --git a/packages/coding-agent/src/modes/components/model-browser.ts b/packages/coding-agent/src/modes/components/model-browser.ts index 8eec17093..4f37b196f 100644 --- a/packages/coding-agent/src/modes/components/model-browser.ts +++ b/packages/coding-agent/src/modes/components/model-browser.ts @@ -24,6 +24,7 @@ import { import { formatNumber } from "@oh-my-pi/pi-utils"; import { getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles"; import type { Settings } from "../../config/settings"; +import type { ModelPerfStats } from "../../session/agent-storage"; import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { theme } from "../theme/theme"; import { @@ -227,6 +228,18 @@ function formatContext(model: Model): string { return `${formatNumber(ctx).toLowerCase()} ${theme.icon.context.replace(/:$/, "")}`; } +/** `118t/s` average output speed; one decimal below 10 t/s. */ +function formatTps(tps: number): string { + const value = tps >= 10 ? String(Math.round(tps)) : tps.toFixed(1); + return `${value}t/s`; +} + +/** `0.9s` average time-to-first-token; whole seconds from 10s up. */ +function formatTtft(ms: number): string { + const seconds = ms / 1000; + return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`; +} + /** Pad `text` on the left to `width` terminal columns (ANSI/emoji aware). */ function padLeftVisible(text: string, width: number): string { const missing = width - visibleWidth(text); @@ -250,6 +263,12 @@ export interface ModelBrowserOptions { const LIST_ROW_START = 2; /** Rendered rows after the list window: blank + two detail rows. */ const DETAIL_ROWS = 3; +/** Row width from which the measured-perf column appears (TPS only). */ +const PERF_TPS_MIN_WIDTH = 76; +/** Row width from which the perf column also includes TTFT. */ +const PERF_FULL_MIN_WIDTH = 96; +/** What the per-row perf column shows at the current width. */ +type PerfMode = "off" | "tps" | "full"; /** * The reusable browser component. Renders a fixed-height block @@ -263,6 +282,7 @@ export class ModelBrowser implements Component { #visibleItems: ModelBrowserItem[] = []; #roles: RoleAssignments = {}; #mruOrder: ReadonlyArray = []; + #perf: ReadonlyMap = new Map(); #selectedIndex = 0; #hoveredIndex: number | null = null; #maxVisible = 10; @@ -270,6 +290,7 @@ export class ModelBrowser implements Component { #currentContextTokens: number; #disableOverContext: boolean; #emptyText?: () => string | undefined; + /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */ #windowStart = 0; #windowCount = 0; @@ -310,7 +331,14 @@ export class ModelBrowser implements Component { this.#mruOrder = order; } + /** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */ + setPerfStats(perf: ReadonlyMap): void { + this.#perf = perf; + } + setMaxVisible(rows: number): void { + // No selection snap here: hosts call this on every render, and it must + // not undo wheel panning. render() re-clamps the window. this.#maxVisible = Math.max(1, rows); } @@ -345,6 +373,7 @@ export class ModelBrowser implements Component { const index = this.#visibleItems.findIndex(item => item.selector === selector); if (index < 0) return false; this.#selectedIndex = this.#coerceSelectedIndex(index); + this.#ensureSelectedVisible(); return true; } @@ -372,6 +401,21 @@ export class ModelBrowser implements Component { return clamped; } + /** Clamp a window start into `[0, total - maxVisible]`. */ + #clampWindowStart(start: number): number { + return Math.max(0, Math.min(start, this.#visibleItems.length - this.#maxVisible)); + } + + /** Scroll just enough to keep the selected row inside the window. */ + #ensureSelectedVisible(): void { + if (this.#selectedIndex < this.#windowStart) { + this.#windowStart = this.#selectedIndex; + } else if (this.#selectedIndex >= this.#windowStart + this.#maxVisible) { + this.#windowStart = this.#selectedIndex - this.#maxVisible + 1; + } + this.#windowStart = this.#clampWindowStart(this.#windowStart); + } + moveSelection(delta: number): void { const count = this.#visibleItems.length; if (count === 0) return; @@ -389,6 +433,7 @@ export class ModelBrowser implements Component { #setSelectedIndex(index: number): void { if (index === this.#selectedIndex) return; this.#selectedIndex = index; + this.#ensureSelectedVisible(); this.onSelectionChange?.(this.getSelected()); } @@ -451,6 +496,7 @@ export class ModelBrowser implements Component { } this.#visibleItems = this.#insertSeparator(items); this.#selectedIndex = this.#coerceSelectedIndex(Math.min(this.#selectedIndex, this.#visibleItems.length - 1)); + this.#ensureSelectedVisible(); this.onSelectionChange?.(this.getSelected()); } @@ -508,36 +554,37 @@ export class ModelBrowser implements Component { */ routeMouse(event: SgrMouseEvent, line: number): void { if (event.wheel !== null) { - this.moveSelection(event.wheel); - return; - } - const listLine = line - LIST_ROW_START; - if (listLine < 0 || listLine >= this.#windowCount) { - if (event.motion && this.#hoveredIndex !== null) { - this.#hoveredIndex = null; - } - return; - } - const index = this.#windowStart + listLine; - const item = this.#visibleItems[index]; - if (!item || this.#isDisabled(item)) { - this.#hoveredIndex = null; + // Wheel pans the window; it never moves the selection and never wraps. + this.#windowStart = this.#clampWindowStart(this.#windowStart + event.wheel); + this.#hoveredIndex = this.#hoverIndexAt(line); return; } if (event.motion) { - this.#hoveredIndex = index; + this.#hoveredIndex = this.#hoverIndexAt(line); return; } - if (event.leftClick) { - // Settings idiom: click selects, click-again activates. - if (index === this.#selectedIndex) { - this.onActivate?.(item); - } else { - this.#setSelectedIndex(index); - } + if (!event.leftClick) return; + const index = this.#hoverIndexAt(line); + const item = index !== null ? this.#visibleItems[index] : undefined; + if (index === null || !item) return; + // Settings idiom: click selects, click-again activates. + if (index === this.#selectedIndex) { + this.onActivate?.(item); + } else { + this.#setSelectedIndex(index); } } + /** List index under a frame-local row, or null when off-list or on a disabled row. */ + #hoverIndexAt(line: number): number | null { + const listLine = line - LIST_ROW_START; + if (listLine < 0 || listLine >= this.#windowCount) return null; + const index = this.#windowStart + listLine; + const item = this.#visibleItems[index]; + if (!item || this.#isDisabled(item)) return null; + return index; + } + #chipsFor(model: Model): string { const parts: string[] = []; const seen = new Set(); @@ -554,6 +601,16 @@ export class ModelBrowser implements Component { return parts.length > 0 ? ` ${parts.join(" ")}` : ""; } + /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */ + #perfCell(item: ModelBrowserItem, mode: PerfMode): string { + if (mode === "off") return ""; + const perf = this.#perf.get(item.selector); + if (!perf) return ""; + const tps = formatTps(perf.tps); + if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`; + return tps; + } + #renderRow( item: ModelBrowserItem, width: number, @@ -561,6 +618,8 @@ export class ModelBrowser implements Component { hovered: boolean, ctxWidth: number, costWidth: number, + perfWidth: number, + perfMode: PerfMode, ): string { if (item.id === "separator") { const dashCount = Math.max(0, width - 4); @@ -576,8 +635,11 @@ export class ModelBrowser implements Component { : ""; let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`; - const meta = `${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`; - const metaWidth = ctxWidth + costWidth + 2; + // Perf column collapses entirely when no visible row has measurements. + const perfCol = + perfWidth > 0 ? `${theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth))} ` : ""; + const meta = `${perfCol}${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`; + const metaWidth = ctxWidth + costWidth + 2 + (perfWidth > 0 ? perfWidth + 2 : 0); const available = Math.max(1, width - metaWidth - 1); left = truncateToWidth(left, available); const gap = Math.max(0, available - visibleWidth(left)); @@ -603,6 +665,11 @@ export class ModelBrowser implements Component { facts.push(`${formatCostPair(model)} per M`); if (model.reasoning) facts.push("reasoning"); if (model.input.includes("image")) facts.push("vision"); + const perf = this.#perf.get(selected.selector); + if (perf) { + facts.push(`~${formatTps(perf.tps)}`); + if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`); + } const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width); if (this.#isDisabled(selected)) { @@ -635,12 +702,12 @@ export class ModelBrowser implements Component { lines.push(""); const total = this.#visibleItems.length; - const startIndex = Math.max( - 0, - Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible), - ); + // The window is persistent state: wheel scrolling panned it, keyboard + // navigation snapped it to the selection. Re-clamp here because items + // or maxVisible may have changed since. + this.#windowStart = this.#clampWindowStart(this.#windowStart); + const startIndex = this.#windowStart; const endIndex = Math.min(startIndex + this.#maxVisible, total); - this.#windowStart = startIndex; this.#windowCount = Math.max(0, endIndex - startIndex); if (total === 0) { @@ -653,11 +720,14 @@ export class ModelBrowser implements Component { // scanning the entire catalog on every render. let ctxWidth = 0; let costWidth = 0; + const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off"; + let perfWidth = 0; for (let i = startIndex; i < endIndex; i++) { const item = this.#visibleItems[i]; if (!item) continue; ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model))); costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model))); + perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode))); } const rows: string[] = []; @@ -672,6 +742,8 @@ export class ModelBrowser implements Component { i === this.#hoveredIndex, ctxWidth, costWidth, + perfWidth, + perfMode, ), ); } diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 82fa5cb6b..7771baf5f 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -202,6 +202,8 @@ export class ModelHubComponent implements Component { #searchTotal = 0; #activeEntryId = "all"; #sidebarScroll = 0; + /** Snap the sidebar viewport to the active entry on the next render; wheel panning leaves it free. */ + #sidebarFollowActive = true; #sidebarHover: number | null = null; /** * Arrow-key ownership: `scope` (default) hops the sidebar even while the @@ -393,11 +395,13 @@ export class ModelHubComponent implements Component { this.#reloadRoles(availableModels); this.#buildRolesRows(); - const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; + const storage = this.#settings.getStorage(); + const mruOrder = storage?.getModelUsageOrder() ?? []; this.#availableItems = buildBrowserItems(availableModels); sortModelItems(this.#availableItems, { roles: this.#roles, mruOrder }); this.#browser.setRoles(this.#roles); this.#browser.setMruOrder(mruOrder); + this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map()); const bySelector = new Map(this.#availableItems.map(item => [item.selector, item])); this.#recentItems = []; @@ -521,6 +525,7 @@ export class ModelHubComponent implements Component { this.#entries = entries; if (!entries.some(entry => entry.id === this.#activeEntryId)) { this.#activeEntryId = "all"; + this.#sidebarFollowActive = true; } } @@ -531,6 +536,7 @@ export class ModelHubComponent implements Component { #setActiveEntry(id: string): void { if (!this.#entries.some(entry => entry.id === id)) return; this.#activeEntryId = id; + this.#sidebarFollowActive = true; this.#applyScope(); const entry = this.#activeEntry(); // Hops must never steal arrow focus: landing on a scope keeps provider @@ -1293,15 +1299,23 @@ export class ModelHubComponent implements Component { } } - /** Step the roles cursor by one row, wrapping and skipping separator rows. */ - #stepRoleIndex(from: number, delta: -1 | 1): number { - const count = Math.max(1, this.#rolesRows.length); + /** 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; + const count = this.#rolesRows.length; + if (count === 0) return 0; let index = from; for (let i = 0; i < count; i++) { - index = (index + delta + count) % count; - if (this.#rolesRows[index]?.kind !== "separator") break; + const next = index + delta; + if (next < 0 || next >= count) { + if (!wrap) return from; + index = (next + count) % count; + } else { + index = next; + } + if (this.#rolesRows[index]?.kind !== "separator") return index; } - return index; + return from; } #handleRolesViewInput(data: string): void { @@ -1431,10 +1445,13 @@ export class ModelHubComponent implements Component { if (event.wheel !== null) { if (overSidebar) { - this.#moveSidebar(event.wheel); + // Wheel pans the sidebar viewport; picking a scope is click/keys only. + const maxScroll = Math.max(0, this.#entries.length - this.#contentRowCount); + this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll + event.wheel, maxScroll)); + this.#sidebarHover = this.#sidebarEntryIndexAt(contentLine); } else if (overBody) { if (entry.kind === "roles" && this.#assigning === null) { - this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1); + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false }); } else if (this.#isBrowserView(entry)) { this.#browser.routeMouse(event, bodyLine); } @@ -1520,15 +1537,22 @@ export class ModelHubComponent implements Component { } #renderSidebar(width: number, rows: number): string[] { - const activeIndex = Math.max( - 0, - this.#entries.findIndex(entry => entry.id === this.#activeEntryId), - ); - if (this.#entries.length > rows) { - this.#sidebarScroll = Math.max(0, Math.min(activeIndex - Math.floor(rows / 2), this.#entries.length - rows)); - } else { - this.#sidebarScroll = 0; + // The scroll offset is persistent: the wheel pans it freely. Only an + // activation (keys, click, programmatic) snaps the viewport to the + // active entry, and only far enough to reveal it. + if (this.#sidebarFollowActive) { + const activeIndex = Math.max( + 0, + this.#entries.findIndex(entry => entry.id === this.#activeEntryId), + ); + if (activeIndex < this.#sidebarScroll) { + this.#sidebarScroll = activeIndex; + } else if (activeIndex >= this.#sidebarScroll + rows) { + this.#sidebarScroll = activeIndex - rows + 1; + } + this.#sidebarFollowActive = false; } + this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll, Math.max(0, this.#entries.length - rows))); const lines: string[] = []; for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) { diff --git a/packages/coding-agent/test/model-browser.test.ts b/packages/coding-agent/test/model-browser.test.ts index a8c34e8dd..2dc3a9968 100644 --- a/packages/coding-agent/test/model-browser.test.ts +++ b/packages/coding-agent/test/model-browser.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -7,6 +7,7 @@ import { ModelBrowser, sortModelItems, } from "@oh-my-pi/pi-coding-agent/modes/components/model-browser"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; function makeModel(provider: string, id: string): Model { return buildModel({ @@ -62,3 +63,44 @@ describe("ModelBrowser search ranking", () => { expect(browser.getSelected()?.selector).toBe("zenmux/gpt-5.5"); }); }); + +describe("ModelBrowser perf display", () => { + beforeAll(async () => { + // render() reads the global theme singleton. + await initTheme(false); + }); + + function makePerfBrowser(): ModelBrowser { + const browser = new ModelBrowser(Settings.isolated({})); + browser.setItems(buildBrowserItems([makeModel("openai", "gpt-5")])); + browser.setPerfStats(new Map([["openai/gpt-5", { samples: 12, tps: 118.4, ttftMs: 930 }]])); + return browser; + } + + function renderPlain(browser: ModelBrowser, width: number): string[] { + return browser.render(width).map(line => Bun.stripANSI(line)); + } + + test("row perf column scales with width: off, TPS-only, TTFT+TPS", () => { + const browser = makePerfBrowser(); + + expect(renderPlain(browser, 70)[2]).not.toContain("t/s"); + expect(renderPlain(browser, 80)[2]).toContain("118t/s"); + const wideRow = renderPlain(browser, 120)[2]; + expect(wideRow).toContain("0.9s 118t/s"); + }); + + test("detail line shows measured perf regardless of width", () => { + const browser = makePerfBrowser(); + + const lines = renderPlain(browser, 70); + expect(lines[lines.length - 2]).toContain("~118t/s · 0.9s ttft"); + }); + + test("models without measurements render no perf cell", () => { + const browser = new ModelBrowser(Settings.isolated({})); + browser.setItems(buildBrowserItems([makeModel("openai", "gpt-5")])); + + expect(renderPlain(browser, 120)[2]).not.toContain("t/s"); + }); +}); diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 95fd2d3b6..93d31ad6c 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -689,6 +689,85 @@ describe("ModelHub", () => { }); }); + describe("mouse wheel", () => { + // SGR wheel reports: button 64 = up, 65 = down. Column 100 lands in the + // body pane, column 3 in the sidebar; row 10 is inside the content rows. + const WHEEL_UP_BODY = "\x1b[<64;100;10M"; + const WHEEL_DOWN_BODY = "\x1b[<65;100;10M"; + const WHEEL_UP_SIDEBAR = "\x1b[<64;3;10M"; + const WHEEL_DOWN_SIDEBAR = "\x1b[<65;3;10M"; + + test("wheel pans the model list without moving the selection and clamps at the ends", () => { + const models = Array.from({ length: 40 }, (_, i) => makeModel("test", `model-${String(i).padStart(2, "0")}`)); + const { hub, onPick } = createHub({ models, scoped: true, hub: { mode: "pick" } }); + + const before = normalize(hub.render(220)); // establishes mouse geometry + hub.handleInput("\n"); + expect(onPick).toHaveBeenCalledTimes(1); + const initiallySelected = onPick.mock.calls[0]?.[0]; + + // Panning reveals rows that were below the fold... + for (let i = 0; i < 8; i++) hub.handleInput(WHEEL_DOWN_BODY); + const panned = normalize(hub.render(220)); + const modelIdsIn = (frame: string) => new Set(Array.from(frame.matchAll(/model-\d\d/g), match => match[0])); + const beforeIds = modelIdsIn(before); + const revealed = [...modelIdsIn(panned)].filter(id => !beforeIds.has(id)); + expect(revealed.length).toBeGreaterThan(0); + + // ...but never moves the selection: Enter still picks the same model. + hub.handleInput("\n"); + expect(onPick).toHaveBeenCalledTimes(2); + expect(onPick.mock.calls[1]?.[0]).toBe(initiallySelected); + + // The window clamps at the bottom instead of wrapping back to the top... + for (let i = 0; i < 500; i++) hub.handleInput(WHEEL_DOWN_BODY); + const saturated = normalize(hub.render(220)); + hub.handleInput(WHEEL_DOWN_BODY); + expect(normalize(hub.render(220))).toBe(saturated); + + // ...and scrolling back up restores the original window exactly. + for (let i = 0; i < 500; i++) hub.handleInput(WHEEL_UP_BODY); + expect(normalize(hub.render(220))).toBe(before); + }); + + test("wheel over the sidebar never changes the active scope or schedules refreshes", () => { + vi.useFakeTimers(); + try { + const refreshProvider = vi.fn(async () => {}); + const { hub } = createHub({ + models: [makeModel("prov-a", "model-a"), makeModel("prov-b", "model-b")], + registry: { refreshProvider }, + }); + + expect(normalize(hub.render(220))).toContain("All available models"); + + // Two hops under the old wheel-selects behavior would land on a + // provider scope; the viewport pan must leave the scope alone. + for (let i = 0; i < 2; i++) hub.handleInput(WHEEL_DOWN_SIDEBAR); + expect(normalize(hub.render(220))).toContain("All available models"); + for (let i = 0; i < 2; i++) hub.handleInput(WHEEL_UP_SIDEBAR); + expect(normalize(hub.render(220))).toContain("All available models"); + + // No scope change means no provider auto-refresh either. + vi.advanceTimersByTime(200); // past the 120ms provider-refresh debounce + expect(refreshProvider).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("wheel in the roles view clamps at the top instead of wrapping to the bottom rows", () => { + const { hub } = createHub({ models: [makeModel("test", "model-a")], scoped: true }); + + hub.handleInput(UP); // All models → Roles + hub.render(220); // establish mouse geometry + for (let i = 0; i < 4; i++) hub.handleInput(WHEEL_UP_BODY); // cursor stays on the first role + hub.handleInput("\n"); // dive into the rows + hub.handleInput("\n"); // activate the cursor row + expect(normalize(hub.render(220))).toContain("Assigning DEFAULT"); + }); + }); + describe("provider scopes and search", () => { test("search inside a provider scope keeps that provider's model (#4522)", () => { const openrouterGlm = makeModel("openrouter", "z-ai/glm-5.2");