@oh-my-pi/pi-tui 18.2.6 → 18.2.7
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 +19 -0
- package/THIRD-PARTY-NOTICES.txt +0 -37
- package/dist/types/glyph-protocol.d.ts +62 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/overlays/model-browser.d.ts +9 -3
- package/dist/types/prompt/gradient-highlight.d.ts +2 -2
- package/dist/types/prompt/magic-keywords.d.ts +37 -10
- package/dist/types/setup/scenes/glyph.d.ts +6 -1
- package/dist/types/terminal-capabilities.d.ts +8 -0
- package/dist/types/terminal.d.ts +16 -0
- package/dist/types/theme/mermaid-cache.d.ts +2 -2
- package/dist/types/tools/eval.d.ts +5 -4
- package/dist/types/tools/find.d.ts +82 -0
- package/dist/types/tools/web-search.d.ts +4 -0
- package/package.json +9 -9
- package/src/glyph-protocol.ts +143 -0
- package/src/index.ts +2 -0
- package/src/overlays/model-browser.ts +47 -6
- package/src/overlays/model-hub.ts +122 -33
- package/src/prompt/gradient-highlight.ts +3 -3
- package/src/prompt/macos-spelling.ts +7 -1
- package/src/prompt/magic-keywords.ts +103 -21
- package/src/prompt/markdown-prose.ts +2 -2
- package/src/prompt/tips.txt +1 -0
- package/src/setup/scenes/glyph.ts +8 -1
- package/src/terminal-capabilities.ts +12 -0
- package/src/terminal.ts +151 -2
- package/src/theme/glyph-bundle.json +797 -0
- package/src/theme/mermaid-cache.ts +5 -4
- package/src/tools/eval.ts +14 -7
- package/src/tools/find.ts +255 -0
- package/src/tools/hub.ts +2 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/web-search.ts +5 -0
- package/src/tui.ts +7 -0
- package/dist/types/prompt/magic-keyword-boundary.d.ts +0 -9
- package/dist/types/prompt/orchestrate.d.ts +0 -13
- package/dist/types/prompt/ultrathink.d.ts +0 -13
- package/dist/types/prompt/workflow.d.ts +0 -13
- package/src/prompt/magic-keyword-boundary.ts +0 -23
- package/src/prompt/orchestrate.ts +0 -26
- package/src/prompt/ultrathink.ts +0 -26
- package/src/prompt/workflow.ts +0 -26
|
@@ -11,6 +11,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
|
11
11
|
import type { Model } from "@oh-my-pi/pi-ai";
|
|
12
12
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
13
13
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
14
|
+
import type { ModelKind } from "@oh-my-pi/pi-catalog/types";
|
|
14
15
|
import type { Component } from "../tui";
|
|
15
16
|
import { fuzzyRank } from "../fuzzy";
|
|
16
17
|
import { Input } from "../components/input";
|
|
@@ -33,7 +34,22 @@ import { MenuSelection } from "../components/menu-selection";
|
|
|
33
34
|
import { clampScrollOffset, scrollOffsetForRow } from "../components/scroll-viewport";
|
|
34
35
|
|
|
35
36
|
/** Canonical display ordering of built-in model roles. */
|
|
36
|
-
export type ModelRole =
|
|
37
|
+
export type ModelRole =
|
|
38
|
+
| "default"
|
|
39
|
+
| "smol"
|
|
40
|
+
| "slow"
|
|
41
|
+
| "vision"
|
|
42
|
+
| "plan"
|
|
43
|
+
| "commit"
|
|
44
|
+
| "tiny"
|
|
45
|
+
| "memory"
|
|
46
|
+
| "task"
|
|
47
|
+
| "advisor"
|
|
48
|
+
| "image"
|
|
49
|
+
| "web"
|
|
50
|
+
| "speech"
|
|
51
|
+
| "dictation"
|
|
52
|
+
| "judge";
|
|
37
53
|
export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
38
54
|
"default",
|
|
39
55
|
"smol",
|
|
@@ -42,9 +58,28 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
|
42
58
|
"plan",
|
|
43
59
|
"commit",
|
|
44
60
|
"tiny",
|
|
61
|
+
"memory",
|
|
45
62
|
"task",
|
|
46
63
|
"advisor",
|
|
64
|
+
"image",
|
|
65
|
+
"web",
|
|
66
|
+
"speech",
|
|
67
|
+
"dictation",
|
|
68
|
+
"judge",
|
|
47
69
|
];
|
|
70
|
+
export const CHAT_MODEL_ROLE_IDS: ModelRole[] = [
|
|
71
|
+
"default",
|
|
72
|
+
"smol",
|
|
73
|
+
"slow",
|
|
74
|
+
"vision",
|
|
75
|
+
"plan",
|
|
76
|
+
"commit",
|
|
77
|
+
"tiny",
|
|
78
|
+
"memory",
|
|
79
|
+
"task",
|
|
80
|
+
"advisor",
|
|
81
|
+
];
|
|
82
|
+
export const KIND_ROLE_IDS: ModelRole[] = ["image", "web", "speech", "dictation", "judge"];
|
|
48
83
|
|
|
49
84
|
/** Measured model performance shown in browser rows. */
|
|
50
85
|
export interface ModelBrowserPerf {
|
|
@@ -59,6 +94,8 @@ export interface ModelBrowserRoleInfo {
|
|
|
59
94
|
name: string;
|
|
60
95
|
color?: ThemeColor;
|
|
61
96
|
hidden?: boolean;
|
|
97
|
+
section: "chat" | "kind";
|
|
98
|
+
accepts(model: Model): boolean;
|
|
62
99
|
}
|
|
63
100
|
|
|
64
101
|
/** Role lookup used for scoped model resolution. */
|
|
@@ -82,14 +119,15 @@ export interface ModelBrowserSource extends ModelRoleLookup {
|
|
|
82
119
|
readonly mruOrder: readonly string[];
|
|
83
120
|
readonly modelPerf: ReadonlyMap<string, ModelBrowserPerf>;
|
|
84
121
|
getRoleInfo(role: string): ModelBrowserRoleInfo;
|
|
122
|
+
defaultRoleChain(role: string): string[];
|
|
85
123
|
resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
|
|
86
124
|
}
|
|
87
125
|
|
|
88
126
|
/** Read-only catalog surface consumed by model browsers. */
|
|
89
127
|
export interface ModelBrowserRegistry {
|
|
90
128
|
getError(): unknown;
|
|
91
|
-
getAvailable(): Model[];
|
|
92
|
-
getAll(): Model[];
|
|
129
|
+
getAvailable(kind?: ModelKind | "all"): Model[];
|
|
130
|
+
getAll(kind?: ModelKind | "all"): Model[];
|
|
93
131
|
}
|
|
94
132
|
|
|
95
133
|
/** One selectable row. `selector` is a canonical model key or host-specific virtual key. */
|
|
@@ -146,7 +184,7 @@ export function resolveRoleAssignments(
|
|
|
146
184
|
const roleValue = settings.getModelRole(role);
|
|
147
185
|
if (!roleValue) continue;
|
|
148
186
|
configuredRoles.add(role);
|
|
149
|
-
const resolved = settings.resolveRoleValue(roleValue, catalog);
|
|
187
|
+
const resolved = settings.resolveRoleValue(roleValue, catalog.filter(settings.getRoleInfo(role).accepts));
|
|
150
188
|
if (resolved.model) {
|
|
151
189
|
roles[role] = {
|
|
152
190
|
model: resolved.model,
|
|
@@ -160,7 +198,10 @@ export function resolveRoleAssignments(
|
|
|
160
198
|
const candidates = [...autoCandidates];
|
|
161
199
|
for (const role of knownRoles) {
|
|
162
200
|
if (configuredRoles.has(role)) continue;
|
|
163
|
-
const resolved = settings.resolveRoleValue(
|
|
201
|
+
const resolved = settings.resolveRoleValue(
|
|
202
|
+
`pi/${role}`,
|
|
203
|
+
candidates.filter(settings.getRoleInfo(role).accepts),
|
|
204
|
+
);
|
|
164
205
|
if (!resolved.model) continue;
|
|
165
206
|
roles[role] = {
|
|
166
207
|
model: resolved.model,
|
|
@@ -311,7 +352,7 @@ export function buildSessionModelScope(
|
|
|
311
352
|
models = [];
|
|
312
353
|
}
|
|
313
354
|
}
|
|
314
|
-
const allModels = scopedModels.length > 0 ? models : registry.getAll();
|
|
355
|
+
const allModels = scopedModels.length > 0 ? models : registry.getAll("all");
|
|
315
356
|
const roles = resolveRoleAssignments(settings, allModels, models);
|
|
316
357
|
const mruOrder = settings.mruOrder;
|
|
317
358
|
const items = buildBrowserItems(models);
|
|
@@ -15,6 +15,7 @@ import type { Model } from "@oh-my-pi/pi-ai";
|
|
|
15
15
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
16
16
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
17
17
|
import { providerEntry } from "@oh-my-pi/pi-catalog/compat/providers";
|
|
18
|
+
import { MODEL_KINDS, modelKind, type ModelKind } from "@oh-my-pi/pi-catalog/types";
|
|
18
19
|
import type { Component, TUI } from "../tui";
|
|
19
20
|
import { extractPrintableText, matchesKey } from "../keys";
|
|
20
21
|
import { fuzzyFilter } from "../fuzzy";
|
|
@@ -187,6 +188,9 @@ type StripState =
|
|
|
187
188
|
|
|
188
189
|
const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
|
|
189
190
|
const RECENT_LIMIT = 15;
|
|
191
|
+
const MODEL_KIND_TABS: ReadonlyArray<"all" | ModelKind> = ["all", ...MODEL_KINDS];
|
|
192
|
+
const ROLE_TABS = ["all", "chat", "kind"] as const;
|
|
193
|
+
type RoleTab = (typeof ROLE_TABS)[number];
|
|
190
194
|
|
|
191
195
|
/**
|
|
192
196
|
* Providers already auto-refreshed this process. Selecting a provider fetches
|
|
@@ -215,6 +219,9 @@ export class ModelHubComponent implements Component {
|
|
|
215
219
|
#roles: RoleAssignments = {};
|
|
216
220
|
#availableItems: ModelBrowserItem[] = [];
|
|
217
221
|
#recentItems: ModelBrowserItem[] = [];
|
|
222
|
+
#candidateItems: ModelBrowserItem[] = [];
|
|
223
|
+
#modelKindTab: "all" | ModelKind = "all";
|
|
224
|
+
#roleTab: RoleTab = "all";
|
|
218
225
|
#configError: string | undefined;
|
|
219
226
|
|
|
220
227
|
#entries: SidebarEntry[] = [];
|
|
@@ -268,7 +275,8 @@ export class ModelHubComponent implements Component {
|
|
|
268
275
|
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
|
|
269
276
|
lines.push(...this.#renderLockedView(entry, width, rows - 1));
|
|
270
277
|
} else {
|
|
271
|
-
this.#
|
|
278
|
+
lines.push(this.#renderModelKindTabs(width));
|
|
279
|
+
this.#browser.setMaxVisible(rows - 2 - 5);
|
|
272
280
|
this.#browser.setFocused(this.#focus === "list");
|
|
273
281
|
lines.push(...this.#browser.render(width));
|
|
274
282
|
}
|
|
@@ -360,7 +368,7 @@ export class ModelHubComponent implements Component {
|
|
|
360
368
|
|
|
361
369
|
/** Resolve every known role: configured values first, auto-selection for the rest. */
|
|
362
370
|
#reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
|
|
363
|
-
const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll();
|
|
371
|
+
const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll("all");
|
|
364
372
|
this.#roles = resolveRoleAssignments(this.#settings, allModels, autoCandidates);
|
|
365
373
|
}
|
|
366
374
|
|
|
@@ -379,9 +387,9 @@ export class ModelHubComponent implements Component {
|
|
|
379
387
|
} else {
|
|
380
388
|
const loadError = this.#registry.getError();
|
|
381
389
|
this.#configError = loadError ? String(loadError) : undefined;
|
|
382
|
-
allModels = this.#registry.getAll();
|
|
390
|
+
allModels = this.#registry.getAll("all");
|
|
383
391
|
try {
|
|
384
|
-
availableModels = this.#registry.getAvailable();
|
|
392
|
+
availableModels = this.#registry.getAvailable("all");
|
|
385
393
|
} catch (error) {
|
|
386
394
|
this.#configError = error instanceof Error ? error.message : String(error);
|
|
387
395
|
availableModels = [];
|
|
@@ -602,18 +610,18 @@ export class ModelHubComponent implements Component {
|
|
|
602
610
|
switch (entry.kind) {
|
|
603
611
|
case "recent":
|
|
604
612
|
this.#browser.setShowProvider(true);
|
|
605
|
-
this.#
|
|
613
|
+
this.#setCandidateItems(this.#recentItems);
|
|
606
614
|
break;
|
|
607
615
|
case "provider": {
|
|
608
616
|
if (entry.locked) {
|
|
609
617
|
// Assign-mode renders the browser regardless of scope; a locked
|
|
610
618
|
// provider contributes nothing selectable.
|
|
611
|
-
this.#
|
|
619
|
+
this.#setCandidateItems([]);
|
|
612
620
|
break;
|
|
613
621
|
}
|
|
614
622
|
const providerId = entry.providerId;
|
|
615
623
|
this.#browser.setShowProvider(false);
|
|
616
|
-
this.#
|
|
624
|
+
this.#setCandidateItems(this.#availableItems.filter(item => item.provider === providerId));
|
|
617
625
|
break;
|
|
618
626
|
}
|
|
619
627
|
case "roles":
|
|
@@ -621,11 +629,25 @@ export class ModelHubComponent implements Component {
|
|
|
621
629
|
break;
|
|
622
630
|
default:
|
|
623
631
|
this.#browser.setShowProvider(true);
|
|
624
|
-
this.#
|
|
632
|
+
this.#setCandidateItems(this.#availableItems);
|
|
625
633
|
break;
|
|
626
634
|
}
|
|
627
635
|
}
|
|
628
636
|
|
|
637
|
+
#setCandidateItems(items: ReadonlyArray<ModelBrowserItem>): void {
|
|
638
|
+
this.#candidateItems = [...items];
|
|
639
|
+
this.#applyModelKind();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#applyModelKind(): void {
|
|
643
|
+
const kind = this.#modelKindTab;
|
|
644
|
+
this.#browser.setItems(
|
|
645
|
+
kind === "all"
|
|
646
|
+
? [...this.#candidateItems]
|
|
647
|
+
: this.#candidateItems.filter(item => modelKind(item.model) === kind),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
629
651
|
/**
|
|
630
652
|
* The configured `retry.fallbackChains` record with malformed keys/entries
|
|
631
653
|
* dropped: non-array chains and non-string selectors never reach the rows
|
|
@@ -655,12 +677,24 @@ export class ModelHubComponent implements Component {
|
|
|
655
677
|
#buildRolesRows(): void {
|
|
656
678
|
const rows: RolesRow[] = [];
|
|
657
679
|
const chains = this.#fallbackChains();
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
680
|
+
const appendRoles = (roles: ReadonlyArray<string>): void => {
|
|
681
|
+
for (const role of roles) {
|
|
682
|
+
rows.push({ kind: "role", role });
|
|
683
|
+
const chain = chains[role] ?? [];
|
|
684
|
+
for (let i = 0; i < chain.length; i++) {
|
|
685
|
+
rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] });
|
|
686
|
+
}
|
|
663
687
|
}
|
|
688
|
+
};
|
|
689
|
+
const visibleRoles = this.#visibleRoleIds();
|
|
690
|
+
if (this.#roleTab === "all") {
|
|
691
|
+
const chatRoles = visibleRoles.filter(role => this.#settings.getRoleInfo(role).section === "chat");
|
|
692
|
+
const kindRoles = visibleRoles.filter(role => this.#settings.getRoleInfo(role).section === "kind");
|
|
693
|
+
appendRoles(chatRoles);
|
|
694
|
+
if (chatRoles.length > 0 && kindRoles.length > 0) rows.push({ kind: "separator" });
|
|
695
|
+
appendRoles(kindRoles);
|
|
696
|
+
} else {
|
|
697
|
+
appendRoles(visibleRoles.filter(role => this.#settings.getRoleInfo(role).section === this.#roleTab));
|
|
664
698
|
}
|
|
665
699
|
rows.push({ kind: "newRole" });
|
|
666
700
|
rows.push({ kind: "separator" });
|
|
@@ -912,7 +946,7 @@ export class ModelHubComponent implements Component {
|
|
|
912
946
|
const roleValue =
|
|
913
947
|
scope === "project" ? this.#settings.getProjectModelRole(role) : this.#settings.getGlobalModelRole(role);
|
|
914
948
|
const allModels =
|
|
915
|
-
this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll();
|
|
949
|
+
this.#scopedModels.length > 0 ? this.#scopedModels.map(scoped => scoped.model) : this.#registry.getAll("all");
|
|
916
950
|
const roleLookup: ModelRoleLookup = {
|
|
917
951
|
getModelRole: scopedRole =>
|
|
918
952
|
scope === "project"
|
|
@@ -987,12 +1021,14 @@ export class ModelHubComponent implements Component {
|
|
|
987
1021
|
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
|
|
988
1022
|
}
|
|
989
1023
|
|
|
1024
|
+
/** Offer only the roles this model can actually fill (chat roles for chat models, `web` for search runners, …). */
|
|
990
1025
|
#openRoleStrip(item: ModelBrowserItem): void {
|
|
991
1026
|
const chips: StripChip[] = [];
|
|
992
1027
|
const scopedStorage = this.#settings.modelRoleStorage === "project";
|
|
993
1028
|
const scopes: readonly ModelRoleSelectionScope[] = scopedStorage ? ["project", "global"] : ["global"];
|
|
994
1029
|
for (const role of this.#visibleRoleIds()) {
|
|
995
1030
|
const info = this.#settings.getRoleInfo(role);
|
|
1031
|
+
if (!info.accepts(item.model)) continue;
|
|
996
1032
|
const assignment = this.#roles[role];
|
|
997
1033
|
for (const scope of scopes) {
|
|
998
1034
|
const scopedModel = scopedStorage
|
|
@@ -1029,7 +1065,10 @@ export class ModelHubComponent implements Component {
|
|
|
1029
1065
|
styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`),
|
|
1030
1066
|
action: "fallbackProvider",
|
|
1031
1067
|
});
|
|
1032
|
-
|
|
1068
|
+
// `retry-fallback` appends to the default chain, so only chat-capable models qualify.
|
|
1069
|
+
if (this.#settings.getRoleInfo("default").accepts(item.model)) {
|
|
1070
|
+
chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
|
|
1071
|
+
}
|
|
1033
1072
|
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
|
|
1034
1073
|
}
|
|
1035
1074
|
|
|
@@ -1290,7 +1329,9 @@ export class ModelHubComponent implements Component {
|
|
|
1290
1329
|
this.#assigning = { kind: "role", role };
|
|
1291
1330
|
this.#focus = "scope";
|
|
1292
1331
|
this.#browser.setShowProvider(true);
|
|
1293
|
-
this.#
|
|
1332
|
+
this.#setCandidateItems(
|
|
1333
|
+
this.#availableItems.filter(item => this.#settings.getRoleInfo(role).accepts(item.model)),
|
|
1334
|
+
);
|
|
1294
1335
|
this.#browser.setQuery("");
|
|
1295
1336
|
const current = this.#roles[role];
|
|
1296
1337
|
if (current) {
|
|
@@ -1303,7 +1344,7 @@ export class ModelHubComponent implements Component {
|
|
|
1303
1344
|
this.#assigning = { kind: "fallback", role, index };
|
|
1304
1345
|
this.#focus = "scope";
|
|
1305
1346
|
this.#browser.setShowProvider(true);
|
|
1306
|
-
this.#
|
|
1347
|
+
this.#setCandidateItems(this.#availableItems);
|
|
1307
1348
|
this.#browser.setQuery("");
|
|
1308
1349
|
if (index !== null) {
|
|
1309
1350
|
const selector = this.#fallbackChains()[role]?.[index];
|
|
@@ -1321,7 +1362,7 @@ export class ModelHubComponent implements Component {
|
|
|
1321
1362
|
this.#assigning = { kind: "fallbackKey" };
|
|
1322
1363
|
this.#focus = "scope";
|
|
1323
1364
|
this.#browser.setShowProvider(true);
|
|
1324
|
-
this.#
|
|
1365
|
+
this.#setCandidateItems(this.#availableItems);
|
|
1325
1366
|
this.#browser.setQuery("");
|
|
1326
1367
|
}
|
|
1327
1368
|
|
|
@@ -1462,6 +1503,22 @@ export class ModelHubComponent implements Component {
|
|
|
1462
1503
|
// Input
|
|
1463
1504
|
// ═══════════════════════════════════════════════════════════════════════
|
|
1464
1505
|
|
|
1506
|
+
#moveModelKind(delta: -1 | 1): void {
|
|
1507
|
+
const current = MODEL_KIND_TABS.indexOf(this.#modelKindTab);
|
|
1508
|
+
const next = (Math.max(0, current) + delta + MODEL_KIND_TABS.length) % MODEL_KIND_TABS.length;
|
|
1509
|
+
this.#modelKindTab = MODEL_KIND_TABS[next] ?? "all";
|
|
1510
|
+
this.#applyModelKind();
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
#moveRoleTab(delta: -1 | 1): void {
|
|
1514
|
+
const current = ROLE_TABS.indexOf(this.#roleTab);
|
|
1515
|
+
const next = (Math.max(0, current) + delta + ROLE_TABS.length) % ROLE_TABS.length;
|
|
1516
|
+
this.#roleTab = ROLE_TABS[next] ?? "all";
|
|
1517
|
+
this.#roleIndex = 0;
|
|
1518
|
+
this.#roleScrollStart = 0;
|
|
1519
|
+
this.#buildRolesRows();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1465
1522
|
handleInput(data: string): void {
|
|
1466
1523
|
if (this.#assignmentPending) {
|
|
1467
1524
|
if (matchesSelectCancel(data)) this.#callbacks.onCancel();
|
|
@@ -1505,6 +1562,21 @@ export class ModelHubComponent implements Component {
|
|
|
1505
1562
|
}
|
|
1506
1563
|
return;
|
|
1507
1564
|
}
|
|
1565
|
+
// Alt+←/→ cycles whichever tab strip is on screen: role tabs in the
|
|
1566
|
+
// Roles view, kind tabs in every browser view. Ctrl+←/→ is unusable on
|
|
1567
|
+
// macOS (Spaces shortcut). macOS terminals (ghostty, Terminal.app,
|
|
1568
|
+
// iTerm) send ESC b / ESC f for Option+←/→, which parse as alt+b /
|
|
1569
|
+
// alt+f — the same aliases the editor's word-motion bindings accept.
|
|
1570
|
+
if (matchesKey(data, "alt+left") || matchesKey(data, "alt+b")) {
|
|
1571
|
+
if (rolesView) this.#moveRoleTab(-1);
|
|
1572
|
+
else this.#moveModelKind(-1);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (matchesKey(data, "alt+right") || matchesKey(data, "alt+f")) {
|
|
1576
|
+
if (rolesView) this.#moveRoleTab(1);
|
|
1577
|
+
else this.#moveModelKind(1);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1508
1580
|
|
|
1509
1581
|
// ←/→ are spatial pane switches: the sidebar sits left of the rows.
|
|
1510
1582
|
// They never reach the search caret — fuzzy queries don't need one.
|
|
@@ -1797,8 +1869,8 @@ export class ModelHubComponent implements Component {
|
|
|
1797
1869
|
} else if (overBody) {
|
|
1798
1870
|
if (entry.kind === "roles" && this.#assigning === null) {
|
|
1799
1871
|
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1, { wrap: false });
|
|
1800
|
-
} else if (this.#isBrowserView(entry)) {
|
|
1801
|
-
this.#browser.routeMouse(event, bodyLine);
|
|
1872
|
+
} else if (this.#isBrowserView(entry) && bodyLine > 0) {
|
|
1873
|
+
this.#browser.routeMouse(event, bodyLine - 1);
|
|
1802
1874
|
}
|
|
1803
1875
|
}
|
|
1804
1876
|
return true;
|
|
@@ -1812,8 +1884,8 @@ export class ModelHubComponent implements Component {
|
|
|
1812
1884
|
roleLine >= 0 && roleLine < this.#rolesVisibleCount ? roleLine + this.#roleScrollStart : null;
|
|
1813
1885
|
} else {
|
|
1814
1886
|
this.#roleHover = null;
|
|
1815
|
-
if (overBody && this.#isBrowserView(entry)) {
|
|
1816
|
-
this.#browser.routeMouse(event, bodyLine);
|
|
1887
|
+
if (overBody && this.#isBrowserView(entry) && bodyLine > 0) {
|
|
1888
|
+
this.#browser.routeMouse(event, bodyLine - 1);
|
|
1817
1889
|
} else {
|
|
1818
1890
|
// Pointer left the browser pane: without this, the last
|
|
1819
1891
|
// hovered row keeps its band while the sidebar hovers too.
|
|
@@ -1860,8 +1932,8 @@ export class ModelHubComponent implements Component {
|
|
|
1860
1932
|
if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
|
|
1861
1933
|
this.#requestLogin(entry);
|
|
1862
1934
|
}
|
|
1863
|
-
} else if (this.#isBrowserView(entry)) {
|
|
1864
|
-
this.#browser.routeMouse(event, bodyLine);
|
|
1935
|
+
} else if (this.#isBrowserView(entry) && bodyLine > 0) {
|
|
1936
|
+
this.#browser.routeMouse(event, bodyLine - 1);
|
|
1865
1937
|
}
|
|
1866
1938
|
}
|
|
1867
1939
|
return true;
|
|
@@ -1927,6 +1999,24 @@ export class ModelHubComponent implements Component {
|
|
|
1927
1999
|
};
|
|
1928
2000
|
};
|
|
1929
2001
|
|
|
2002
|
+
#renderModelKindTabs(width: number): string {
|
|
2003
|
+
const active = MODEL_KIND_TABS.indexOf(this.#modelKindTab);
|
|
2004
|
+
const track = renderSegmentTrack(
|
|
2005
|
+
MODEL_KIND_TABS.map(kind => ({ label: kind })),
|
|
2006
|
+
Math.max(0, active),
|
|
2007
|
+
);
|
|
2008
|
+
return truncateToWidth(` ${theme.fg("dim", "Kind:")} ${track} ${theme.fg("dim", "Alt+←/→")}`, width);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
#renderRoleTabs(width: number): string {
|
|
2012
|
+
const active = ROLE_TABS.indexOf(this.#roleTab);
|
|
2013
|
+
const track = renderSegmentTrack(
|
|
2014
|
+
ROLE_TABS.map(tab => ({ label: tab === "kind" ? "kinds" : tab })),
|
|
2015
|
+
Math.max(0, active),
|
|
2016
|
+
);
|
|
2017
|
+
return truncateToWidth(` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", "Alt+←/→")}`, width);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1930
2020
|
#statusRow(width: number): string {
|
|
1931
2021
|
if (this.#assignmentPending) {
|
|
1932
2022
|
return truncateToWidth(theme.fg("accent", " Applying model…"), width);
|
|
@@ -1995,10 +2085,9 @@ export class ModelHubComponent implements Component {
|
|
|
1995
2085
|
|
|
1996
2086
|
#renderRolesView(width: number, rows: number): string[] {
|
|
1997
2087
|
const lines: string[] = [];
|
|
1998
|
-
lines.push(
|
|
2088
|
+
lines.push(this.#renderRoleTabs(width));
|
|
1999
2089
|
// First row's offset in bodyLine coordinates: the mouse router's
|
|
2000
|
-
// `bodyLine` has already dropped the status row,
|
|
2001
|
-
// leading blank line — no extra status-row offset here.
|
|
2090
|
+
// `bodyLine` has already dropped the status row, leaving the tabs row.
|
|
2002
2091
|
this.#rolesRowStart = lines.length;
|
|
2003
2092
|
|
|
2004
2093
|
let tagWidth = 0;
|
|
@@ -2162,7 +2251,7 @@ export class ModelHubComponent implements Component {
|
|
|
2162
2251
|
const catalogCount = entry.catalogCount ?? 0;
|
|
2163
2252
|
if (catalogCount > 0) {
|
|
2164
2253
|
lines.push(truncateToWidth(theme.fg("dim", ` ${catalogCount} models in catalog:`), width));
|
|
2165
|
-
const preview = this.#scopedModels.length > 0 ? [] : this.#registry.getAll();
|
|
2254
|
+
const preview = this.#scopedModels.length > 0 ? [] : this.#registry.getAll("all");
|
|
2166
2255
|
for (const model of preview) {
|
|
2167
2256
|
if (model.provider !== entry.providerId) continue;
|
|
2168
2257
|
if (lines.length >= rows) break;
|
|
@@ -2186,17 +2275,17 @@ export class ModelHubComponent implements Component {
|
|
|
2186
2275
|
if (this.#assigning !== null) {
|
|
2187
2276
|
switch (this.#assigning.kind) {
|
|
2188
2277
|
case "fallback":
|
|
2189
|
-
return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel";
|
|
2278
|
+
return "Enter pick fallback · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
|
|
2190
2279
|
case "fallbackKey":
|
|
2191
|
-
return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel";
|
|
2280
|
+
return "Enter pick the protected model · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
|
|
2192
2281
|
default:
|
|
2193
|
-
return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
|
|
2282
|
+
return "Enter assign · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
|
|
2194
2283
|
}
|
|
2195
2284
|
}
|
|
2196
2285
|
const entry = this.#activeEntry();
|
|
2197
2286
|
if (entry.kind === "roles") {
|
|
2198
2287
|
if (this.#focus !== "list") {
|
|
2199
|
-
return "↑/↓ providers · → roles · Esc close";
|
|
2288
|
+
return "↑/↓ providers · → roles · Alt+←/→ tabs · Esc close";
|
|
2200
2289
|
}
|
|
2201
2290
|
const row = this.#rolesRows[this.#roleIndex];
|
|
2202
2291
|
if (row?.kind === "fallback") {
|
|
@@ -2220,7 +2309,7 @@ export class ModelHubComponent implements Component {
|
|
|
2220
2309
|
}
|
|
2221
2310
|
const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
|
|
2222
2311
|
const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
|
|
2223
|
-
return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
|
|
2312
|
+
return `Enter assign roles · ${arrows} · type to search · Alt+←/→ kind${refresh} · Esc close`;
|
|
2224
2313
|
}
|
|
2225
2314
|
|
|
2226
2315
|
#renderFooter(width: number): string {
|
|
@@ -14,8 +14,8 @@ export type KeywordHighlighter = (text: string, resetTo?: string, phase?: number
|
|
|
14
14
|
|
|
15
15
|
/** Declarative spec for {@link createGradientHighlighter}. */
|
|
16
16
|
export interface GradientHighlightSpec {
|
|
17
|
-
/**
|
|
18
|
-
probe:
|
|
17
|
+
/** Literal substring whose absence skips the boundary regex on most lines. */
|
|
18
|
+
probe: string;
|
|
19
19
|
/** Global, word-bounded match regex walked by `.replace`. */
|
|
20
20
|
highlight: RegExp;
|
|
21
21
|
/** Number of color stops swept across the gradient. */
|
|
@@ -82,7 +82,7 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordH
|
|
|
82
82
|
};
|
|
83
83
|
|
|
84
84
|
return (text: string, resetTo: string = FG_RESET, phase: number = 0): string => {
|
|
85
|
-
if (!
|
|
85
|
+
if (!text.includes(probe)) return text;
|
|
86
86
|
// Wrap phase into [0, 1) so negative inputs and values ≥ 1 stay well-defined.
|
|
87
87
|
const wrappedPhase = ((phase % 1) + 1) % 1;
|
|
88
88
|
// Match against a code/markup-masked copy so keywords inside code spans,
|
|
@@ -2,6 +2,7 @@ import * as native from "@oh-my-pi/pi-natives";
|
|
|
2
2
|
import { TERMINAL } from "../index";
|
|
3
3
|
import type { EditorInlineReplacement, EditorTextAssistProvider, EditorWordReplacements } from "../components/editor";
|
|
4
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { isMagicKeyword } from "./magic-keywords";
|
|
5
6
|
import { maskNonProse } from "./markdown-prose";
|
|
6
7
|
|
|
7
8
|
/** Styled underline: red curly undercurl via colon-subparameter SGR (4:3 + SGR 58 color). */
|
|
@@ -200,6 +201,8 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
|
|
|
200
201
|
const match = COMPLETED_WORD.exec(textBeforeCursor);
|
|
201
202
|
if (!match) return null;
|
|
202
203
|
const word = match[1] ?? "";
|
|
204
|
+
// Magic keywords are deliberate non-dictionary words; never "fix" them.
|
|
205
|
+
if (isMagicKeyword(word)) return null;
|
|
203
206
|
const boundary = match[2] ?? "";
|
|
204
207
|
const start = match.index;
|
|
205
208
|
const masked = maskNonProse(textBeforeCursor);
|
|
@@ -350,7 +353,10 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
|
|
|
350
353
|
if (generation !== this.#cacheGeneration || !this.#available) return [];
|
|
351
354
|
const masked = maskNonProse(text);
|
|
352
355
|
const ranges = checked
|
|
353
|
-
.filter(range =>
|
|
356
|
+
.filter(range => {
|
|
357
|
+
const end = range.start + range.length;
|
|
358
|
+
return isProseWord(text, masked, range.start, end) && !isMagicKeyword(text.slice(range.start, end));
|
|
359
|
+
})
|
|
354
360
|
.toSorted((left, right) => left.start - right.start);
|
|
355
361
|
const hadProjectedRanges = (this.#projectTypoRanges(text)?.length ?? 0) > 0;
|
|
356
362
|
if (this.#typoCache.size >= CACHE_LIMIT) this.#typoCache.clear();
|
|
@@ -1,14 +1,98 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { containsWorkflow, highlightWorkflow } from "./workflow";
|
|
1
|
+
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
|
|
2
|
+
import { keywordInProse } from "./markdown-prose";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
5
|
+
* Magic-keyword engine: standalone prose words the host registers via
|
|
6
|
+
* {@link setMagicKeywords} glow with a per-word gradient in the editor and in
|
|
7
|
+
* sent bubbles, and are exempt from spelling autocorrect. The host (coding-agent
|
|
8
|
+
* `modes/magic-keywords.ts`) owns the word list and the notices each word
|
|
9
|
+
* injects; this module only knows how to find and paint them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One registered magic keyword: the prose trigger and its editor gradient. */
|
|
13
|
+
export interface MagicKeywordSpec {
|
|
14
|
+
/** Exact lowercase trigger, matched only as standalone prose. */
|
|
15
|
+
readonly word: string;
|
|
16
|
+
/** HSL hue sweep `[from, to]` in degrees painted across the word; `to` may exceed 360 to wrap through red. */
|
|
17
|
+
readonly hue: readonly [number, number];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Characters that bind a magic keyword into an identifier or path segment. */
|
|
21
|
+
const LEFT_BOUNDARY = String.raw`(?<![\p{L}\p{N}_./\\-])(?<!::)`;
|
|
22
|
+
|
|
23
|
+
/** Characters that cannot immediately follow a standalone magic keyword. */
|
|
24
|
+
const RIGHT_BOUNDARY = String.raw`(?![\p{L}\p{N}_/\\-])(?!\.[\p{L}\p{N}_-])(?!\()`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a case-sensitive matcher for `word` at prose punctuation boundaries.
|
|
28
|
+
*
|
|
29
|
+
* Sentence punctuation and quotes may touch the keyword, but letters, digits,
|
|
30
|
+
* underscores, slashes, backslashes, hyphens, file-extension dots, symbol
|
|
31
|
+
* references (`foo::keyword`), and immediate call parentheses (`keyword()`)
|
|
32
|
+
* keep the occurrence embedded in code rather than prose.
|
|
33
|
+
*/
|
|
34
|
+
function magicKeywordRegex(word: string, flags = ""): RegExp {
|
|
35
|
+
const escaped = word.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
36
|
+
return new RegExp(`${LEFT_BOUNDARY}${escaped}${RIGHT_BOUNDARY}`, `${flags}u`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RegisteredKeyword {
|
|
40
|
+
readonly word: string;
|
|
41
|
+
readonly highlight: KeywordHighlighter;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let registry: readonly RegisteredKeyword[] = [];
|
|
45
|
+
|
|
46
|
+
/** Non-global standalone-prose matcher per word, shared by detection paths. */
|
|
47
|
+
const matchers = new Map<string, RegExp>();
|
|
48
|
+
|
|
49
|
+
function matcherFor(word: string): RegExp {
|
|
50
|
+
let matcher = matchers.get(word);
|
|
51
|
+
if (!matcher) {
|
|
52
|
+
matcher = magicKeywordRegex(word);
|
|
53
|
+
matchers.set(word, matcher);
|
|
54
|
+
}
|
|
55
|
+
return matcher;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Replace the registered keyword set. The host calls this once at startup;
|
|
60
|
+
* until then nothing glows and no word is exempt from autocorrect.
|
|
61
|
+
*/
|
|
62
|
+
export function setMagicKeywords(specs: readonly MagicKeywordSpec[]): void {
|
|
63
|
+
registry = specs.map(({ word, hue: [from, to] }) => ({
|
|
64
|
+
word,
|
|
65
|
+
highlight: createGradientHighlighter({
|
|
66
|
+
probe: word,
|
|
67
|
+
highlight: magicKeywordRegex(word, "g"),
|
|
68
|
+
stops: 14,
|
|
69
|
+
hue: t => (from + t * (to - from)) % 360,
|
|
70
|
+
}),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether `word` is exactly a registered magic keyword (used to shield it from spelling autocorrect). */
|
|
75
|
+
export function isMagicKeyword(word: string): boolean {
|
|
76
|
+
for (const keyword of registry) if (keyword.word === word) return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether `text` contains `word` as standalone lowercase prose — never inside
|
|
82
|
+
* a code block, inline code span, or XML/HTML section. Pure: does not require
|
|
83
|
+
* `word` to be registered.
|
|
84
|
+
*/
|
|
85
|
+
export function containsMagicKeyword(text: string, word: string): boolean {
|
|
86
|
+
return keywordInProse(text, matcherFor(word));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Gradient-highlight every registered keyword that appears as standalone
|
|
91
|
+
* prose, skipping any occurrence inside a code block, inline code span, or
|
|
92
|
+
* XML/HTML section. Each pass paints one word with its own gradient, so order
|
|
93
|
+
* is irrelevant — earlier passes only inject zero-width SGR escapes (no
|
|
94
|
+
* backticks or angle brackets), which never confuse later passes' markdown
|
|
95
|
+
* masking.
|
|
12
96
|
*
|
|
13
97
|
* `resetTo` is the SGR foreground sequence restored after each painted keyword;
|
|
14
98
|
* pass the surrounding text color when decorating already-colored content (e.g.
|
|
@@ -21,22 +105,20 @@ import { containsWorkflow, highlightWorkflow } from "./workflow";
|
|
|
21
105
|
* to keep the static gradient.
|
|
22
106
|
*/
|
|
23
107
|
export function highlightMagicKeywords(text: string, resetTo?: string, phase?: number): string {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
phase,
|
|
28
|
-
);
|
|
108
|
+
let out = text;
|
|
109
|
+
for (const keyword of registry) out = keyword.highlight(out, resetTo, phase);
|
|
110
|
+
return out;
|
|
29
111
|
}
|
|
30
112
|
|
|
31
113
|
/**
|
|
32
|
-
* Cheap test for "does this text contain any
|
|
33
|
-
* Short-circuits on a substring probe before paying for the
|
|
34
|
-
* prose check, so the common "no keyword in buffer" path is
|
|
35
|
-
* `String#
|
|
114
|
+
* Cheap test for "does this text contain any registered keyword as standalone
|
|
115
|
+
* prose?". Short-circuits on a substring probe before paying for the
|
|
116
|
+
* markdown-aware prose check, so the common "no keyword in buffer" path is one
|
|
117
|
+
* `String#includes` per word. Used by the live editor to gate the shimmer timer.
|
|
36
118
|
*/
|
|
37
119
|
export function hasMagicKeyword(text: string): boolean {
|
|
38
|
-
|
|
39
|
-
return
|
|
120
|
+
for (const keyword of registry) {
|
|
121
|
+
if (text.includes(keyword.word) && containsMagicKeyword(text, keyword.word)) return true;
|
|
40
122
|
}
|
|
41
|
-
return
|
|
123
|
+
return false;
|
|
42
124
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { FENCE_RE } from "../render/render-utils";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Markdown structure awareness for the magic-keyword affordances
|
|
5
|
-
*
|
|
4
|
+
* Markdown structure awareness for the magic-keyword affordances (see
|
|
5
|
+
* `magic-keywords.ts`) and the spelling provider.
|
|
6
6
|
*
|
|
7
7
|
* Keyword detection and editor/transcript highlighting must fire only on prose
|
|
8
8
|
* the user is actually addressing to the model — never on a word that happens to
|