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.
This commit is contained in:
@@ -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<Model>;
|
||||
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);
|
||||
|
||||
@@ -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<string> = [];
|
||||
#perf: ReadonlyMap<string, ModelPerfStats> = 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<string, ModelPerfStats>): 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<string>();
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user