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

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 (38) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli.js +3052 -3005
  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 +3 -0
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  11. package/dist/types/modes/interactive-mode.d.ts +2 -0
  12. package/dist/types/modes/queue-input.d.ts +8 -0
  13. package/dist/types/modes/types.d.ts +2 -0
  14. package/dist/types/session/agent-storage.d.ts +57 -0
  15. package/package.json +12 -12
  16. package/scripts/build-binary.ts +0 -1
  17. package/scripts/compile-binary.ts +4 -3
  18. package/src/cli/bench-cli.ts +7 -26
  19. package/src/cli/usage-cli.ts +11 -0
  20. package/src/commands/usage.ts +13 -2
  21. package/src/config/settings-schema.ts +1 -1
  22. package/src/modes/components/advisor-config.ts +3 -1
  23. package/src/modes/components/custom-editor.test.ts +58 -1
  24. package/src/modes/components/custom-editor.ts +42 -11
  25. package/src/modes/components/model-browser.ts +114 -33
  26. package/src/modes/components/model-hub.ts +455 -108
  27. package/src/modes/components/usage-row.ts +5 -6
  28. package/src/modes/controllers/input-controller.ts +140 -6
  29. package/src/modes/controllers/selector-controller.ts +20 -13
  30. package/src/modes/controllers/todo-command-controller.ts +1 -2
  31. package/src/modes/interactive-mode.ts +5 -0
  32. package/src/modes/queue-input.ts +132 -0
  33. package/src/modes/types.ts +2 -0
  34. package/src/modes/utils/ui-helpers.ts +19 -20
  35. package/src/session/agent-session.ts +184 -48
  36. package/src/session/agent-storage.ts +330 -3
  37. package/src/session/history-storage.ts +1 -34
  38. package/src/slash-commands/builtin-registry.ts +9 -0
@@ -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,6 +290,7 @@ 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;
275
296
 
@@ -310,7 +331,14 @@ export class ModelBrowser implements Component {
310
331
  this.#mruOrder = order;
311
332
  }
312
333
 
334
+ /** Measured TPS/TTFT averages keyed by `provider/id` selector (see AgentStorage.getModelPerf). */
335
+ setPerfStats(perf: ReadonlyMap<string, ModelPerfStats>): void {
336
+ this.#perf = perf;
337
+ }
338
+
313
339
  setMaxVisible(rows: number): void {
340
+ // No selection snap here: hosts call this on every render, and it must
341
+ // not undo wheel panning. render() re-clamps the window.
314
342
  this.#maxVisible = Math.max(1, rows);
315
343
  }
316
344
 
@@ -345,6 +373,7 @@ export class ModelBrowser implements Component {
345
373
  const index = this.#visibleItems.findIndex(item => item.selector === selector);
346
374
  if (index < 0) return false;
347
375
  this.#selectedIndex = this.#coerceSelectedIndex(index);
376
+ this.#ensureSelectedVisible();
348
377
  return true;
349
378
  }
350
379
 
@@ -372,6 +401,21 @@ export class ModelBrowser implements Component {
372
401
  return clamped;
373
402
  }
374
403
 
404
+ /** Clamp a window start into `[0, total - maxVisible]`. */
405
+ #clampWindowStart(start: number): number {
406
+ return Math.max(0, Math.min(start, this.#visibleItems.length - this.#maxVisible));
407
+ }
408
+
409
+ /** Scroll just enough to keep the selected row inside the window. */
410
+ #ensureSelectedVisible(): void {
411
+ if (this.#selectedIndex < this.#windowStart) {
412
+ this.#windowStart = this.#selectedIndex;
413
+ } else if (this.#selectedIndex >= this.#windowStart + this.#maxVisible) {
414
+ this.#windowStart = this.#selectedIndex - this.#maxVisible + 1;
415
+ }
416
+ this.#windowStart = this.#clampWindowStart(this.#windowStart);
417
+ }
418
+
375
419
  moveSelection(delta: number): void {
376
420
  const count = this.#visibleItems.length;
377
421
  if (count === 0) return;
@@ -389,6 +433,7 @@ export class ModelBrowser implements Component {
389
433
  #setSelectedIndex(index: number): void {
390
434
  if (index === this.#selectedIndex) return;
391
435
  this.#selectedIndex = index;
436
+ this.#ensureSelectedVisible();
392
437
  this.onSelectionChange?.(this.getSelected());
393
438
  }
394
439
 
@@ -432,16 +477,26 @@ export class ModelBrowser implements Component {
432
477
  if (query.trim()) {
433
478
  // Match against the displayed "provider/id" string so the user can
434
479
  // 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}`);
480
+ // queries all flow through the same fuzzy matcher.
481
+ const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
482
+ const matches = ranked.map(result => result.item);
483
+ // Match quality is the primary key while searching: an exact
484
+ // "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
485
+ // role rank is skipped and MRU only breaks ties. Scores are
486
+ // bucketed so sub-point position noise (provider-name length)
487
+ // can't split equally good matches; within a bucket the stable
488
+ // sort keeps sortModelItems' MRU/version order.
438
489
  sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
490
+ const buckets = new Map<ModelBrowserItem, number>();
491
+ for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
492
+ matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
439
493
  items = matches;
440
494
  } else {
441
495
  items = this.#baseItems;
442
496
  }
443
497
  this.#visibleItems = this.#insertSeparator(items);
444
498
  this.#selectedIndex = this.#coerceSelectedIndex(Math.min(this.#selectedIndex, this.#visibleItems.length - 1));
499
+ this.#ensureSelectedVisible();
445
500
  this.onSelectionChange?.(this.getSelected());
446
501
  }
447
502
 
@@ -499,36 +554,37 @@ export class ModelBrowser implements Component {
499
554
  */
500
555
  routeMouse(event: SgrMouseEvent, line: number): void {
501
556
  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;
557
+ // Wheel pans the window; it never moves the selection and never wraps.
558
+ this.#windowStart = this.#clampWindowStart(this.#windowStart + event.wheel);
559
+ this.#hoveredIndex = this.#hoverIndexAt(line);
516
560
  return;
517
561
  }
518
562
  if (event.motion) {
519
- this.#hoveredIndex = index;
563
+ this.#hoveredIndex = this.#hoverIndexAt(line);
520
564
  return;
521
565
  }
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
- }
566
+ if (!event.leftClick) return;
567
+ const index = this.#hoverIndexAt(line);
568
+ const item = index !== null ? this.#visibleItems[index] : undefined;
569
+ if (index === null || !item) return;
570
+ // Settings idiom: click selects, click-again activates.
571
+ if (index === this.#selectedIndex) {
572
+ this.onActivate?.(item);
573
+ } else {
574
+ this.#setSelectedIndex(index);
529
575
  }
530
576
  }
531
577
 
578
+ /** List index under a frame-local row, or null when off-list or on a disabled row. */
579
+ #hoverIndexAt(line: number): number | null {
580
+ const listLine = line - LIST_ROW_START;
581
+ if (listLine < 0 || listLine >= this.#windowCount) return null;
582
+ const index = this.#windowStart + listLine;
583
+ const item = this.#visibleItems[index];
584
+ if (!item || this.#isDisabled(item)) return null;
585
+ return index;
586
+ }
587
+
532
588
  #chipsFor(model: Model): string {
533
589
  const parts: string[] = [];
534
590
  const seen = new Set<string>();
@@ -545,6 +601,16 @@ export class ModelBrowser implements Component {
545
601
  return parts.length > 0 ? ` ${parts.join(" ")}` : "";
546
602
  }
547
603
 
604
+ /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */
605
+ #perfCell(item: ModelBrowserItem, mode: PerfMode): string {
606
+ if (mode === "off") return "";
607
+ const perf = this.#perf.get(item.selector);
608
+ if (!perf) return "";
609
+ const tps = formatTps(perf.tps);
610
+ if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`;
611
+ return tps;
612
+ }
613
+
548
614
  #renderRow(
549
615
  item: ModelBrowserItem,
550
616
  width: number,
@@ -552,6 +618,8 @@ export class ModelBrowser implements Component {
552
618
  hovered: boolean,
553
619
  ctxWidth: number,
554
620
  costWidth: number,
621
+ perfWidth: number,
622
+ perfMode: PerfMode,
555
623
  ): string {
556
624
  if (item.id === "separator") {
557
625
  const dashCount = Math.max(0, width - 4);
@@ -567,8 +635,11 @@ export class ModelBrowser implements Component {
567
635
  : "";
568
636
  let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`;
569
637
 
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;
638
+ // Perf column collapses entirely when no visible row has measurements.
639
+ const perfCol =
640
+ perfWidth > 0 ? `${theme.fg("dim", padLeftVisible(this.#perfCell(item, perfMode), perfWidth))} ` : "";
641
+ const meta = `${perfCol}${theme.fg("dim", padLeftVisible(formatContext(item.model), ctxWidth))} ${theme.fg("dim", padLeftVisible(formatCostPair(item.model), costWidth))}`;
642
+ const metaWidth = ctxWidth + costWidth + 2 + (perfWidth > 0 ? perfWidth + 2 : 0);
572
643
  const available = Math.max(1, width - metaWidth - 1);
573
644
  left = truncateToWidth(left, available);
574
645
  const gap = Math.max(0, available - visibleWidth(left));
@@ -594,6 +665,11 @@ export class ModelBrowser implements Component {
594
665
  facts.push(`${formatCostPair(model)} per M`);
595
666
  if (model.reasoning) facts.push("reasoning");
596
667
  if (model.input.includes("image")) facts.push("vision");
668
+ const perf = this.#perf.get(selected.selector);
669
+ if (perf) {
670
+ facts.push(`~${formatTps(perf.tps)}`);
671
+ if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`);
672
+ }
597
673
  const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width);
598
674
 
599
675
  if (this.#isDisabled(selected)) {
@@ -626,12 +702,12 @@ export class ModelBrowser implements Component {
626
702
  lines.push("");
627
703
 
628
704
  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
- );
705
+ // The window is persistent state: wheel scrolling panned it, keyboard
706
+ // navigation snapped it to the selection. Re-clamp here because items
707
+ // or maxVisible may have changed since.
708
+ this.#windowStart = this.#clampWindowStart(this.#windowStart);
709
+ const startIndex = this.#windowStart;
633
710
  const endIndex = Math.min(startIndex + this.#maxVisible, total);
634
- this.#windowStart = startIndex;
635
711
  this.#windowCount = Math.max(0, endIndex - startIndex);
636
712
 
637
713
  if (total === 0) {
@@ -644,11 +720,14 @@ export class ModelBrowser implements Component {
644
720
  // scanning the entire catalog on every render.
645
721
  let ctxWidth = 0;
646
722
  let costWidth = 0;
723
+ const perfMode: PerfMode = width >= PERF_FULL_MIN_WIDTH ? "full" : width >= PERF_TPS_MIN_WIDTH ? "tps" : "off";
724
+ let perfWidth = 0;
647
725
  for (let i = startIndex; i < endIndex; i++) {
648
726
  const item = this.#visibleItems[i];
649
727
  if (!item) continue;
650
728
  ctxWidth = Math.max(ctxWidth, visibleWidth(formatContext(item.model)));
651
729
  costWidth = Math.max(costWidth, visibleWidth(formatCostPair(item.model)));
730
+ perfWidth = Math.max(perfWidth, visibleWidth(this.#perfCell(item, perfMode)));
652
731
  }
653
732
 
654
733
  const rows: string[] = [];
@@ -663,6 +742,8 @@ export class ModelBrowser implements Component {
663
742
  i === this.#hoveredIndex,
664
743
  ctxWidth,
665
744
  costWidth,
745
+ perfWidth,
746
+ perfMode,
666
747
  ),
667
748
  );
668
749
  }