@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3271 -3224
  3. package/dist/types/cli/bench-cli.d.ts +1 -7
  4. package/dist/types/cli/usage-cli.d.ts +1 -0
  5. package/dist/types/commands/usage.d.ts +7 -0
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  8. package/dist/types/modes/components/model-browser.d.ts +14 -1
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/modes/components/welcome.d.ts +4 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  13. package/dist/types/modes/interactive-mode.d.ts +2 -0
  14. package/dist/types/modes/queue-input.d.ts +8 -0
  15. package/dist/types/modes/types.d.ts +2 -0
  16. package/dist/types/session/agent-storage.d.ts +57 -0
  17. package/package.json +12 -12
  18. package/scripts/build-binary.ts +0 -1
  19. package/scripts/compile-binary.ts +4 -3
  20. package/src/cli/bench-cli.ts +7 -26
  21. package/src/cli/usage-cli.ts +11 -0
  22. package/src/commands/usage.ts +13 -2
  23. package/src/config/settings-schema.ts +1 -1
  24. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  25. package/src/modes/components/advisor-config.ts +3 -1
  26. package/src/modes/components/custom-editor.test.ts +58 -1
  27. package/src/modes/components/custom-editor.ts +42 -11
  28. package/src/modes/components/model-browser.ts +154 -60
  29. package/src/modes/components/model-hub.ts +475 -122
  30. package/src/modes/components/plan-review-overlay.ts +7 -0
  31. package/src/modes/components/tips.txt +2 -1
  32. package/src/modes/components/usage-row.ts +5 -6
  33. package/src/modes/components/welcome.ts +13 -14
  34. package/src/modes/controllers/input-controller.ts +140 -6
  35. package/src/modes/controllers/selector-controller.ts +20 -13
  36. package/src/modes/controllers/todo-command-controller.ts +1 -2
  37. package/src/modes/interactive-mode.ts +18 -0
  38. package/src/modes/queue-input.ts +132 -0
  39. package/src/modes/types.ts +2 -0
  40. package/src/modes/utils/ui-helpers.ts +19 -20
  41. package/src/session/agent-session.ts +184 -48
  42. package/src/session/agent-storage.ts +330 -3
  43. package/src/session/history-storage.ts +1 -34
  44. package/src/slash-commands/builtin-registry.ts +9 -0
  45. package/src/web/search/providers/perplexity.ts +18 -2
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
2
3
  import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
3
4
  import { $ } from "bun";
4
5
  import { getDefaultPasteImageKeys } from "../../config/keybindings";
5
- import { getEditorTheme, initTheme } from "../theme/theme";
6
+ import { getEditorTheme, initTheme, theme } from "../theme/theme";
6
7
  import {
7
8
  CustomEditor,
8
9
  extractBracketedImagePastePaths,
@@ -78,6 +79,62 @@ describe("CustomEditor placeholder decoration", () => {
78
79
  });
79
80
  });
80
81
 
82
+ describe("CustomEditor queue shorthand decoration", () => {
83
+ beforeAll(async () => {
84
+ await initTheme();
85
+ });
86
+
87
+ it("reserves the first line as soon as either queue prefix is completed", () => {
88
+ for (const prefix of ["->", "=>"]) {
89
+ const editor = new CustomEditor(getEditorTheme());
90
+ editor.handleInput(prefix[0] ?? "");
91
+ expect(editor.getText()).toBe(prefix[0]);
92
+
93
+ editor.handleInput(prefix[1] ?? "");
94
+ expect(editor.getText()).toBe(`${prefix}\n`);
95
+ expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
96
+
97
+ editor.handleInput("\x7f");
98
+ expect(editor.getText()).toBe(`${prefix}\n`);
99
+ expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
100
+ }
101
+ });
102
+
103
+ it("renders the reserved line as a dim Queueing header", () => {
104
+ for (const prefix of ["->", "=>"]) {
105
+ const editor = new CustomEditor(getEditorTheme());
106
+ editor.setText(`${prefix}\nqueue this`);
107
+
108
+ expect(editor.decorateText(prefix)).toBe(theme.fg("dim", `Queueing ${theme.nav.selected}`));
109
+ editor.focused = true;
110
+ const rendered = editor.render(40).map(line => Bun.stripANSI(line.replace(CURSOR_MARKER, "")));
111
+ expect(rendered.some(line => line.includes(`Queueing ${theme.nav.selected}`))).toBe(true);
112
+ expect(rendered.every(line => Bun.stringWidth(line) === 40)).toBe(true);
113
+ expect(rendered.some(line => line.includes("queue this"))).toBe(true);
114
+ }
115
+ });
116
+
117
+ it("highlights dot and parenthesis markers only for detected queue lists", () => {
118
+ for (const [input, marker] of [
119
+ ["=>\n1. first\n2. second", "1."],
120
+ ["=>\n1) first\n2) second", "1)"],
121
+ ]) {
122
+ const editor = new CustomEditor(getEditorTheme());
123
+ editor.setText(input);
124
+ expect(editor.decorateText(`${marker} first`).startsWith(theme.fg("accent", marker))).toBe(true);
125
+ }
126
+
127
+ const unfinished = new CustomEditor(getEditorTheme());
128
+ unfinished.setText("=>\n1. first\n2. second\n3. third\n4.");
129
+ expect(unfinished.decorateText("1. first").startsWith(theme.fg("accent", "1."))).toBe(true);
130
+ expect(unfinished.decorateText("4.").startsWith(theme.fg("accent", "4."))).toBe(true);
131
+
132
+ const editor = new CustomEditor(getEditorTheme());
133
+ editor.setText("=>\n1. first\n3. third");
134
+ expect(editor.decorateText("1. first")).toBe("1. first");
135
+ });
136
+ });
137
+
81
138
  describe("CustomEditor bracketed path paste", () => {
82
139
  it("leaves a pasted bare .png filename on the normal text path", () => {
83
140
  expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
@@ -6,7 +6,8 @@ import type { AppKeybinding } from "../../config/keybindings";
6
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
7
  import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references";
8
8
  import { hasMagicKeyword, highlightMagicKeywords } from "../magic-keywords";
9
- import { fgOrPlain } from "../theme/theme";
9
+ import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "../queue-input";
10
+ import { fgOrPlain, theme } from "../theme/theme";
10
11
 
11
12
  type ConfigurableEditorAction = Extract<
12
13
  AppKeybinding,
@@ -326,21 +327,41 @@ export class CustomEditor extends Editor {
326
327
  * timer to request the next animation frame. Undefined when nobody is
327
328
  * listening (tests, headless callers); the timer chain still self-cleans. */
328
329
  #requestShimmerRepaint: (() => void) | undefined;
330
+ #queueDecorationText: string | undefined;
331
+ #queueShorthandActive = false;
332
+ #queueListActive = false;
329
333
 
330
- /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types
331
- * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
332
- * pasted image placeholders visually distinct and hyperlink them once their blob file exists.
333
- * When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled`
334
- * is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render
335
- * schedules the next frame, so losing focus, deleting the keyword, or flipping the setting
336
- * stops the animation on its own. The static glow itself runs even when shimmering is gated
337
- * off, matching existing behavior for the editor and sent bubbles. */
334
+ /** Decorate magic keywords, attachments, and the queue-composer header/list markers.
335
+ * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
336
+ * item markers use the accent color so separate follow-ups remain visible while composing. */
338
337
  decorateText = (text: string): string => {
339
- const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(this.getText());
338
+ const editorText = this.getText();
339
+ const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
340
340
  const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
341
341
  if (animated) this.#scheduleShimmerFrame();
342
+ if (this.#queueDecorationText !== editorText) {
343
+ this.#queueDecorationText = editorText;
344
+ const queueBody = parseQueueShorthand(editorText);
345
+ this.#queueShorthandActive = queueBody !== undefined;
346
+ this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody);
347
+ }
342
348
  return renderPlaceholders(text, {
343
- renderText: value => highlightMagicKeywords(value, undefined, phase),
349
+ renderText: value => {
350
+ const highlighted = highlightMagicKeywords(value, undefined, phase);
351
+ if (this.#queueShorthandActive && (value.startsWith("->") || value.startsWith("=>"))) {
352
+ const icon = typeof theme === "undefined" ? "➤" : theme.nav.selected;
353
+ return `${fgOrPlain("dim", `Queueing ${icon}`)}${highlighted.slice(2)}`;
354
+ }
355
+ if (this.#queueListActive) {
356
+ const markerMatch = QUEUE_LIST_MARKER_RE.exec(value);
357
+ if (markerMatch) {
358
+ const indent = markerMatch[1] ?? "";
359
+ const markerEnd = markerMatch[0].length;
360
+ return `${indent}${fgOrPlain("accent", value.slice(indent.length, markerEnd))}${highlighted.slice(markerEnd)}`;
361
+ }
362
+ }
363
+ return highlighted;
364
+ },
344
365
  renderReference: (value, kind, index) =>
345
366
  kind === "image"
346
367
  ? imageReferenceHyperlink(value, index, this.imageLinks, label =>
@@ -628,6 +649,7 @@ export class CustomEditor extends Editor {
628
649
  this.#pendingInput.push(data);
629
650
  return;
630
651
  }
652
+ const hadBareQueuePrefix = this.getText() === "->" || this.getText() === "=>";
631
653
  const kittyParsed = parseKittySequence(data);
632
654
  if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
633
655
  // Caps Lock is modifier bit 64
@@ -831,5 +853,14 @@ export class CustomEditor extends Editor {
831
853
 
832
854
  // Pass to parent for normal handling
833
855
  super.handleInput(data);
856
+ const cursor = this.getCursor();
857
+ if (
858
+ !hadBareQueuePrefix &&
859
+ (this.getText() === "->" || this.getText() === "=>") &&
860
+ cursor.line === 0 &&
861
+ cursor.col === 2
862
+ ) {
863
+ this.insertText("\n");
864
+ }
834
865
  }
835
866
  }
@@ -13,7 +13,7 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
13
13
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
14
  import {
15
15
  type Component,
16
- fuzzyFilter,
16
+ fuzzyRank,
17
17
  Input,
18
18
  matchesKey,
19
19
  ScrollView,
@@ -24,6 +24,7 @@ import {
24
24
  import { formatNumber } from "@oh-my-pi/pi-utils";
25
25
  import { getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles";
26
26
  import type { Settings } from "../../config/settings";
27
+ import type { ModelPerfStats } from "../../session/agent-storage";
27
28
  import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
28
29
  import { theme } from "../theme/theme";
29
30
  import {
@@ -227,6 +228,18 @@ function formatContext(model: Model): string {
227
228
  return `${formatNumber(ctx).toLowerCase()} ${theme.icon.context.replace(/:$/, "")}`;
228
229
  }
229
230
 
231
+ /** `118t/s` average output speed; one decimal below 10 t/s. */
232
+ function formatTps(tps: number): string {
233
+ const value = tps >= 10 ? String(Math.round(tps)) : tps.toFixed(1);
234
+ return `${value}t/s`;
235
+ }
236
+
237
+ /** `0.9s` average time-to-first-token; whole seconds from 10s up. */
238
+ function formatTtft(ms: number): string {
239
+ const seconds = ms / 1000;
240
+ return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`;
241
+ }
242
+
230
243
  /** Pad `text` on the left to `width` terminal columns (ANSI/emoji aware). */
231
244
  function padLeftVisible(text: string, width: number): string {
232
245
  const missing = width - visibleWidth(text);
@@ -250,6 +263,12 @@ export interface ModelBrowserOptions {
250
263
  const LIST_ROW_START = 2;
251
264
  /** Rendered rows after the list window: blank + two detail rows. */
252
265
  const DETAIL_ROWS = 3;
266
+ /** Row width from which the measured-perf column appears (TPS only). */
267
+ const PERF_TPS_MIN_WIDTH = 76;
268
+ /** Row width from which the perf column also includes TTFT. */
269
+ const PERF_FULL_MIN_WIDTH = 96;
270
+ /** What the per-row perf column shows at the current width. */
271
+ type PerfMode = "off" | "tps" | "full";
253
272
 
254
273
  /**
255
274
  * The reusable browser component. Renders a fixed-height block
@@ -263,6 +282,7 @@ export class ModelBrowser implements Component {
263
282
  #visibleItems: ModelBrowserItem[] = [];
264
283
  #roles: RoleAssignments = {};
265
284
  #mruOrder: ReadonlyArray<string> = [];
285
+ #perf: ReadonlyMap<string, ModelPerfStats> = new Map();
266
286
  #selectedIndex = 0;
267
287
  #hoveredIndex: number | null = null;
268
288
  #maxVisible = 10;
@@ -270,8 +290,11 @@ export class ModelBrowser implements Component {
270
290
  #currentContextTokens: number;
271
291
  #disableOverContext: boolean;
272
292
  #emptyText?: () => string | undefined;
293
+ /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */
273
294
  #windowStart = 0;
274
295
  #windowCount = 0;
296
+ /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
297
+ #focused = true;
275
298
 
276
299
  /** Enter or click-on-selected. */
277
300
  onActivate?: (item: ModelBrowserItem) => void;
@@ -310,13 +333,24 @@ export class ModelBrowser implements Component {
310
333
  this.#mruOrder = order;
311
334
  }
312
335
 
336
+ /** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */
337
+ setPerfStats(perf: ReadonlyMap<string, ModelPerfStats>): void {
338
+ this.#perf = perf;
339
+ }
340
+
313
341
  setMaxVisible(rows: number): void {
342
+ // No selection snap here: hosts call this on every render, and it must
343
+ // not undo wheel panning. render() re-clamps the window.
314
344
  this.#maxVisible = Math.max(1, rows);
315
345
  }
316
346
 
317
347
  setShowProvider(show: boolean): void {
318
348
  this.#showProvider = show;
319
349
  }
350
+ /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
351
+ setFocused(focused: boolean): void {
352
+ this.#focused = focused;
353
+ }
320
354
 
321
355
  /** Total rendered height for the current `maxVisible` (host layout budgeting). */
322
356
  get renderedRows(): number {
@@ -345,6 +379,7 @@ export class ModelBrowser implements Component {
345
379
  const index = this.#visibleItems.findIndex(item => item.selector === selector);
346
380
  if (index < 0) return false;
347
381
  this.#selectedIndex = this.#coerceSelectedIndex(index);
382
+ this.#ensureSelectedVisible();
348
383
  return true;
349
384
  }
350
385
 
@@ -372,23 +407,48 @@ export class ModelBrowser implements Component {
372
407
  return clamped;
373
408
  }
374
409
 
375
- moveSelection(delta: number): void {
410
+ /** Clamp a window start into `[0, total - maxVisible]`. */
411
+ #clampWindowStart(start: number): number {
412
+ return Math.max(0, Math.min(start, this.#visibleItems.length - this.#maxVisible));
413
+ }
414
+
415
+ /** Scroll just enough to keep the selected row inside the window. */
416
+ #ensureSelectedVisible(): void {
417
+ if (this.#selectedIndex < this.#windowStart) {
418
+ this.#windowStart = this.#selectedIndex;
419
+ } else if (this.#selectedIndex >= this.#windowStart + this.#maxVisible) {
420
+ this.#windowStart = this.#selectedIndex - this.#maxVisible + 1;
421
+ }
422
+ this.#windowStart = this.#clampWindowStart(this.#windowStart);
423
+ }
424
+
425
+ /**
426
+ * Move the selection by `delta` rows, skipping disabled rows. Single steps
427
+ * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead.
428
+ */
429
+ moveSelection(delta: number, options: { wrap?: boolean } = {}): void {
376
430
  const count = this.#visibleItems.length;
377
431
  if (count === 0) return;
378
- let index = this.#selectedIndex;
379
- for (let step = 0; step < count; step++) {
380
- index = (index + delta + count) % count;
381
- const item = this.#visibleItems[index];
382
- if (item && !this.#isDisabled(item)) {
383
- this.#setSelectedIndex(index);
384
- return;
432
+ if (options.wrap ?? true) {
433
+ let index = this.#selectedIndex;
434
+ for (let step = 0; step < count; step++) {
435
+ index = (index + delta + count) % count;
436
+ const item = this.#visibleItems[index];
437
+ if (item && !this.#isDisabled(item)) {
438
+ this.#setSelectedIndex(index);
439
+ return;
440
+ }
385
441
  }
442
+ return;
386
443
  }
444
+ const target = Math.max(0, Math.min(this.#selectedIndex + delta, count - 1));
445
+ this.#setSelectedIndex(this.#coerceSelectedIndex(target));
387
446
  }
388
447
 
389
448
  #setSelectedIndex(index: number): void {
390
449
  if (index === this.#selectedIndex) return;
391
450
  this.#selectedIndex = index;
451
+ this.#ensureSelectedVisible();
392
452
  this.onSelectionChange?.(this.getSelected());
393
453
  }
394
454
 
@@ -432,16 +492,26 @@ export class ModelBrowser implements Component {
432
492
  if (query.trim()) {
433
493
  // Match against the displayed "provider/id" string so the user can
434
494
  // type what they see: bare names, provider prefixes, or scoped
435
- // queries all flow through the same fuzzy matcher. Skip role rank
436
- // so a weakly matching default doesn't trump a stronger match.
437
- const matches = fuzzyFilter(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
495
+ // queries all flow through the same fuzzy matcher.
496
+ const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
497
+ const matches = ranked.map(result => result.item);
498
+ // Match quality is the primary key while searching: an exact
499
+ // "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
500
+ // role rank is skipped and MRU only breaks ties. Scores are
501
+ // bucketed so sub-point position noise (provider-name length)
502
+ // can't split equally good matches; within a bucket the stable
503
+ // sort keeps sortModelItems' MRU/version order.
438
504
  sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
505
+ const buckets = new Map<ModelBrowserItem, number>();
506
+ for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
507
+ matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
439
508
  items = matches;
440
509
  } else {
441
510
  items = this.#baseItems;
442
511
  }
443
512
  this.#visibleItems = this.#insertSeparator(items);
444
513
  this.#selectedIndex = this.#coerceSelectedIndex(Math.min(this.#selectedIndex, this.#visibleItems.length - 1));
514
+ this.#ensureSelectedVisible();
445
515
  this.onSelectionChange?.(this.getSelected());
446
516
  }
447
517
 
@@ -459,11 +529,19 @@ export class ModelBrowser implements Component {
459
529
  return;
460
530
  }
461
531
  if (matchesSelectPageUp(data)) {
462
- this.moveSelection(-this.#maxVisible);
532
+ this.moveSelection(-this.#maxVisible, { wrap: false });
463
533
  return;
464
534
  }
465
535
  if (matchesSelectPageDown(data)) {
466
- this.moveSelection(this.#maxVisible);
536
+ this.moveSelection(this.#maxVisible, { wrap: false });
537
+ return;
538
+ }
539
+ if (matchesKey(data, "home")) {
540
+ this.moveSelection(-this.#visibleItems.length, { wrap: false });
541
+ return;
542
+ }
543
+ if (matchesKey(data, "end")) {
544
+ this.moveSelection(this.#visibleItems.length, { wrap: false });
467
545
  return;
468
546
  }
469
547
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
@@ -499,50 +577,49 @@ export class ModelBrowser implements Component {
499
577
  */
500
578
  routeMouse(event: SgrMouseEvent, line: number): void {
501
579
  if (event.wheel !== null) {
502
- this.moveSelection(event.wheel);
503
- return;
504
- }
505
- const listLine = line - LIST_ROW_START;
506
- if (listLine < 0 || listLine >= this.#windowCount) {
507
- if (event.motion && this.#hoveredIndex !== null) {
508
- this.#hoveredIndex = null;
509
- }
510
- return;
511
- }
512
- const index = this.#windowStart + listLine;
513
- const item = this.#visibleItems[index];
514
- if (!item || this.#isDisabled(item)) {
515
- this.#hoveredIndex = null;
580
+ // Wheel pans the window; it never moves the selection and never wraps.
581
+ this.#windowStart = this.#clampWindowStart(this.#windowStart + event.wheel);
582
+ this.#hoveredIndex = this.#hoverIndexAt(line);
516
583
  return;
517
584
  }
518
585
  if (event.motion) {
519
- this.#hoveredIndex = index;
586
+ this.#hoveredIndex = this.#hoverIndexAt(line);
520
587
  return;
521
588
  }
522
- if (event.leftClick) {
523
- // Settings idiom: click selects, click-again activates.
524
- if (index === this.#selectedIndex) {
525
- this.onActivate?.(item);
526
- } else {
527
- this.#setSelectedIndex(index);
528
- }
589
+ if (!event.leftClick) return;
590
+ const index = this.#hoverIndexAt(line);
591
+ const item = index !== null ? this.#visibleItems[index] : undefined;
592
+ if (index === null || !item) return;
593
+ // Settings idiom: click selects, click-again activates.
594
+ if (index === this.#selectedIndex) {
595
+ this.onActivate?.(item);
596
+ } else {
597
+ this.#setSelectedIndex(index);
529
598
  }
530
599
  }
600
+ /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */
601
+ clearHover(): void {
602
+ this.#hoveredIndex = null;
603
+ }
531
604
 
532
- #chipsFor(model: Model): string {
533
- const parts: string[] = [];
534
- const seen = new Set<string>();
535
- const pushChip = (role: string) => {
536
- if (seen.has(role)) return;
537
- seen.add(role);
538
- const assignment = this.#roles[role];
539
- if (!assignment || !modelsAreEqual(assignment.model, model)) return;
540
- if (getRoleInfo(role, this.#settings).hidden) return;
541
- parts.push(formatRoleChip(role, assignment, this.#settings));
542
- };
543
- for (const role of MODEL_ROLE_IDS) pushChip(role);
544
- for (const role in this.#roles) pushChip(role);
545
- return parts.length > 0 ? ` ${parts.join(" ")}` : "";
605
+ /** List index under a frame-local row, or null when off-list or on a disabled row. */
606
+ #hoverIndexAt(line: number): number | null {
607
+ const listLine = line - LIST_ROW_START;
608
+ if (listLine < 0 || listLine >= this.#windowCount) return null;
609
+ const index = this.#windowStart + listLine;
610
+ const item = this.#visibleItems[index];
611
+ if (!item || this.#isDisabled(item)) return null;
612
+ return index;
613
+ }
614
+
615
+ /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */
616
+ #perfCell(item: ModelBrowserItem, mode: PerfMode): string {
617
+ if (mode === "off") return "";
618
+ const perf = this.#perf.get(item.selector);
619
+ if (!perf) return "";
620
+ const tps = formatTps(perf.tps);
621
+ if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`;
622
+ return tps;
546
623
  }
547
624
 
548
625
  #renderRow(
@@ -552,6 +629,8 @@ export class ModelBrowser implements Component {
552
629
  hovered: boolean,
553
630
  ctxWidth: number,
554
631
  costWidth: number,
632
+ perfWidth: number,
633
+ perfMode: PerfMode,
555
634
  ): string {
556
635
  if (item.id === "separator") {
557
636
  const dashCount = Math.max(0, width - 4);
@@ -559,16 +638,19 @@ export class ModelBrowser implements Component {
559
638
  return ` ${line} `;
560
639
  }
561
640
  const disabled = this.#isDisabled(item);
562
- const prefix = selected ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
641
+ const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
563
642
  const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
564
643
  const name = selected ? theme.fg("accent", item.id) : item.id;
565
644
  const overLimit = disabled
566
645
  ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
567
646
  : "";
568
- let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`;
647
+ let left = `${prefix}${providerPrefix}${name}${overLimit}`;
569
648
 
570
- const meta = `${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`;
571
- const metaWidth = ctxWidth + costWidth + 2;
649
+ // Perf column collapses entirely when no visible row has measurements.
650
+ const perfCol =
651
+ perfWidth > 0 ? `${theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth))} ` : "";
652
+ const meta = `${perfCol}${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`;
653
+ const metaWidth = ctxWidth + costWidth + 2 + (perfWidth > 0 ? perfWidth + 2 : 0);
572
654
  const available = Math.max(1, width - metaWidth - 1);
573
655
  left = truncateToWidth(left, available);
574
656
  const gap = Math.max(0, available - visibleWidth(left));
@@ -577,7 +659,9 @@ export class ModelBrowser implements Component {
577
659
  if (disabled) {
578
660
  line = theme.fg("dim", Bun.stripANSI(line));
579
661
  }
580
- if (hovered && !selected && !disabled) {
662
+ // The bg band is reserved for the mouse: it marks hover, nothing else.
663
+ // Keyboard selection is the cursor glyph + accent name.
664
+ if (hovered && !disabled) {
581
665
  line = theme.bg("selectedBg", line);
582
666
  }
583
667
  return line;
@@ -594,6 +678,11 @@ export class ModelBrowser implements Component {
594
678
  facts.push(`${formatCostPair(model)} per M`);
595
679
  if (model.reasoning) facts.push("reasoning");
596
680
  if (model.input.includes("image")) facts.push("vision");
681
+ const perf = this.#perf.get(selected.selector);
682
+ if (perf) {
683
+ facts.push(`~${formatTps(perf.tps)}`);
684
+ if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`);
685
+ }
597
686
  const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width);
598
687
 
599
688
  if (this.#isDisabled(selected)) {
@@ -626,12 +715,12 @@ export class ModelBrowser implements Component {
626
715
  lines.push("");
627
716
 
628
717
  const total = this.#visibleItems.length;
629
- const startIndex = Math.max(
630
- 0,
631
- Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible),
632
- );
718
+ // The window is persistent state: wheel scrolling panned it, keyboard
719
+ // navigation snapped it to the selection. Re-clamp here because items
720
+ // or maxVisible may have changed since.
721
+ this.#windowStart = this.#clampWindowStart(this.#windowStart);
722
+ const startIndex = this.#windowStart;
633
723
  const endIndex = Math.min(startIndex + this.#maxVisible, total);
634
- this.#windowStart = startIndex;
635
724
  this.#windowCount = Math.max(0, endIndex - startIndex);
636
725
 
637
726
  if (total === 0) {
@@ -644,11 +733,14 @@ export class ModelBrowser implements Component {
644
733
  // scanning the entire catalog on every render.
645
734
  let ctxWidth = 0;
646
735
  let costWidth = 0;
736
+ const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off";
737
+ let perfWidth = 0;
647
738
  for (let i = startIndex; i < endIndex; i++) {
648
739
  const item = this.#visibleItems[i];
649
740
  if (!item) continue;
650
741
  ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model)));
651
742
  costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model)));
743
+ perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode)));
652
744
  }
653
745
 
654
746
  const rows: string[] = [];
@@ -663,6 +755,8 @@ export class ModelBrowser implements Component {
663
755
  i === this.#hoveredIndex,
664
756
  ctxWidth,
665
757
  costWidth,
758
+ perfWidth,
759
+ perfMode,
666
760
  ),
667
761
  );
668
762
  }