@oh-my-pi/pi-tui 18.6.3 → 18.7.0

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 (57) hide show
  1. package/CHANGELOG.md +26 -58
  2. package/dist/types/app-keybindings.d.ts +1 -1
  3. package/dist/types/autocomplete.d.ts +5 -2
  4. package/dist/types/charts/chart-plan.d.ts +92 -0
  5. package/dist/types/charts/chart-svg.d.ts +30 -0
  6. package/dist/types/charts/table-data.d.ts +107 -0
  7. package/dist/types/chat/assistant-message.d.ts +4 -0
  8. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  9. package/dist/types/chat/figure-markdown.d.ts +42 -0
  10. package/dist/types/chat/svg-figure.d.ts +33 -0
  11. package/dist/types/chat/svg-source.d.ts +49 -0
  12. package/dist/types/chat/table-chart.d.ts +88 -0
  13. package/dist/types/components/editor.d.ts +1 -1
  14. package/dist/types/components/image.d.ts +9 -0
  15. package/dist/types/components/markdown.d.ts +9 -0
  16. package/dist/types/fuzzy.d.ts +14 -0
  17. package/dist/types/keybindings.d.ts +1 -1
  18. package/dist/types/overlays/agents-hub.d.ts +9 -0
  19. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  20. package/dist/types/overlays/model-browser.d.ts +25 -3
  21. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  22. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
  23. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  24. package/dist/types/render/composite.d.ts +2 -0
  25. package/dist/types/tools/glob.d.ts +4 -0
  26. package/dist/types/utils.d.ts +6 -0
  27. package/package.json +9 -9
  28. package/src/autocomplete.ts +8 -10
  29. package/src/charts/chart-plan.ts +367 -0
  30. package/src/charts/chart-svg.ts +693 -0
  31. package/src/charts/table-data.ts +436 -0
  32. package/src/chat/assistant-message.ts +139 -21
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/figure-markdown.ts +174 -0
  35. package/src/chat/svg-figure.ts +364 -0
  36. package/src/chat/svg-source.ts +188 -0
  37. package/src/chat/table-chart.ts +301 -0
  38. package/src/chrome/transcript-container.ts +7 -1
  39. package/src/components/editor.ts +52 -30
  40. package/src/components/image.ts +27 -1
  41. package/src/components/markdown.ts +6 -3
  42. package/src/fuzzy.ts +59 -19
  43. package/src/keybindings.ts +1 -1
  44. package/src/overlays/agent-transcript-viewer.ts +2 -0
  45. package/src/overlays/agents-hub.ts +14 -1
  46. package/src/overlays/annotation-overlay.ts +179 -80
  47. package/src/overlays/ask-dialog.ts +1 -1
  48. package/src/overlays/model-browser.ts +169 -76
  49. package/src/overlays/plan-review-overlay.ts +41 -4
  50. package/src/prompt/composer.ts +2 -14
  51. package/src/prompt/interactive-context-helpers.ts +3 -0
  52. package/src/prompt/model-mention-autocomplete.ts +14 -14
  53. package/src/render/composite.ts +62 -0
  54. package/src/status-line/segments.ts +3 -1
  55. package/src/tools/glob.ts +23 -6
  56. package/src/tui.ts +118 -105
  57. package/src/utils.ts +19 -0
@@ -1132,7 +1132,7 @@ export class AskDialogComponent implements Component {
1132
1132
  const action = question?.multi
1133
1133
  ? `${formatKeyHint("space")} toggle · ${enter} ${enterAction}`
1134
1134
  : `${enter} select · ${formatKeyHint("n")} note`;
1135
- const tabs = this.#hasSubmitTab() ? ` · ${formatKeyHints(["tab", "left", "right"])}` : "";
1135
+ const tabs = this.#hasSubmitTab() ? ` · ${formatKeyHints(["tab", "left", "right"])} question` : "";
1136
1136
  const expand = this.#expandHint();
1137
1137
  if (this.#questionCanPage && indicator) {
1138
1138
  const pageKeys = editorKeys("tui.select.pageUp", "tui.select.pageDown");
@@ -13,7 +13,7 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build";
13
13
  import { getModelPricingStatus, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
14
  import type { ModelKind, ModelPricingStatus } from "@oh-my-pi/pi-catalog/types";
15
15
  import type { Component } from "../tui";
16
- import { fuzzyRank } from "../fuzzy";
16
+ import { FuzzyCorpus, fuzzyRank } from "../fuzzy";
17
17
  import { Input } from "../components/input";
18
18
  import { ScrollView } from "../components/scroll-view";
19
19
  import { matchesKey } from "../keys";
@@ -134,6 +134,12 @@ export interface ModelBrowserSource extends ModelRoleLookup {
134
134
  readonly knownRoleIds: readonly string[];
135
135
  readonly mruOrder: readonly string[];
136
136
  readonly modelPerf: ReadonlyMap<string, ModelBrowserPerf>;
137
+ /**
138
+ * Service tier the host would send for `model`, when it runs one. Rows with a
139
+ * measured tier aggregate (`selector@tier`) then display that tier's numbers
140
+ * instead of the standard aggregate.
141
+ */
142
+ serviceTierFor?(model: Model): string | undefined;
137
143
  getRoleInfo(role: string): ModelBrowserRoleInfo;
138
144
  defaultRoleChain(role: string): string[];
139
145
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
@@ -297,13 +303,17 @@ export interface SortModelItemsOptions {
297
303
  * then per provider by priority, version, and recency.
298
304
  */
299
305
  export function sortModelItems(items: ModelBrowserItem[], options: SortModelItemsOptions = {}): void {
306
+ items.sort(modelItemComparator(options));
307
+ }
308
+
309
+ function modelItemComparator(options: SortModelItemsOptions): (a: ModelBrowserItem, b: ModelBrowserItem) => number {
300
310
  const { roles = {}, mruOrder = [], skipRoleRank = false } = options;
301
311
  const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
302
312
 
303
313
  const dateRe = /-(\d{8})$/;
304
314
  const latestRe = /-latest$/;
305
315
 
306
- items.sort((a, b) => {
316
+ return (a, b) => {
307
317
  if (!skipRoleRank) {
308
318
  const aRank = computeModelRank(a.model, roles);
309
319
  const bRank = computeModelRank(b.model, roles);
@@ -348,9 +358,9 @@ export function sortModelItems(items: ModelBrowserItem[], options: SortModelItem
348
358
  // Both have dates — descending (newest first)
349
359
  if (aDate && bDate) return bDate.localeCompare(aDate);
350
360
 
351
- // One has date, other is latest — latest first
352
- return aIsLatest ? -1 : bIsLatest ? 1 : a.id.localeCompare(b.id);
353
- });
361
+ // Both -latest — alphabetical (a fixed -1 here made the order depend on input order)
362
+ return a.id.localeCompare(b.id);
363
+ };
354
364
  }
355
365
 
356
366
  /** Picker candidates and ordering inputs shared with composer model mentions. */
@@ -530,58 +540,100 @@ function compactModelSearchText(value: string): string {
530
540
  return value.toLowerCase().replace(/[^\p{Letter}\p{Mark}\p{Number}]+/gu, "");
531
541
  }
532
542
 
533
- /** Exact id/selector → contiguous literal → fuzzy-only. */
534
- function modelSearchTier(query: string, item: ModelBrowserItem): number {
543
+ /** Exact id/selector → contiguous literal → fuzzy-only, against precomputed compact forms. */
544
+ function modelSearchTier(query: string, id: string, selector: string): number {
535
545
  if (!query) return 2;
536
- const id = compactModelSearchText(item.id);
537
- const selector = compactModelSearchText(item.selector);
538
546
  if (query === id || query === selector) return 0;
539
547
  if (id.includes(query) || selector.includes(query)) return 1;
540
548
  return 2;
541
549
  }
542
550
 
551
+ /** Inputs to {@link rankModelItems} / {@link ModelItemRanker}. */
552
+ export interface ModelRankOptions {
553
+ roles: RoleAssignments;
554
+ mruOrder: ReadonlyArray<string>;
555
+ affinity: SearchAffinity;
556
+ }
557
+
558
+ /** Query-independent sort keys of one candidate, computed once per ranker. */
559
+ interface ModelRankKeys {
560
+ compactId: string;
561
+ compactSelector: string;
562
+ modelAffinity: number;
563
+ providerAffinity: number;
564
+ /** Position class in the MRU/version order; comparator-equal items share it. */
565
+ orderGroup: number;
566
+ }
567
+
568
+ /**
569
+ * {@link rankModelItems} prepared for one candidate list: the fuzzy index, the
570
+ * MRU/version order, and the affinity ranks depend only on the items and options,
571
+ * so per-keystroke callers (composer mentions, `/switch` arguments, the picker)
572
+ * hold one ranker and pay only the per-query fuzzy scan.
573
+ */
574
+ export class ModelItemRanker {
575
+ readonly items: ReadonlyArray<ModelBrowserItem>;
576
+ readonly options: ModelRankOptions;
577
+ readonly #corpus: FuzzyCorpus<ModelBrowserItem>;
578
+ readonly #keys = new Map<ModelBrowserItem, ModelRankKeys>();
579
+
580
+ constructor(items: ReadonlyArray<ModelBrowserItem>, options: ModelRankOptions) {
581
+ this.items = items;
582
+ this.options = options;
583
+ this.#corpus = new FuzzyCorpus(items, modelSearchText);
584
+ const compare = modelItemComparator({ roles: options.roles, mruOrder: options.mruOrder, skipRoleRank: true });
585
+ const ordered = [...items].sort(compare);
586
+ let group = 0;
587
+ for (let i = 0; i < ordered.length; i++) {
588
+ const item = ordered[i]!;
589
+ if (i > 0 && compare(ordered[i - 1]!, item) !== 0) group++;
590
+ this.#keys.set(item, {
591
+ compactId: compactModelSearchText(item.id),
592
+ compactSelector: compactModelSearchText(item.selector),
593
+ modelAffinity: options.affinity.models.get(item.selector.toLowerCase()) ?? Number.MAX_SAFE_INTEGER,
594
+ providerAffinity: options.affinity.providers.get(item.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER,
595
+ orderGroup: group,
596
+ });
597
+ }
598
+ }
599
+
600
+ /** Rank by text relevance, user affinity, and MRU/version order. */
601
+ rank(query: string): ModelBrowserItem[] {
602
+ if (!query.trim()) return [...this.items];
603
+ const queryKey = compactModelSearchText(query);
604
+ // Exact and contiguous matches stay ahead of fuzzy-only candidates; affinity
605
+ // breaks ties before fuzzy quality and the normal MRU/version ordering, and
606
+ // fuzzy order settles items that ordering cannot tell apart.
607
+ const rows = this.#corpus.rank(query).map((result, fuzzyIndex) => {
608
+ const keys = this.#keys.get(result.item)!;
609
+ return {
610
+ item: result.item,
611
+ keys,
612
+ tier: modelSearchTier(queryKey, keys.compactId, keys.compactSelector),
613
+ bucket: Math.round(result.score / 10),
614
+ fuzzyIndex,
615
+ };
616
+ });
617
+ rows.sort(
618
+ (a, b) =>
619
+ a.tier - b.tier ||
620
+ a.keys.modelAffinity - b.keys.modelAffinity ||
621
+ a.keys.providerAffinity - b.keys.providerAffinity ||
622
+ a.bucket - b.bucket ||
623
+ a.keys.orderGroup - b.keys.orderGroup ||
624
+ a.fuzzyIndex - b.fuzzyIndex,
625
+ );
626
+ return rows.map(row => row.item);
627
+ }
628
+ }
629
+
543
630
  /** Rank picker and mention candidates by text relevance, user affinity, and MRU/version order. */
544
631
  export function rankModelItems(
545
632
  query: string,
546
633
  items: ReadonlyArray<ModelBrowserItem>,
547
- options: { roles: RoleAssignments; mruOrder: ReadonlyArray<string>; affinity: SearchAffinity },
634
+ options: ModelRankOptions,
548
635
  ): ModelBrowserItem[] {
549
- if (!query.trim()) return [...items];
550
- const ranked = fuzzyRank(items, query, modelSearchText);
551
- const matches = ranked.map(result => result.item);
552
- // Exact and contiguous matches stay ahead of fuzzy-only candidates; affinity
553
- // breaks ties before fuzzy quality and the normal MRU/version ordering.
554
- sortModelItems(matches, { roles: options.roles, mruOrder: options.mruOrder, skipRoleRank: true });
555
- const fallbackRanks = new Map(matches.map((item, index) => [item, index]));
556
- const queryKey = compactModelSearchText(query);
557
- const searchRanks = new Map<ModelBrowserItem, { tier: number; bucket: number }>();
558
- for (const result of ranked) {
559
- searchRanks.set(result.item, {
560
- tier: modelSearchTier(queryKey, result.item),
561
- bucket: Math.round(result.score / 10),
562
- });
563
- }
564
- matches.sort((a, b) => {
565
- const aSearch = searchRanks.get(a);
566
- const bSearch = searchRanks.get(b);
567
- const tierCmp = (aSearch?.tier ?? Number.MAX_SAFE_INTEGER) - (bSearch?.tier ?? Number.MAX_SAFE_INTEGER);
568
- if (tierCmp !== 0) return tierCmp;
569
-
570
- const modelCmp =
571
- (options.affinity.models.get(a.selector.toLowerCase()) ?? Number.MAX_SAFE_INTEGER) -
572
- (options.affinity.models.get(b.selector.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
573
- if (modelCmp !== 0) return modelCmp;
574
-
575
- const providerCmp =
576
- (options.affinity.providers.get(a.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER) -
577
- (options.affinity.providers.get(b.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
578
- if (providerCmp !== 0) return providerCmp;
579
-
580
- const bucketCmp = (aSearch?.bucket ?? Number.MAX_SAFE_INTEGER) - (bSearch?.bucket ?? Number.MAX_SAFE_INTEGER);
581
- if (bucketCmp !== 0) return bucketCmp;
582
- return (fallbackRanks.get(a) ?? Number.MAX_SAFE_INTEGER) - (fallbackRanks.get(b) ?? Number.MAX_SAFE_INTEGER);
583
- });
584
- return matches;
636
+ return new ModelItemRanker(items, options).rank(query);
585
637
  }
586
638
 
587
639
  /**
@@ -852,6 +904,8 @@ export class ModelBrowser implements Component {
852
904
  #roles: RoleAssignments = {};
853
905
  #mruOrder: ReadonlyArray<string> = [];
854
906
  #affinity: SearchAffinity = { models: new Map(), providers: new Map() };
907
+ /** Ranker for the menu's current item array; rebuilt when items or affinity change. */
908
+ #ranker?: { source: readonly ModelBrowserItem[]; ranker: ModelItemRanker };
855
909
  #perf: ReadonlyMap<string, ModelBrowserPerf> = new Map();
856
910
  #hoveredIndex: number | null = null;
857
911
  #maxVisible = 10;
@@ -1039,17 +1093,26 @@ export class ModelBrowser implements Component {
1039
1093
  * unfiltered when the query is blank.
1040
1094
  */
1041
1095
  #filterItems(items: readonly ModelBrowserItem[], query: string): readonly ModelBrowserItem[] {
1042
- const base = items.filter(item => !this.#isDisabled(item));
1043
- const ranked = this.#preserveQueryOrder
1044
- ? query.trim()
1045
- ? fuzzyRank(base, query, modelSearchText).map(result => result.item)
1046
- : base
1047
- : rankModelItems(query, base, {
1048
- roles: this.#roles,
1049
- mruOrder: this.#mruOrder,
1050
- affinity: this.#affinity,
1051
- });
1052
- return this.#insertSeparator(ranked);
1096
+ if (this.#preserveQueryOrder) {
1097
+ const base = items.filter(item => !this.#isDisabled(item));
1098
+ return this.#insertSeparator(
1099
+ query.trim() ? fuzzyRank(base, query, modelSearchText).map(result => result.item) : base,
1100
+ );
1101
+ }
1102
+ // The menu hands back the same array until `setItems`, and roles/MRU replace the
1103
+ // affinity object, so one ranker serves every keystroke of a search.
1104
+ let cached = this.#ranker;
1105
+ if (cached?.source !== items || cached.ranker.options.affinity !== this.#affinity) {
1106
+ cached = {
1107
+ source: items,
1108
+ ranker: new ModelItemRanker(
1109
+ items.filter(item => !this.#isDisabled(item)),
1110
+ { roles: this.#roles, mruOrder: this.#mruOrder, affinity: this.#affinity },
1111
+ ),
1112
+ };
1113
+ this.#ranker = cached;
1114
+ }
1115
+ return this.#insertSeparator(cached.ranker.rank(query));
1053
1116
  }
1054
1117
 
1055
1118
  /** True when `item`'s context window is smaller than the live session token count (grayed row; hosts compact before switching). */
@@ -1270,14 +1333,40 @@ export class ModelBrowser implements Component {
1270
1333
  return index;
1271
1334
  }
1272
1335
 
1336
+ /**
1337
+ * Measured perf for a row: the tier the host would send, else the standard
1338
+ * aggregate, else any measured tier (fastest first) so a model only ever run
1339
+ * on a non-default tier still shows its real speed. `tier` is set whenever
1340
+ * the numbers come from a tier aggregate, for the caller to label.
1341
+ */
1342
+ #perfFor(item: ModelBrowserItem): { perf: ModelBrowserPerf; tier?: string } | undefined {
1343
+ const tier = this.#settings.serviceTierFor?.(item.model);
1344
+ if (tier) {
1345
+ const tiered = this.#perf.get(`${item.selector}@${tier}`);
1346
+ if (tiered) return { perf: tiered, tier };
1347
+ }
1348
+ const standard = this.#perf.get(item.selector);
1349
+ if (standard) return { perf: standard };
1350
+ const prefix = `${item.selector}@`;
1351
+ let best: { perf: ModelBrowserPerf; tier: string } | undefined;
1352
+ for (const [key, perf] of this.#perf) {
1353
+ if (!key.startsWith(prefix)) continue;
1354
+ if (!best || perf.tps > best.perf.tps) best = { perf, tier: key.slice(prefix.length) };
1355
+ }
1356
+ return best;
1357
+ }
1358
+
1273
1359
  /** Measured TPS/TTFT, falling back to the catalog TPS as an estimated `~118t/s`. */
1274
1360
  #perfCell(item: ModelBrowserItem, mode: PerfMode): string {
1275
1361
  if (mode === "off") return "";
1276
- const perf = this.#perf.get(item.selector);
1277
- if (perf) {
1278
- const tps = formatTps(perf.tps);
1279
- if (mode === "full" && perf.ttftMs !== null) return `${formatTtft(perf.ttftMs)} ${tps}`;
1280
- return tps;
1362
+ const measured = this.#perfFor(item);
1363
+ if (measured) {
1364
+ const tps = formatTps(measured.perf.tps);
1365
+ const tier = measured.tier ? ` ${measured.tier}` : "";
1366
+ if (mode === "full" && measured.perf.ttftMs !== null) {
1367
+ return `${formatTtft(measured.perf.ttftMs)} ${tps}${tier}`;
1368
+ }
1369
+ return `${tps}${tier}`;
1281
1370
  }
1282
1371
  const tps = item.model.tps;
1283
1372
  return tps != null && Number.isFinite(tps) && tps > 0 ? `~${formatTps(tps)}` : "";
@@ -1359,10 +1448,10 @@ export class ModelBrowser implements Component {
1359
1448
  if (model.input.includes("image")) facts.push("vision");
1360
1449
  const intelligence = formatIntelligence(model);
1361
1450
  if (intelligence) facts.push(intelligence);
1362
- const perf = this.#perf.get(selected.selector);
1363
- if (perf) {
1364
- facts.push(`~${formatTps(perf.tps)}`);
1365
- if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`);
1451
+ const measured = this.#perfFor(selected);
1452
+ if (measured) {
1453
+ facts.push(`~${formatTps(measured.perf.tps)}${measured.tier ? ` ${measured.tier}` : ""}`);
1454
+ if (measured.perf.ttftMs !== null) facts.push(`${formatTtft(measured.perf.ttftMs)} ttft`);
1366
1455
  } else if (model.tps != null && Number.isFinite(model.tps) && model.tps > 0) {
1367
1456
  facts.push(`~${formatTps(model.tps)}`);
1368
1457
  }
@@ -1627,10 +1716,10 @@ export class ModelBrowser implements Component {
1627
1716
  if (model.input.includes("image")) facts.push("vision");
1628
1717
  const intelligence = formatIntelligence(model);
1629
1718
  if (intelligence) facts.push(intelligence);
1630
- const perf = this.#perf.get(selected.selector);
1631
- if (perf) {
1632
- facts.push(`~${formatTps(perf.tps)}`);
1633
- if (perf.ttftMs !== null) facts.push(`${formatTtft(perf.ttftMs)} ttft`);
1719
+ const measured = this.#perfFor(selected);
1720
+ if (measured) {
1721
+ facts.push(`~${formatTps(measured.perf.tps)}${measured.tier ? ` ${measured.tier}` : ""}`);
1722
+ if (measured.perf.ttftMs !== null) facts.push(`${formatTtft(measured.perf.ttftMs)} ttft`);
1634
1723
  } else if (model.tps != null && Number.isFinite(model.tps) && model.tps > 0) {
1635
1724
  facts.push(`~${formatTps(model.tps)}`);
1636
1725
  }
@@ -1742,7 +1831,9 @@ export class ModelBrowser implements Component {
1742
1831
  const facts: Record<string, TspText | number> = {};
1743
1832
  const int = model.int != null && Number.isFinite(model.int) ? model.int : undefined;
1744
1833
  if (int !== undefined) facts.int = String(Math.round(int));
1745
- const speed = this.#perfCell(item, "tps").replace(/t\/s$/, "");
1834
+ const speed = this.#perfCell(item, "tps")
1835
+ .replace(/\s*t\/s\s*/, " ")
1836
+ .trim();
1746
1837
  if (speed) facts.speed = speed;
1747
1838
  if (model.contextWindow) facts.ctx = model.contextWindow;
1748
1839
  facts.price = pickerPrice(model);
@@ -1904,7 +1995,7 @@ export class ModelBrowser implements Component {
1904
1995
  modelPreview(item: ModelBrowserItem, mode: "full" | "compact", current: string | undefined): NativeChild[] {
1905
1996
  const model = item.model;
1906
1997
  const selector = `${model.provider}/${model.id}`;
1907
- const perf = this.#perf.get(selector);
1998
+ const measured = this.#perfFor(item);
1908
1999
  const ctx = model.contextWindow ?? 0;
1909
2000
  const out = model.maxTokens ?? 0;
1910
2001
  const overContext = this.isOverContext(item);
@@ -1964,10 +2055,12 @@ export class ModelBrowser implements Component {
1964
2055
  if (badges.length > 0) children.push(row(badges, { gap: "xs", wrap: true }));
1965
2056
 
1966
2057
  const speed: string[] = [];
1967
- if (perf) {
1968
- speed.push(`${formatTps(perf.tps).replace("t/s", " t/s")}`);
1969
- if (perf.ttftMs !== null) speed.push(`${formatTtft(perf.ttftMs).replace("s", " s")} TTFT`);
1970
- speed.push(`${perf.samples} ${perf.samples === 1 ? "sample" : "samples"}`);
2058
+ if (measured) {
2059
+ speed.push(
2060
+ `${formatTps(measured.perf.tps).replace("t/s", " t/s")}${measured.tier ? ` ${measured.tier}` : ""}`,
2061
+ );
2062
+ if (measured.perf.ttftMs !== null) speed.push(`${formatTtft(measured.perf.ttftMs).replace("s", " s")} TTFT`);
2063
+ speed.push(`${measured.perf.samples} ${measured.perf.samples === 1 ? "sample" : "samples"}`);
1971
2064
  } else if (model.tps != null && Number.isFinite(model.tps) && model.tps > 0) {
1972
2065
  speed.push(`~${formatTps(model.tps).replace("t/s", " t/s")} (catalog)`);
1973
2066
  }
@@ -13,8 +13,9 @@
13
13
  * Focus regions (`toc`/`body`/`actions`) cycle with Tab/Shift+Tab; arrows move
14
14
  * within the focused region and step left into the sidebar. The default focus is
15
15
  * `actions`, so the muscle memory of the old single-target overlay carries over:
16
- * ↑/↓ select options, Enter confirms, ←/→ drives the slider when there is no
17
- * sidebar, g/G + PgUp/PgDn scroll, and the external-editor key opens the plan.
16
+ * ↑/↓ select options, Enter confirms, ←/→ drives the slider, g/G + PgUp/PgDn
17
+ * scroll, and the external-editor key opens the plan. Under Tern the options
18
+ * are a horizontal bar, so ←/→ select options and Shift+←/→ drive the slider.
18
19
  */
19
20
  import {
20
21
  type Component,
@@ -58,6 +59,7 @@ import type { KeyName } from "../key-hint-format";
58
59
  import { col, item, keyed, md, node, row as rowNode, span, text } from "../native/describe";
59
60
  import { leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
60
61
  import { actionButton, actionHint, hintsRow, itemIndex, type NativeHint, selectList } from "../native/overlay";
62
+ import { isNativeRendering } from "../native/state";
61
63
  import { getKeybindings } from "../keybindings";
62
64
 
63
65
  /** Title shown in the overlay's top border. */
@@ -666,6 +668,10 @@ export class PlanReviewOverlay implements Component {
666
668
  }
667
669
 
668
670
  #handleActions(data: string): void {
671
+ if (isNativeRendering()) {
672
+ this.#handleNativeActions(data);
673
+ return;
674
+ }
669
675
  // Left/right always drive the slider. The sidebar sits beside the body
670
676
  // (above this row), not the slider, so stealing left for it would strand
671
677
  // the operator unable to step the model tier back — reach the ToC via Tab.
@@ -695,6 +701,37 @@ export class PlanReviewOverlay implements Component {
695
701
  this.#handleBodyScroll(data);
696
702
  }
697
703
 
704
+ /** Tern lays the options out as a horizontal decision bar: ←/→ step through
705
+ * it, ↑ returns to the body above, and the model slider moves to Shift+←/→. */
706
+ #handleNativeActions(data: string): void {
707
+ if (matchesKey(data, "shift+left")) {
708
+ this.#moveSlider(-1);
709
+ return;
710
+ }
711
+ if (matchesKey(data, "shift+right")) {
712
+ this.#moveSlider(1);
713
+ return;
714
+ }
715
+ if (matchesKey(data, "left") || matchesKey(data, "h")) {
716
+ this.#moveSelection(-1);
717
+ return;
718
+ }
719
+ if (matchesKey(data, "right") || matchesKey(data, "l")) {
720
+ this.#moveSelection(1);
721
+ return;
722
+ }
723
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
724
+ this.#setFocus("body");
725
+ return;
726
+ }
727
+ if (matchesSelectDown(data) || matchesKey(data, "j")) return;
728
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
729
+ this.#confirmSelection();
730
+ return;
731
+ }
732
+ this.#handleBodyScroll(data);
733
+ }
734
+
698
735
  #handleBody(data: string): void {
699
736
  if (data === "a") {
700
737
  this.#startBodyAnnotate();
@@ -1686,8 +1723,8 @@ export class PlanReviewOverlay implements Component {
1686
1723
  const hints: (NativeHint | undefined)[] = [];
1687
1724
  switch (this.#focus) {
1688
1725
  case "actions":
1689
- hints.push(upDown("select"), key("enter", "confirm"));
1690
- if (this.#slider) hints.push(key(["left", "right"], "model"));
1726
+ hints.push(key(["left", "right"], "select"), key("enter", "confirm"));
1727
+ if (this.#slider) hints.push(key(["shift+left", "shift+right"], "model"));
1691
1728
  break;
1692
1729
  case "toc":
1693
1730
  hints.push(
@@ -15,7 +15,7 @@ import {
15
15
  type TUIOptions,
16
16
  type ViewportSize,
17
17
  } from "../tui";
18
- import { sliceWithWidth, visibleWidth } from "../utils";
18
+ import { wrapLiteralLine } from "../utils";
19
19
  import type { NativeChild, NativeSurface, NativeSurfaceProvider } from "../native/node";
20
20
  import { sameItems } from "../native/memo";
21
21
  import { postmortem } from "@oh-my-pi/pi-utils";
@@ -803,20 +803,8 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
803
803
  if (!lines) return [];
804
804
  if (isInsideTerminalMultiplexer()) return lines.slice(start);
805
805
  const reflowed: string[] = [];
806
- const columns = Math.max(1, width);
807
806
  for (let index = start; index < lines.length; index++) {
808
- const line = lines[index]!;
809
- const lineWidth = visibleWidth(line);
810
- if (lineWidth === 0) {
811
- reflowed.push("");
812
- continue;
813
- }
814
- for (let column = 0; column < lineWidth;) {
815
- let slice = sliceWithWidth(line, column, columns, true);
816
- if (slice.width === 0) slice = sliceWithWidth(line, column, columns);
817
- reflowed.push(slice.text);
818
- column += Math.max(1, slice.width);
819
- }
807
+ reflowed.push(...wrapLiteralLine(lines[index]!, width));
820
808
  }
821
809
  return reflowed;
822
810
  }
@@ -18,6 +18,8 @@ export interface AssistantMessageHost {
18
18
  readonly proseOnlyThinking: boolean;
19
19
  readonly expandThinkingBlocks: boolean;
20
20
  readonly assistantImagesVisible: boolean;
21
+ /** Whether the viewed session's tables get charts: the main session's do, a focused subagent's do not. */
22
+ readonly tableChartsVisible: boolean;
21
23
  readonly hideToolActivity: boolean;
22
24
  readonly toolOutputExpanded: boolean;
23
25
  readonly ui: { requestRender(): void; readonly imageBudget: ImageBudget };
@@ -116,6 +118,7 @@ export function createAssistantMessageComponent(
116
118
  ctx.expandThinkingBlocks,
117
119
  );
118
120
  component.setImagesVisible(ctx.assistantImagesVisible);
121
+ component.setTableChartsVisible(ctx.tableChartsVisible);
119
122
  component.setToolResultImagesVisible(!ctx.hideToolActivity);
120
123
  component.setExpanded(ctx.toolOutputExpanded);
121
124
  // A wire the `stream-revision` axis marks `possible` can rewrite text it has
@@ -5,9 +5,7 @@ import { modelMentionDisplayName } from "./model-mention-syntax";
5
5
  import type {
6
6
  buildSearchAffinity as BuildSearchAffinity,
7
7
  ModelBrowserItem,
8
- rankModelItems as RankModelItems,
9
- SearchAffinity,
10
- SessionModelScope,
8
+ ModelItemRanker as ModelItemRankerClass,
11
9
  SessionModelScopeCache as SessionModelScopeCacheClass,
12
10
  } from "../overlays/model-browser";
13
11
  import { theme } from "../theme/theme";
@@ -21,7 +19,7 @@ export type ModelMentionCandidateSource = (query: string) => ReadonlyArray<Model
21
19
  interface ModelBrowserModules {
22
20
  SessionModelScopeCache: typeof SessionModelScopeCacheClass;
23
21
  buildSearchAffinity: typeof BuildSearchAffinity;
24
- rankModelItems: typeof RankModelItems;
22
+ ModelItemRanker: typeof ModelItemRankerClass;
25
23
  }
26
24
 
27
25
  /** Synchronous first-use boundary for the interactive model browser implementation. */
@@ -80,7 +78,7 @@ export function applyModelMentionCompletion(
80
78
 
81
79
  /**
82
80
  * Create a session-scoped model candidate lookup using picker ordering. The
83
- * scope and search affinity are reused across queries until their inputs change.
81
+ * scope and its prepared ranker are reused across queries until their inputs change.
84
82
  */
85
83
  export function createModelMentionSource(host: {
86
84
  source: ModelBrowserSource;
@@ -88,20 +86,22 @@ export function createModelMentionSource(host: {
88
86
  scopedModels: () => ReadonlyArray<Model>;
89
87
  }): ModelMentionCandidateSource {
90
88
  let scopeCache: SessionModelScopeCacheClass | undefined;
91
- let affinityScope: SessionModelScope | undefined;
92
- let affinityProviderOrder: readonly string[] | undefined;
93
- let affinity: SearchAffinity | undefined;
89
+ let ranker: ModelItemRankerClass | undefined;
90
+ let rankerProviderOrder: readonly string[] | undefined;
94
91
  return query => {
95
- const { SessionModelScopeCache, buildSearchAffinity, rankModelItems } = loadModelBrowser();
92
+ const { SessionModelScopeCache, buildSearchAffinity, ModelItemRanker } = loadModelBrowser();
96
93
  scopeCache ??= new SessionModelScopeCache(host.source, host.registry);
97
94
  const scope = scopeCache.get(host.scopedModels());
98
95
  if (!query.trim()) return scope.items;
99
96
  const providerOrder = host.source.modelProviderOrder;
100
- if (!affinity || affinityScope !== scope || affinityProviderOrder !== providerOrder) {
101
- affinity = buildSearchAffinity(providerOrder, scope.roles, scope.mruOrder);
102
- affinityScope = scope;
103
- affinityProviderOrder = providerOrder;
97
+ if (ranker?.items !== scope.items || rankerProviderOrder !== providerOrder) {
98
+ ranker = new ModelItemRanker(scope.items, {
99
+ roles: scope.roles,
100
+ mruOrder: scope.mruOrder,
101
+ affinity: buildSearchAffinity(providerOrder, scope.roles, scope.mruOrder),
102
+ });
103
+ rankerProviderOrder = providerOrder;
104
104
  }
105
- return rankModelItems(query, scope.items, { roles: scope.roles, mruOrder: scope.mruOrder, affinity });
105
+ return ranker.rank(query);
106
106
  };
107
107
  }
@@ -0,0 +1,62 @@
1
+ import { TERMINAL } from "../terminal-capabilities";
2
+ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "../utils";
3
+
4
+ const SEGMENT_RESET = "\x1b[0m";
5
+
6
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
7
+ export function compositeLineAt(
8
+ baseLine: string,
9
+ overlayLine: string,
10
+ startCol: number,
11
+ overlayWidth: number,
12
+ totalWidth: number,
13
+ ): string {
14
+ if (TERMINAL.isImageLine(baseLine)) {
15
+ // Full-width overlays such as /switch are opaque: replace the
16
+ // Unicode placeholder cells so the image cannot cover the modal.
17
+ // Partial overlays cannot safely splice placement control sequences.
18
+ if (startCol !== 0 || overlayWidth < totalWidth) return baseLine;
19
+ const overlay = sliceWithWidth(overlayLine, 0, totalWidth, true);
20
+ return SEGMENT_RESET + overlay.text + " ".repeat(Math.max(0, totalWidth - overlay.width));
21
+ }
22
+
23
+ // Single pass through baseLine extracts both before and after segments
24
+ const afterStart = startCol + overlayWidth;
25
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
26
+
27
+ // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
28
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
29
+
30
+ // Pad segments to target widths
31
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
32
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
33
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
34
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
35
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
36
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
37
+
38
+ // Compose result
39
+ const r = SEGMENT_RESET;
40
+ const result =
41
+ base.before +
42
+ " ".repeat(beforePad) +
43
+ r +
44
+ overlay.text +
45
+ " ".repeat(overlayPad) +
46
+ r +
47
+ base.after +
48
+ " ".repeat(afterPad);
49
+
50
+ // CRITICAL: Always verify and truncate to terminal width.
51
+ // This is the final safeguard against width overflow which would crash the TUI.
52
+ // Width tracking can drift from actual visible width due to:
53
+ // - Complex ANSI/OSC sequences (hyperlinks, colors)
54
+ // - Wide characters at segment boundaries
55
+ // - Edge cases in segment extraction
56
+ const resultWidth = visibleWidth(result);
57
+ if (resultWidth <= totalWidth) {
58
+ return result;
59
+ }
60
+ // Truncate with strict=true to ensure we don't exceed totalWidth
61
+ return sliceByColumn(result, 0, totalWidth, true);
62
+ }
@@ -183,7 +183,9 @@ function getProjectDirDisplay(projectDir: string): ProjectDirDisplay {
183
183
  }
184
184
  }
185
185
  if (!scratch) {
186
- displayRoots ??= [path.join(homeDir, "Projects"), "/work"].map(normalizePathForComparison);
186
+ displayRoots ??= [path.join(homeDir, "Projects"), path.join(homeDir, "repos"), "/work"].map(
187
+ normalizePathForComparison,
188
+ );
187
189
  for (const root of displayRoots) {
188
190
  const relative = relativePathWithinNormalizedRoot(root, normalizedProjectDir);
189
191
  if (relative) {