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:
can1357
2026-07-12 01:06:22 +02:00
parent c4fa0ebaae
commit a0dcb8ae20
5 changed files with 267 additions and 48 deletions
@@ -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");