@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.
- package/CHANGELOG.md +26 -58
- package/dist/types/app-keybindings.d.ts +1 -1
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +4 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/fuzzy.d.ts +14 -0
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/model-browser.d.ts +25 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +8 -10
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +139 -21
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +364 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chrome/transcript-container.ts +7 -1
- package/src/components/editor.ts +52 -30
- package/src/components/image.ts +27 -1
- package/src/components/markdown.ts +6 -3
- package/src/fuzzy.ts +59 -19
- package/src/keybindings.ts +1 -1
- package/src/overlays/agent-transcript-viewer.ts +2 -0
- package/src/overlays/agents-hub.ts +14 -1
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/model-browser.ts +169 -76
- package/src/overlays/plan-review-overlay.ts +41 -4
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/interactive-context-helpers.ts +3 -0
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/render/composite.ts +62 -0
- package/src/status-line/segments.ts +3 -1
- package/src/tools/glob.ts +23 -6
- package/src/tui.ts +118 -105
- 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
|
-
|
|
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
|
-
//
|
|
352
|
-
return
|
|
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,
|
|
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:
|
|
634
|
+
options: ModelRankOptions,
|
|
548
635
|
): ModelBrowserItem[] {
|
|
549
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
? fuzzyRank(base, query, modelSearchText).map(result => result.item)
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
1277
|
-
if (
|
|
1278
|
-
const tps = formatTps(perf.tps);
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
|
1363
|
-
if (
|
|
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
|
|
1631
|
-
if (
|
|
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")
|
|
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
|
|
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 (
|
|
1968
|
-
speed.push(
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
|
17
|
-
*
|
|
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(
|
|
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(
|
package/src/prompt/composer.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type TUIOptions,
|
|
16
16
|
type ViewportSize,
|
|
17
17
|
} from "../tui";
|
|
18
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
92
|
-
let
|
|
93
|
-
let affinity: SearchAffinity | undefined;
|
|
89
|
+
let ranker: ModelItemRankerClass | undefined;
|
|
90
|
+
let rankerProviderOrder: readonly string[] | undefined;
|
|
94
91
|
return query => {
|
|
95
|
-
const { SessionModelScopeCache, buildSearchAffinity,
|
|
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 (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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(
|
|
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) {
|