@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/THIRD-PARTY-NOTICES.txt +0 -37
  3. package/dist/types/glyph-protocol.d.ts +62 -0
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/overlays/model-browser.d.ts +9 -3
  6. package/dist/types/prompt/gradient-highlight.d.ts +2 -2
  7. package/dist/types/prompt/magic-keywords.d.ts +37 -10
  8. package/dist/types/setup/scenes/glyph.d.ts +6 -1
  9. package/dist/types/terminal-capabilities.d.ts +8 -0
  10. package/dist/types/terminal.d.ts +16 -0
  11. package/dist/types/theme/mermaid-cache.d.ts +2 -2
  12. package/dist/types/tools/eval.d.ts +5 -4
  13. package/dist/types/tools/find.d.ts +82 -0
  14. package/dist/types/tools/web-search.d.ts +4 -0
  15. package/package.json +9 -9
  16. package/src/glyph-protocol.ts +143 -0
  17. package/src/index.ts +2 -0
  18. package/src/overlays/model-browser.ts +47 -6
  19. package/src/overlays/model-hub.ts +122 -33
  20. package/src/prompt/gradient-highlight.ts +3 -3
  21. package/src/prompt/macos-spelling.ts +7 -1
  22. package/src/prompt/magic-keywords.ts +103 -21
  23. package/src/prompt/markdown-prose.ts +2 -2
  24. package/src/prompt/tips.txt +1 -0
  25. package/src/setup/scenes/glyph.ts +8 -1
  26. package/src/terminal-capabilities.ts +12 -0
  27. package/src/terminal.ts +151 -2
  28. package/src/theme/glyph-bundle.json +797 -0
  29. package/src/theme/mermaid-cache.ts +5 -4
  30. package/src/tools/eval.ts +14 -7
  31. package/src/tools/find.ts +255 -0
  32. package/src/tools/hub.ts +2 -0
  33. package/src/tools/index.ts +2 -0
  34. package/src/tools/web-search.ts +5 -0
  35. package/src/tui.ts +7 -0
  36. package/dist/types/prompt/magic-keyword-boundary.d.ts +0 -9
  37. package/dist/types/prompt/orchestrate.d.ts +0 -13
  38. package/dist/types/prompt/ultrathink.d.ts +0 -13
  39. package/dist/types/prompt/workflow.d.ts +0 -13
  40. package/src/prompt/magic-keyword-boundary.ts +0 -23
  41. package/src/prompt/orchestrate.ts +0 -26
  42. package/src/prompt/ultrathink.ts +0 -26
  43. 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 = "default" | "smol" | "slow" | "vision" | "plan" | "commit" | "tiny" | "task" | "advisor";
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(`pi/${role}`, candidates);
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.#browser.setMaxVisible(rows - 1 - 5);
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.#browser.setItems([...this.#recentItems]);
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.#browser.setItems([]);
619
+ this.#setCandidateItems([]);
612
620
  break;
613
621
  }
614
622
  const providerId = entry.providerId;
615
623
  this.#browser.setShowProvider(false);
616
- this.#browser.setItems(this.#availableItems.filter(item => item.provider === providerId));
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.#browser.setItems([...this.#availableItems]);
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
- for (const role of this.#visibleRoleIds()) {
659
- rows.push({ kind: "role", role });
660
- const chain = chains[role] ?? [];
661
- for (let i = 0; i < chain.length; i++) {
662
- rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] });
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
- chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
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.#browser.setItems([...this.#availableItems]);
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.#browser.setItems([...this.#availableItems]);
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.#browser.setItems([...this.#availableItems]);
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, so this is just the
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
- /** Cheap, stateless presence probe used to skip the boundary regex on most lines. Must be non-global. */
18
- probe: RegExp;
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 (!probe.test(text)) return text;
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 => isProseWord(text, masked, range.start, range.start + range.length))
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 { containsOrchestrate, highlightOrchestrate } from "./orchestrate";
2
- import { containsUltrathink, highlightUltrathink } from "./ultrathink";
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
- * Gradient-highlight every magic keyword ("ultrathink", "orchestrate",
7
- * "workflowz") that appears as standalone prose, skipping any occurrence inside a
8
- * code block, inline code span, or XML/HTML section. Each highlighter paints its
9
- * own keyword with its own gradient, so chaining is order-independent — the
10
- * earlier passes only inject zero-width SGR escapes (no backticks or angle
11
- * brackets), which never confuse the later passes' markdown masking.
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
- return highlightWorkflow(
25
- highlightOrchestrate(highlightUltrathink(text, resetTo, phase), resetTo, phase),
26
- resetTo,
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 magic keyword as standalone prose?".
33
- * Short-circuits on a substring probe before paying for the markdown-aware
34
- * prose check, so the common "no keyword in buffer" path is just three
35
- * `String#indexOf`s. Used by the live editor to gate the shimmer timer.
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
- if (!text.includes("ultrathink") && !text.includes("orchestrate") && !text.includes("workflowz")) {
39
- return false;
120
+ for (const keyword of registry) {
121
+ if (text.includes(keyword.word) && containsMagicKeyword(text, keyword.word)) return true;
40
122
  }
41
- return containsUltrathink(text) || containsOrchestrate(text) || containsWorkflow(text);
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
- * ("ultrathink"/"orchestrate"/"workflowz").
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