@oh-my-pi/pi-coding-agent 15.11.7 → 15.11.8

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 (61) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/cli.js +363 -356
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +12 -0
  5. package/dist/types/collab/guest.d.ts +21 -0
  6. package/dist/types/collab/host.d.ts +13 -0
  7. package/dist/types/collab/protocol.d.ts +100 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +21 -1
  11. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  12. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  13. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  15. package/dist/types/modes/components/segment-track.d.ts +11 -6
  16. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  17. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  18. package/dist/types/modes/interactive-mode.d.ts +7 -0
  19. package/dist/types/modes/types.d.ts +8 -0
  20. package/dist/types/session/agent-session.d.ts +11 -0
  21. package/dist/types/session/session-manager.d.ts +21 -0
  22. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  23. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  24. package/package.json +14 -12
  25. package/scripts/bench-guard.ts +71 -0
  26. package/src/cli/args.ts +2 -0
  27. package/src/cli-commands.ts +1 -0
  28. package/src/collab/crypto.ts +57 -0
  29. package/src/collab/guest.ts +421 -0
  30. package/src/collab/host.ts +494 -0
  31. package/src/collab/protocol.ts +191 -0
  32. package/src/collab/relay-client.ts +216 -0
  33. package/src/commands/join.ts +39 -0
  34. package/src/config/model-registry.ts +22 -14
  35. package/src/config/settings-schema.ts +27 -1
  36. package/src/extensibility/slash-commands.ts +1 -97
  37. package/src/internal-urls/docs-index.generated.ts +3 -2
  38. package/src/main.ts +11 -2
  39. package/src/modes/components/agent-hub.ts +119 -22
  40. package/src/modes/components/assistant-message.ts +126 -6
  41. package/src/modes/components/collab-prompt-message.ts +30 -0
  42. package/src/modes/components/hook-selector.ts +4 -5
  43. package/src/modes/components/segment-track.ts +44 -7
  44. package/src/modes/components/status-line/component.ts +21 -1
  45. package/src/modes/components/status-line/presets.ts +1 -1
  46. package/src/modes/components/status-line/segments.ts +13 -0
  47. package/src/modes/components/status-line/types.ts +10 -0
  48. package/src/modes/components/tips.txt +2 -1
  49. package/src/modes/controllers/input-controller.ts +72 -6
  50. package/src/modes/controllers/selector-controller.ts +2 -0
  51. package/src/modes/controllers/streaming-reveal.ts +7 -0
  52. package/src/modes/interactive-mode.ts +12 -4
  53. package/src/modes/types.ts +8 -0
  54. package/src/modes/utils/ui-helpers.ts +7 -0
  55. package/src/sdk.ts +239 -36
  56. package/src/session/agent-session.ts +17 -0
  57. package/src/session/session-manager.ts +44 -0
  58. package/src/session/snapcompact-inline.ts +9 -3
  59. package/src/slash-commands/builtin-registry.ts +210 -0
  60. package/src/tools/read.ts +38 -5
  61. package/src/tools/write.ts +13 -42
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Client-side WebSocket wrapper for collab live-session sharing.
3
+ *
4
+ * Connects to a relay room, seals/opens AES-GCM frames, and reconnects with
5
+ * exponential backoff on transient drops. Fatal relay close codes (room gone,
6
+ * host conflict, room full) and decryption failures never reconnect.
7
+ */
8
+ import { logger } from "@oh-my-pi/pi-utils";
9
+ import { open, seal } from "./crypto";
10
+ import type { CollabFrame, RelayControlMessage } from "./protocol";
11
+ import { packEnvelope, unpackEnvelope } from "./protocol";
12
+
13
+ const FATAL_CLOSE_REASONS: Record<number, string> = {
14
+ 4001: "room closed",
15
+ 4004: "no such room",
16
+ 4009: "a host is already connected for this room",
17
+ 4029: "room is full",
18
+ };
19
+
20
+ const BACKOFF_BASE_MS = 1_000;
21
+ const BACKOFF_MAX_MS = 30_000;
22
+ /** Max enveloped frames buffered while a reconnect is pending; overflow is dropped. */
23
+ const MAX_PENDING_SENDS = 256;
24
+
25
+ export interface CollabSocketOptions {
26
+ /** wss://host[:port]/r/<roomId> — no query string. */
27
+ wsUrl: string;
28
+ role: "host" | "guest";
29
+ key: CryptoKey;
30
+ }
31
+
32
+ export class CollabSocket {
33
+ /** Fires after every successful (re)connect. */
34
+ onOpen?: () => void;
35
+ onFrame?: (frame: CollabFrame, fromPeer: number) => void;
36
+ onControl?: (msg: RelayControlMessage) => void;
37
+ /** Fires once per terminal close (intentional, fatal code, or bad key). willReconnect=true for transient drops that will retry. */
38
+ onClose?: (reason: string, willReconnect: boolean) => void;
39
+
40
+ readonly #opts: CollabSocketOptions;
41
+ #ws: WebSocket | null = null;
42
+ #retryTimer: NodeJS.Timeout | undefined;
43
+ #attempt = 0;
44
+ /** Terminal state: intentional close or fatal failure. Cleared by connect(). */
45
+ #closed = false;
46
+ /** Serializes seal() so frames hit the wire in send() order. */
47
+ #sendChain: Promise<void> = Promise.resolve();
48
+ /** Serializes open() so frames are delivered in arrival order. */
49
+ #recvChain: Promise<void> = Promise.resolve();
50
+ /** Envelopes sealed while disconnected, flushed on the next open. */
51
+ #pendingSends: Uint8Array[] = [];
52
+
53
+ constructor(opts: CollabSocketOptions) {
54
+ this.#opts = opts;
55
+ }
56
+
57
+ get isOpen(): boolean {
58
+ return this.#ws?.readyState === WebSocket.OPEN;
59
+ }
60
+
61
+ connect(): void {
62
+ if (this.#ws || this.#retryTimer) return;
63
+ this.#closed = false;
64
+ this.#attempt = 0;
65
+ this.#openSocket();
66
+ }
67
+
68
+ send(frame: CollabFrame, targetPeer = 0): void {
69
+ this.#sendChain = this.#sendChain
70
+ .then(async () => {
71
+ if (this.#closed) {
72
+ logger.debug("collab: dropping frame, socket closed", { t: frame.t });
73
+ return;
74
+ }
75
+ const sealed = await seal(this.#opts.key, frame);
76
+ const envelope = packEnvelope(targetPeer, sealed);
77
+ const ws = this.#ws;
78
+ if (ws && ws.readyState === WebSocket.OPEN) {
79
+ ws.send(envelope);
80
+ return;
81
+ }
82
+ if (this.#pendingSends.length >= MAX_PENDING_SENDS) {
83
+ logger.debug("collab: dropping frame, reconnect buffer full", { t: frame.t });
84
+ return;
85
+ }
86
+ this.#pendingSends.push(envelope);
87
+ })
88
+ .catch((err: unknown) => {
89
+ logger.debug("collab: send failed", { error: String(err) });
90
+ });
91
+ }
92
+
93
+ /** Intentional close: clears any retry timer, suppresses reconnect. A later connect() starts fresh. */
94
+ close(): void {
95
+ const hadActivity = this.#ws !== null || this.#retryTimer !== undefined;
96
+ this.#clearRetry();
97
+ const wasClosed = this.#closed;
98
+ this.#closed = true;
99
+ this.#pendingSends.length = 0;
100
+ const ws = this.#ws;
101
+ this.#ws = null;
102
+ if (ws) {
103
+ try {
104
+ ws.close(1000);
105
+ } catch {
106
+ // already closing/closed
107
+ }
108
+ }
109
+ if (hadActivity && !wasClosed) this.onClose?.("closed", false);
110
+ }
111
+
112
+ #openSocket(): void {
113
+ const ws = new WebSocket(`${this.#opts.wsUrl}?role=${this.#opts.role}`);
114
+ ws.binaryType = "arraybuffer";
115
+ this.#ws = ws;
116
+ ws.onopen = () => {
117
+ if (this.#ws !== ws) return;
118
+ this.#attempt = 0;
119
+ for (const envelope of this.#pendingSends) ws.send(envelope);
120
+ this.#pendingSends.length = 0;
121
+ this.onOpen?.();
122
+ };
123
+ ws.onmessage = (event: MessageEvent) => {
124
+ if (this.#ws !== ws) return;
125
+ this.#handleMessage(ws, event.data);
126
+ };
127
+ ws.onerror = () => {
128
+ // The paired close event carries the actionable state; nothing to do here.
129
+ };
130
+ ws.onclose = (event: CloseEvent) => {
131
+ if (this.#ws !== ws) return;
132
+ this.#ws = null;
133
+ this.#handleClose(event.code, event.reason);
134
+ };
135
+ }
136
+
137
+ #handleMessage(ws: WebSocket, data: unknown): void {
138
+ if (typeof data === "string") {
139
+ try {
140
+ this.onControl?.(JSON.parse(data) as RelayControlMessage);
141
+ } catch {
142
+ logger.debug("collab: ignoring malformed control message");
143
+ }
144
+ return;
145
+ }
146
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
147
+ if (!bytes) return;
148
+ const envelope = unpackEnvelope(bytes);
149
+ if (!envelope) return;
150
+ this.#recvChain = this.#recvChain
151
+ .then(async () => {
152
+ if (this.#ws !== ws) return;
153
+ let frame: CollabFrame;
154
+ try {
155
+ frame = await open(this.#opts.key, envelope.payload);
156
+ } catch {
157
+ this.#failFatal("bad key or corrupted frame");
158
+ return;
159
+ }
160
+ if (this.#ws !== ws) return;
161
+ this.onFrame?.(frame, envelope.peerId);
162
+ })
163
+ .catch((err: unknown) => {
164
+ logger.debug("collab: frame handler failed", { error: String(err) });
165
+ });
166
+ }
167
+
168
+ #handleClose(code: number, reason: string): void {
169
+ if (this.#closed) return;
170
+ const fatalReason = FATAL_CLOSE_REASONS[code];
171
+ if (fatalReason !== undefined) {
172
+ this.#closed = true;
173
+ this.#pendingSends.length = 0;
174
+ this.onClose?.(fatalReason, false);
175
+ return;
176
+ }
177
+ this.onClose?.(reason || `connection lost (code ${code})`, true);
178
+ this.#scheduleRetry();
179
+ }
180
+
181
+ /** Decryption failure: wrong key or corrupted frame. Never reconnect. */
182
+ #failFatal(reason: string): void {
183
+ if (this.#closed) return;
184
+ this.#closed = true;
185
+ this.#clearRetry();
186
+ this.#pendingSends.length = 0;
187
+ const ws = this.#ws;
188
+ this.#ws = null;
189
+ if (ws) {
190
+ try {
191
+ ws.close(1000);
192
+ } catch {
193
+ // already closing/closed
194
+ }
195
+ }
196
+ this.onClose?.(reason, false);
197
+ }
198
+
199
+ #scheduleRetry(): void {
200
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** this.#attempt, BACKOFF_MAX_MS);
201
+ this.#attempt++;
202
+ const delay = base * (0.75 + Math.random() * 0.5);
203
+ this.#retryTimer = setTimeout(() => {
204
+ this.#retryTimer = undefined;
205
+ if (this.#closed) return;
206
+ this.#openSocket();
207
+ }, delay);
208
+ }
209
+
210
+ #clearRetry(): void {
211
+ if (this.#retryTimer !== undefined) {
212
+ clearTimeout(this.#retryTimer);
213
+ this.#retryTimer = undefined;
214
+ }
215
+ }
216
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Join a shared collab session from the CLI: launches the interactive TUI and
3
+ * immediately runs `/join <link>`.
4
+ */
5
+ import { APP_NAME } from "@oh-my-pi/pi-utils";
6
+ import { Args, Command } from "@oh-my-pi/pi-utils/cli";
7
+ import { parseArgs } from "../cli/args";
8
+ import { runRootCommand } from "../main";
9
+
10
+ export default class Join extends Command {
11
+ static description = "Join a shared collab session (same as /join)";
12
+
13
+ static args = {
14
+ link: Args.string({
15
+ description: "Collab link shared by the host (/collab)",
16
+ required: true,
17
+ }),
18
+ };
19
+
20
+ static examples = [`${APP_NAME} join wss://relay.omp.sh/s/abc123#key`];
21
+
22
+ async run(): Promise<void> {
23
+ const { args } = await this.parse(Join);
24
+ const link = args.link?.trim();
25
+ if (!link) {
26
+ process.stderr.write(`Usage: ${APP_NAME} join <link>\n`);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
31
+ process.stderr.write(`${APP_NAME} join requires an interactive terminal\n`);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ const parsed = parseArgs([]);
36
+ parsed.join = link;
37
+ await runRootCommand(parsed, []);
38
+ }
39
+ }
@@ -602,6 +602,7 @@ function getConfiguredProviderOrderFromSettings(): string[] {
602
602
  export class ModelRegistry {
603
603
  #models: Model<Api>[] = [];
604
604
  #canonicalIndex: CanonicalModelIndex = { records: [], byId: new Map(), bySelector: new Map() };
605
+ #canonicalIndexDirty: boolean = true;
605
606
  #customProviderApiKeys: Map<string, string> = new Map();
606
607
  #keylessProviders: Set<string> = new Set();
607
608
  #discoverableProviders: DiscoveryProviderConfig[] = [];
@@ -1519,14 +1520,25 @@ export class ModelRegistry {
1519
1520
  this.#rebuildPending = true;
1520
1521
  return;
1521
1522
  }
1522
- this.#canonicalIndex = buildCanonicalModelIndex(
1523
- this.#models,
1524
- getBundledCanonicalReferenceData(),
1525
- this.#equivalenceConfig,
1526
- );
1523
+ // Defer the catalog-wide index build to first read. Boot model
1524
+ // resolution reads it only when enabledModels or a default-role pattern
1525
+ // is configured; the empty interactive launch never reads it pre-paint,
1526
+ // so the ~200ms build over the full catalog moves off the first-paint
1527
+ // critical path.
1528
+ this.#canonicalIndexDirty = true;
1527
1529
  this.#rebuildPending = false;
1528
1530
  }
1529
1531
 
1532
+ #ensureCanonicalIndex(): CanonicalModelIndex {
1533
+ if (this.#canonicalIndexDirty) {
1534
+ this.#canonicalIndex = logger.time("buildCanonicalModelIndex", () =>
1535
+ buildCanonicalModelIndex(this.#models, getBundledCanonicalReferenceData(), this.#equivalenceConfig),
1536
+ );
1537
+ this.#canonicalIndexDirty = false;
1538
+ }
1539
+ return this.#canonicalIndex;
1540
+ }
1541
+
1530
1542
  #suspendRebuild(): void {
1531
1543
  this.#rebuildSuspended += 1;
1532
1544
  }
@@ -1537,11 +1549,7 @@ export class ModelRegistry {
1537
1549
  }
1538
1550
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
1539
1551
  this.#rebuildPending = false;
1540
- this.#canonicalIndex = buildCanonicalModelIndex(
1541
- this.#models,
1542
- getBundledCanonicalReferenceData(),
1543
- this.#equivalenceConfig,
1544
- );
1552
+ this.#canonicalIndexDirty = true;
1545
1553
  }
1546
1554
  }
1547
1555
 
@@ -1650,7 +1658,7 @@ export class ModelRegistry {
1650
1658
  getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
1651
1659
  const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
1652
1660
  const records: CanonicalModelRecord[] = [];
1653
- for (const record of this.#canonicalIndex.records) {
1661
+ for (const record of this.#ensureCanonicalIndex().records) {
1654
1662
  const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
1655
1663
  if (variants.length === 0) {
1656
1664
  continue;
@@ -1676,7 +1684,7 @@ export class ModelRegistry {
1676
1684
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
1677
1685
  const preferences = this.#variantPreferences(candidates);
1678
1686
  const selections: CanonicalModelSelection[] = [];
1679
- for (const record of this.#canonicalIndex.records) {
1687
+ for (const record of this.#ensureCanonicalIndex().records) {
1680
1688
  const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
1681
1689
  if (variants.length === 0) {
1682
1690
  continue;
@@ -1694,7 +1702,7 @@ export class ModelRegistry {
1694
1702
  }
1695
1703
 
1696
1704
  getCanonicalVariants(canonicalId: string, options?: CanonicalModelQueryOptions): CanonicalModelVariant[] {
1697
- const record = this.#canonicalIndex.byId.get(canonicalId.trim().toLowerCase());
1705
+ const record = this.#ensureCanonicalIndex().byId.get(canonicalId.trim().toLowerCase());
1698
1706
  if (!record) {
1699
1707
  return [];
1700
1708
  }
@@ -1712,7 +1720,7 @@ export class ModelRegistry {
1712
1720
  }
1713
1721
 
1714
1722
  getCanonicalId(model: Model<Api>): string | undefined {
1715
- return this.#canonicalIndex.bySelector.get(formatCanonicalVariantSelector(model).toLowerCase());
1723
+ return this.#ensureCanonicalIndex().bySelector.get(formatCanonicalVariantSelector(model).toLowerCase());
1716
1724
  }
1717
1725
 
1718
1726
  /**
@@ -1,5 +1,6 @@
1
1
  import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
2
2
  import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact";
3
+ import { DEFAULT_RELAY_URL } from "../collab/protocol";
3
4
  import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
4
5
  import {
5
6
  TINY_MODEL_DEVICE_DEFAULT,
@@ -101,6 +102,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
101
102
  "Approvals",
102
103
  "Notifications",
103
104
  "Speech",
105
+ "Collab",
104
106
  "Magic Keywords",
105
107
  "Startup & Updates",
106
108
  "Power (macOS)",
@@ -147,7 +149,8 @@ export type StatusLineSegmentId =
147
149
  | "cache_write"
148
150
  | "cache_hit"
149
151
  | "session_name"
150
- | "usage";
152
+ | "usage"
153
+ | "collab";
151
154
 
152
155
  /** Submenu choice metadata. */
153
156
  export type SubmenuOption<V extends string = string> = {
@@ -1327,6 +1330,29 @@ export const SETTINGS_SCHEMA = {
1327
1330
  },
1328
1331
  },
1329
1332
 
1333
+ // Collab
1334
+ "collab.relayUrl": {
1335
+ type: "string",
1336
+ default: DEFAULT_RELAY_URL,
1337
+ ui: {
1338
+ tab: "interaction",
1339
+ group: "Collab",
1340
+ label: "Relay URL",
1341
+ description: "Relay used by /collab (wss://host[:port]; self-host with the omp-collab-relay service)",
1342
+ },
1343
+ },
1344
+
1345
+ "collab.displayName": {
1346
+ type: "string",
1347
+ default: "",
1348
+ ui: {
1349
+ tab: "interaction",
1350
+ group: "Collab",
1351
+ label: "Display Name",
1352
+ description: "Name shown to other collab participants (default: OS username)",
1353
+ },
1354
+ },
1355
+
1330
1356
  // Speech-to-text
1331
1357
  "stt.enabled": {
1332
1358
  type: "boolean",
@@ -1,14 +1,8 @@
1
- import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
2
1
  import { parseFrontmatter, prompt } from "@oh-my-pi/pi-utils";
3
2
  import { slashCommandCapability } from "../capability/slash-command";
4
3
  import { appendInlineArgsFallback, templateUsesInlineArgPlaceholders } from "../config/prompt-templates";
5
4
  import type { SlashCommand } from "../discovery";
6
5
  import { loadCapability } from "../discovery";
7
- import {
8
- BUILTIN_SLASH_COMMAND_DEFS,
9
- type BuiltinSlashCommand,
10
- type SubcommandDef,
11
- } from "../slash-commands/builtin-registry";
12
6
  import { EMBEDDED_COMMAND_TEMPLATES } from "../task/commands";
13
7
  import { parseCommandArgs, substituteArgs } from "../utils/command-args";
14
8
 
@@ -24,97 +18,7 @@ export interface SlashCommandInfo {
24
18
  path?: string;
25
19
  }
26
20
 
27
- export type { BuiltinSlashCommand, SubcommandDef } from "../slash-commands/builtin-registry";
28
-
29
- /**
30
- * Build getArgumentCompletions from declarative subcommand definitions.
31
- * Returns subcommand names filtered by prefix in the dropdown.
32
- */
33
- function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string) => AutocompleteItem[] | null {
34
- return (argumentPrefix: string) => {
35
- if (argumentPrefix.includes(" ")) return null; // past the subcommand
36
- const lower = argumentPrefix.toLowerCase();
37
- const matches = subcommands
38
- .filter(s => s.name.startsWith(lower))
39
- .map(s => ({
40
- value: `${s.name} `,
41
- label: s.name,
42
- description: s.description,
43
- hint: s.usage,
44
- }));
45
- return matches.length > 0 ? matches : null;
46
- };
47
- }
48
-
49
- /**
50
- * Build getInlineHint from declarative subcommand definitions.
51
- * Shows remaining completion + usage as dim ghost text after cursor.
52
- */
53
- function buildSubcommandInlineHint(subcommands: SubcommandDef[]): (argumentText: string) => string | null {
54
- return (argumentText: string) => {
55
- const trimmed = argumentText.trimStart();
56
- const spaceIndex = trimmed.indexOf(" ");
57
-
58
- if (spaceIndex === -1) {
59
- // Still typing subcommand name — show remaining chars + usage
60
- const prefix = trimmed.toLowerCase();
61
- if (prefix.length === 0) return null;
62
- const match = subcommands.find(s => s.name.startsWith(prefix));
63
- if (!match) return null;
64
- const remaining = match.name.slice(prefix.length);
65
- return remaining + (match.usage ? ` ${match.usage}` : "");
66
- }
67
-
68
- // Subcommand typed — show remaining usage params
69
- const subName = trimmed.slice(0, spaceIndex).toLowerCase();
70
- const afterSub = trimmed.slice(spaceIndex + 1);
71
- const sub = subcommands.find(s => s.name === subName);
72
- if (!sub?.usage) return null;
73
-
74
- if (afterSub.length > 0) {
75
- const usageParts = sub.usage.split(" ");
76
- const inputParts = afterSub.trim().split(/\s+/);
77
- const remaining = usageParts.slice(inputParts.length);
78
- return remaining.length > 0 ? remaining.join(" ") : null;
79
- }
80
-
81
- return sub.usage;
82
- };
83
- }
84
-
85
- /**
86
- * Build getInlineHint for commands with a simple static hint string.
87
- * Shows the hint only when no arguments have been typed yet.
88
- */
89
- function buildStaticInlineHint(hint: string): (argumentText: string) => string | null {
90
- return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null);
91
- }
92
-
93
- /**
94
- * Materialized builtin slash commands with completion functions derived from
95
- * declarative subcommand/hint definitions.
96
- */
97
- export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<
98
- BuiltinSlashCommand & {
99
- getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
100
- getInlineHint?: (argumentText: string) => string | null;
101
- }
102
- > = BUILTIN_SLASH_COMMAND_DEFS.map(cmd => {
103
- if (cmd.subcommands) {
104
- return {
105
- ...cmd,
106
- getArgumentCompletions: buildArgumentCompletions(cmd.subcommands),
107
- getInlineHint: buildSubcommandInlineHint(cmd.subcommands),
108
- };
109
- }
110
- if (cmd.inlineHint) {
111
- return {
112
- ...cmd,
113
- getInlineHint: buildStaticInlineHint(cmd.inlineHint),
114
- };
115
- }
116
- return cmd;
117
- });
21
+ export type { BuiltinSlashCommand, SubcommandDef } from "../slash-commands/types";
118
22
 
119
23
  /**
120
24
  * Represents a custom slash command loaded from a file