@digithings-ai/digichat-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -2
- package/package.json +42 -4
- package/src/components/DigiChatMark.tsx +31 -0
- package/src/components/DocumentPane.tsx +62 -0
- package/src/components/MiniMarkdown.tsx +9 -0
- package/src/index.ts +91 -0
- package/src/slash-commands.ts +669 -0
- package/src/styles/cursor.css +59 -0
- package/src/styles/session.css +797 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product slash commands (#3418, #3511, #3556, #3733, #3736).
|
|
3
|
+
* Client-only actions never leave the browser. A command that opens a pane
|
|
4
|
+
* is one composer sequence (`/language English`); tools are toggled only
|
|
5
|
+
* inside `/tools`, never as their own slash commands.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
TRACE_DENSITIES,
|
|
9
|
+
TRACE_DENSITY_LABELS,
|
|
10
|
+
type TraceDensity,
|
|
11
|
+
} from "@digithings-ai/ui/chat/trace-spacing";
|
|
12
|
+
|
|
13
|
+
export const LANG_CODES = ["en", "nl", "it", "es", "fr"] as const;
|
|
14
|
+
export type LangCode = (typeof LANG_CODES)[number];
|
|
15
|
+
|
|
16
|
+
export type SlashCategory = "tools" | "session" | "actions" | "setup";
|
|
17
|
+
|
|
18
|
+
export type SlashId =
|
|
19
|
+
| "lang"
|
|
20
|
+
| "help"
|
|
21
|
+
| "new"
|
|
22
|
+
| "clear"
|
|
23
|
+
| "copy"
|
|
24
|
+
| "export"
|
|
25
|
+
| "settings"
|
|
26
|
+
| "byok"
|
|
27
|
+
| "models"
|
|
28
|
+
| "effort"
|
|
29
|
+
| "search-engine"
|
|
30
|
+
| "view"
|
|
31
|
+
| "density"
|
|
32
|
+
| "theme"
|
|
33
|
+
| "thinking"
|
|
34
|
+
| "sessions"
|
|
35
|
+
| "compact"
|
|
36
|
+
| "undo"
|
|
37
|
+
| "redo"
|
|
38
|
+
| "mcp"
|
|
39
|
+
| "tools"
|
|
40
|
+
| "artifacts";
|
|
41
|
+
|
|
42
|
+
export type SlashDef = {
|
|
43
|
+
id: SlashId | string;
|
|
44
|
+
names: readonly string[];
|
|
45
|
+
needsArg: boolean;
|
|
46
|
+
/** Public copy on the embed palette. */
|
|
47
|
+
hint: string;
|
|
48
|
+
forceTool?: string;
|
|
49
|
+
/** When set, selecting the command opens a discrete Up/Down choice list. */
|
|
50
|
+
choiceOptions?: readonly { value: string; label: string }[];
|
|
51
|
+
/**
|
|
52
|
+
* Opens a submenu. The composer text is the sequence (`/theme dark`);
|
|
53
|
+
* a second `/` filters that pane and does not open another palette.
|
|
54
|
+
* Commands with `choiceOptions` are panes even when this is omitted.
|
|
55
|
+
*/
|
|
56
|
+
pane?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* tool — empty Enter toggles; with a query forces (or enables web search).
|
|
59
|
+
* toggle — always flips, ignores extra words.
|
|
60
|
+
*/
|
|
61
|
+
kind?: "toggle" | "action" | "tool" | "client";
|
|
62
|
+
category?: SlashCategory;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Featured submenu labels. Full ISO map lives in digichat `languages.ts`. */
|
|
66
|
+
export const LANG_LABELS: Record<LangCode, string> = {
|
|
67
|
+
en: "English",
|
|
68
|
+
nl: "Dutch",
|
|
69
|
+
it: "Italian",
|
|
70
|
+
es: "Spanish",
|
|
71
|
+
fr: "French",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const LANG_CHOICES: readonly { value: LangCode; label: string }[] = LANG_CODES.map(
|
|
75
|
+
(code) => ({ value: code, label: LANG_LABELS[code] }),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const EFFORT_CODES = ["low", "medium", "high"] as const;
|
|
79
|
+
export type EffortCode = (typeof EFFORT_CODES)[number];
|
|
80
|
+
export const EFFORT_CHOICES: readonly { value: EffortCode; label: string }[] = EFFORT_CODES.map(
|
|
81
|
+
(code) => ({ value: code, label: code }),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
export function isEffortCode(value: string): value is EffortCode {
|
|
85
|
+
return (EFFORT_CODES as readonly string[]).includes(value.trim().toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Web-search engine ids (digisearch provider vocabulary, #4724). */
|
|
89
|
+
export const SEARCH_ENGINE_CODES = [
|
|
90
|
+
"auto",
|
|
91
|
+
"internal",
|
|
92
|
+
"exa",
|
|
93
|
+
"tavily",
|
|
94
|
+
"parallel",
|
|
95
|
+
"firecrawl",
|
|
96
|
+
"tinyfish",
|
|
97
|
+
] as const;
|
|
98
|
+
export type SearchEngineCode = (typeof SEARCH_ENGINE_CODES)[number];
|
|
99
|
+
export const SEARCH_ENGINE_CHOICES: readonly { value: SearchEngineCode; label: string }[] =
|
|
100
|
+
SEARCH_ENGINE_CODES.map((code) => ({
|
|
101
|
+
value: code,
|
|
102
|
+
label: code === "auto" ? "Auto \u00b7 in-house" : code.charAt(0).toUpperCase() + code.slice(1),
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
export function isSearchEngine(value: string): value is SearchEngineCode {
|
|
106
|
+
return (SEARCH_ENGINE_CODES as readonly string[]).includes(value.trim().toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const VIEW_CODES = ["quiet", "balanced", "verbose"] as const;
|
|
110
|
+
export type ViewCode = (typeof VIEW_CODES)[number];
|
|
111
|
+
/**
|
|
112
|
+
* The one home of the view-level wording. The composer menu's `/view` page
|
|
113
|
+
* prints it beside each level, and the palette's `/view` choice list is built
|
|
114
|
+
* from the same map, so the two surfaces name a level the same way. Keep it
|
|
115
|
+
* exported: a second, private copy of this map is what let a rename drift and
|
|
116
|
+
* leave one surface unlabelled (DIG-4665).
|
|
117
|
+
*
|
|
118
|
+
* The wording is the one the product prints today: `quiet` shows the titles
|
|
119
|
+
* only, `balanced` opens the rows while they stream, `verbose` leaves
|
|
120
|
+
* everything open. The retired `hidden` level has no entry: it folded into
|
|
121
|
+
* `quiet` (#6079).
|
|
122
|
+
*/
|
|
123
|
+
export const VIEW_LABELS: Record<ViewCode, string> = {
|
|
124
|
+
quiet: "Quiet \u00b7 titles only",
|
|
125
|
+
balanced: "Balanced \u00b7 open while it runs",
|
|
126
|
+
verbose: "Verbose \u00b7 everything open",
|
|
127
|
+
};
|
|
128
|
+
export const VIEW_CHOICES: readonly { value: ViewCode; label: string }[] = VIEW_CODES.map(
|
|
129
|
+
(code) => ({ value: code, label: VIEW_LABELS[code] }),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
export function isViewCode(value: string): value is ViewCode {
|
|
133
|
+
return (VIEW_CODES as readonly string[]).includes(value.trim().toLowerCase());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const DENSITY_CHOICES: readonly { value: TraceDensity; label: string }[] =
|
|
137
|
+
TRACE_DENSITIES.map((code) => ({ value: code, label: TRACE_DENSITY_LABELS[code] }));
|
|
138
|
+
|
|
139
|
+
export const THINKING_CODES = ["auto", "collapsed", "open"] as const;
|
|
140
|
+
export type ThinkingCode = (typeof THINKING_CODES)[number];
|
|
141
|
+
export const THINKING_CHOICES: readonly { value: ThinkingCode; label: string }[] =
|
|
142
|
+
THINKING_CODES.map((code) => ({ value: code, label: code.charAt(0).toUpperCase() + code.slice(1) }));
|
|
143
|
+
|
|
144
|
+
export function isThinkingCode(value: string): value is ThinkingCode {
|
|
145
|
+
return (THINKING_CODES as readonly string[]).includes(value.trim().toLowerCase());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const SLASH_CATEGORIES: readonly { id: SlashCategory; label: string }[] = [
|
|
149
|
+
{ id: "tools", label: "Tools" },
|
|
150
|
+
{ id: "session", label: "Session" },
|
|
151
|
+
{ id: "actions", label: "Actions" },
|
|
152
|
+
{ id: "setup", label: "Setup" },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/** `/export` formats. Values are the typed argument. */
|
|
156
|
+
export const EXPORT_CHOICES: readonly { value: string; label: string }[] = [
|
|
157
|
+
{ value: "md", label: "Markdown (.md)" },
|
|
158
|
+
{ value: "html", label: "HTML (.html)" },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
export const SLASH_COMMANDS: readonly SlashDef[] = [
|
|
162
|
+
{
|
|
163
|
+
id: "mcp",
|
|
164
|
+
names: ["/mcp"],
|
|
165
|
+
needsArg: false,
|
|
166
|
+
hint: "Connect and edit MCP servers.",
|
|
167
|
+
kind: "action",
|
|
168
|
+
category: "tools",
|
|
169
|
+
pane: true,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "tools",
|
|
173
|
+
names: ["/tools"],
|
|
174
|
+
needsArg: false,
|
|
175
|
+
hint: "Turn connected tools on or off.",
|
|
176
|
+
kind: "action",
|
|
177
|
+
category: "tools",
|
|
178
|
+
pane: true,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "lang",
|
|
182
|
+
names: ["/language", "/lang"],
|
|
183
|
+
needsArg: false,
|
|
184
|
+
hint: "Choose the reply language.",
|
|
185
|
+
choiceOptions: LANG_CHOICES,
|
|
186
|
+
kind: "client",
|
|
187
|
+
category: "setup",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "models",
|
|
191
|
+
names: ["/models"],
|
|
192
|
+
needsArg: false,
|
|
193
|
+
hint: "Choose the model for this chat.",
|
|
194
|
+
kind: "action",
|
|
195
|
+
category: "setup",
|
|
196
|
+
pane: true,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: "effort",
|
|
200
|
+
names: ["/effort"],
|
|
201
|
+
needsArg: false,
|
|
202
|
+
hint: "Set how hard the model thinks.",
|
|
203
|
+
choiceOptions: EFFORT_CHOICES,
|
|
204
|
+
kind: "client",
|
|
205
|
+
category: "setup",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "view",
|
|
209
|
+
names: ["/view"],
|
|
210
|
+
needsArg: false,
|
|
211
|
+
hint: "Choose how much of the work to show.",
|
|
212
|
+
choiceOptions: VIEW_CHOICES,
|
|
213
|
+
kind: "client",
|
|
214
|
+
category: "setup",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "density",
|
|
218
|
+
names: ["/density"],
|
|
219
|
+
needsArg: false,
|
|
220
|
+
hint: "Set the spacing of the work trace.",
|
|
221
|
+
choiceOptions: DENSITY_CHOICES,
|
|
222
|
+
kind: "client",
|
|
223
|
+
category: "setup",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "theme",
|
|
227
|
+
names: ["/theme"],
|
|
228
|
+
needsArg: false,
|
|
229
|
+
hint: "Switch the colour theme.",
|
|
230
|
+
kind: "client",
|
|
231
|
+
category: "setup",
|
|
232
|
+
pane: true,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "thinking",
|
|
236
|
+
names: ["/thinking"],
|
|
237
|
+
needsArg: false,
|
|
238
|
+
hint: "Choose whether reasoning stays open.",
|
|
239
|
+
choiceOptions: THINKING_CHOICES,
|
|
240
|
+
kind: "client",
|
|
241
|
+
category: "setup",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "search-engine",
|
|
245
|
+
names: ["/search-engine"],
|
|
246
|
+
needsArg: false,
|
|
247
|
+
hint: "Choose the web search engine.",
|
|
248
|
+
choiceOptions: SEARCH_ENGINE_CHOICES,
|
|
249
|
+
kind: "client",
|
|
250
|
+
category: "setup",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "byok",
|
|
254
|
+
names: ["/provider", "/byok", "/key"],
|
|
255
|
+
needsArg: false,
|
|
256
|
+
hint: "Use your own model provider.",
|
|
257
|
+
kind: "action",
|
|
258
|
+
category: "setup",
|
|
259
|
+
pane: true,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "settings",
|
|
263
|
+
names: ["/settings"],
|
|
264
|
+
needsArg: false,
|
|
265
|
+
hint: "Open every setting.",
|
|
266
|
+
kind: "action",
|
|
267
|
+
category: "setup",
|
|
268
|
+
pane: true,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: "toolbar",
|
|
272
|
+
names: ["/toolbar"],
|
|
273
|
+
needsArg: false,
|
|
274
|
+
hint: "Show or hide the composer toolbar.",
|
|
275
|
+
kind: "action",
|
|
276
|
+
category: "setup",
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "scrollbar",
|
|
280
|
+
names: ["/scrollbar"],
|
|
281
|
+
needsArg: false,
|
|
282
|
+
hint: "Show scrollbars always or while scrolling.",
|
|
283
|
+
kind: "action",
|
|
284
|
+
category: "setup",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "artifacts",
|
|
288
|
+
names: ["/artifacts"],
|
|
289
|
+
needsArg: false,
|
|
290
|
+
hint: "Open files from this session.",
|
|
291
|
+
kind: "action",
|
|
292
|
+
category: "session",
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: "private",
|
|
296
|
+
names: ["/private"],
|
|
297
|
+
needsArg: false,
|
|
298
|
+
hint: "Ask the model to use stand-in names in its replies.",
|
|
299
|
+
kind: "toggle",
|
|
300
|
+
category: "session",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "sessions",
|
|
304
|
+
names: ["/sessions", "/resume", "/continue"],
|
|
305
|
+
needsArg: false,
|
|
306
|
+
hint: "Switch to another conversation.",
|
|
307
|
+
kind: "action",
|
|
308
|
+
category: "session",
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: "new",
|
|
312
|
+
names: ["/new"],
|
|
313
|
+
needsArg: false,
|
|
314
|
+
hint: "Start a new conversation.",
|
|
315
|
+
kind: "client",
|
|
316
|
+
category: "session",
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: "clear",
|
|
320
|
+
names: ["/clear"],
|
|
321
|
+
needsArg: false,
|
|
322
|
+
hint: "Start a new conversation.",
|
|
323
|
+
kind: "client",
|
|
324
|
+
category: "session",
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: "compact",
|
|
328
|
+
names: ["/compact", "/summarize"],
|
|
329
|
+
needsArg: false,
|
|
330
|
+
hint: "Summarize this thread to free context.",
|
|
331
|
+
kind: "client",
|
|
332
|
+
category: "session",
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "undo",
|
|
336
|
+
names: ["/undo"],
|
|
337
|
+
needsArg: false,
|
|
338
|
+
hint: "Go back to the previous answer.",
|
|
339
|
+
kind: "client",
|
|
340
|
+
category: "actions",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
id: "redo",
|
|
344
|
+
names: ["/redo"],
|
|
345
|
+
needsArg: false,
|
|
346
|
+
hint: "Regenerate the last answer.",
|
|
347
|
+
kind: "client",
|
|
348
|
+
category: "actions",
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "copy",
|
|
352
|
+
names: ["/copy"],
|
|
353
|
+
needsArg: false,
|
|
354
|
+
hint: "Copy the last answer as markdown.",
|
|
355
|
+
kind: "client",
|
|
356
|
+
category: "actions",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "export",
|
|
360
|
+
names: ["/export"],
|
|
361
|
+
needsArg: false,
|
|
362
|
+
hint: "Download this conversation as a file.",
|
|
363
|
+
kind: "client",
|
|
364
|
+
category: "actions",
|
|
365
|
+
// A pane: `/export ` lists the formats, `/export md|html` runs one.
|
|
366
|
+
choiceOptions: EXPORT_CHOICES,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: "help",
|
|
370
|
+
names: ["/help"],
|
|
371
|
+
needsArg: false,
|
|
372
|
+
hint: "Show what you can ask.",
|
|
373
|
+
kind: "client",
|
|
374
|
+
category: "actions",
|
|
375
|
+
pane: true,
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
export type SlashVisibility = {
|
|
380
|
+
webSearch?: boolean;
|
|
381
|
+
byok?: boolean;
|
|
382
|
+
digisearch?: boolean;
|
|
383
|
+
digivault?: boolean;
|
|
384
|
+
/** Full-app thread list. Embed hides /sessions. */
|
|
385
|
+
sessions?: boolean;
|
|
386
|
+
models?: boolean;
|
|
387
|
+
effort?: boolean;
|
|
388
|
+
mcp?: boolean;
|
|
389
|
+
/** Connected-tools palette. Hide when the install has no catalog/MCP. */
|
|
390
|
+
tools?: boolean;
|
|
391
|
+
/** Artifact list for this session. Hide where there is no artifact surface. */
|
|
392
|
+
artifacts?: boolean;
|
|
393
|
+
/** Colour theme. Hidden only when a host sets `themeLocked`; tenant embeds own the pick (`setTheme`). */
|
|
394
|
+
theme?: boolean;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
export type ParsedSlash =
|
|
398
|
+
| { kind: "none" }
|
|
399
|
+
| { kind: "incomplete"; command: SlashDef; prefix: string }
|
|
400
|
+
| { kind: "command"; command: SlashDef; arg: string }
|
|
401
|
+
| { kind: "unknown"; name: string };
|
|
402
|
+
|
|
403
|
+
function allDefs(extra?: readonly SlashDef[]): SlashDef[] {
|
|
404
|
+
if (!extra?.length) return [...SLASH_COMMANDS];
|
|
405
|
+
const seen = new Set(SLASH_COMMANDS.map((c) => c.id));
|
|
406
|
+
return [...SLASH_COMMANDS, ...extra.filter((c) => !seen.has(c.id))];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function parseSlashInput(raw: string, extra?: readonly SlashDef[]): ParsedSlash {
|
|
410
|
+
const text = raw.trim();
|
|
411
|
+
if (!text.startsWith("/")) return { kind: "none" };
|
|
412
|
+
const [name, ...rest] = text.split(/\s+/);
|
|
413
|
+
const arg = rest.join(" ").trim();
|
|
414
|
+
const needle = name.toLowerCase();
|
|
415
|
+
const command = allDefs(extra).find((c) =>
|
|
416
|
+
c.names.some((n) => n.toLowerCase() === needle),
|
|
417
|
+
);
|
|
418
|
+
if (!command) return { kind: "unknown", name };
|
|
419
|
+
if (command.needsArg && !arg) {
|
|
420
|
+
return { kind: "incomplete", command, prefix: `${command.names[0]} ` };
|
|
421
|
+
}
|
|
422
|
+
return { kind: "command", command, arg };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isVisible(cmd: SlashDef, visibility?: SlashVisibility): boolean {
|
|
426
|
+
if (cmd.id === "search-engine") return visibility?.webSearch === true;
|
|
427
|
+
if (cmd.id === "byok") return visibility?.byok !== false;
|
|
428
|
+
if (cmd.id === "sessions") return visibility?.sessions === true;
|
|
429
|
+
if (cmd.id === "models" || cmd.id === "effort") return visibility?.models !== false;
|
|
430
|
+
if (cmd.id === "mcp") return visibility?.mcp !== false;
|
|
431
|
+
if (cmd.id === "tools") return visibility?.tools !== false;
|
|
432
|
+
if (cmd.id === "artifacts") return visibility?.artifacts === true;
|
|
433
|
+
if (cmd.id === "theme") return visibility?.theme !== false;
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Palette rows while the composer holds a slash prefix and no argument yet. */
|
|
438
|
+
export function matchingSlashCommands(
|
|
439
|
+
input: string,
|
|
440
|
+
visibility?: SlashVisibility,
|
|
441
|
+
extra?: readonly SlashDef[],
|
|
442
|
+
): SlashDef[] {
|
|
443
|
+
const q = input.trim().toLowerCase();
|
|
444
|
+
if (!q.startsWith("/")) return [];
|
|
445
|
+
if (/\s/.test(q)) return [];
|
|
446
|
+
return allDefs(extra).filter(
|
|
447
|
+
(c) =>
|
|
448
|
+
isVisible(c, visibility) &&
|
|
449
|
+
// A name must start with what was typed. The reverse (typed text starts
|
|
450
|
+
// with a name) would keep a row on screen after the user typed past it,
|
|
451
|
+
// so `/mcpx` still offered `/mcp`.
|
|
452
|
+
c.names.some((n) => n.toLowerCase().startsWith(q)),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function slashHelpText(
|
|
457
|
+
visibility?: SlashVisibility,
|
|
458
|
+
extra?: readonly SlashDef[],
|
|
459
|
+
): string {
|
|
460
|
+
return allDefs(extra)
|
|
461
|
+
.filter((c) => isVisible(c, visibility))
|
|
462
|
+
.map((c) => `${c.names[0]} — ${c.hint}`)
|
|
463
|
+
.join("\n");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function isLangCode(value: string): value is LangCode {
|
|
467
|
+
return (LANG_CODES as readonly string[]).includes(value.trim().toLowerCase());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Wrap palette highlight for Up/Down navigation. */
|
|
471
|
+
export function nextPaletteIndex(current: number, delta: number, length: number): number {
|
|
472
|
+
if (length <= 0) return 0;
|
|
473
|
+
return ((current + delta) % length + length) % length;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export type CliSettingRow =
|
|
477
|
+
| {
|
|
478
|
+
id: string;
|
|
479
|
+
label: string;
|
|
480
|
+
description: string;
|
|
481
|
+
kind: "toggle";
|
|
482
|
+
value: boolean;
|
|
483
|
+
}
|
|
484
|
+
| {
|
|
485
|
+
id: string;
|
|
486
|
+
label: string;
|
|
487
|
+
description: string;
|
|
488
|
+
kind: "choice";
|
|
489
|
+
value: string;
|
|
490
|
+
options: readonly { value: string; label: string }[];
|
|
491
|
+
}
|
|
492
|
+
| {
|
|
493
|
+
id: string;
|
|
494
|
+
label: string;
|
|
495
|
+
description: string;
|
|
496
|
+
kind: "action";
|
|
497
|
+
actionLabel: string;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
export function formatCliSettingLine(row: CliSettingRow, selected: boolean): string {
|
|
501
|
+
const mark = selected ? ">" : " ";
|
|
502
|
+
if (row.kind === "toggle") {
|
|
503
|
+
return `${mark} [${row.value ? "on" : "off"}] ${row.label} — ${row.description}`;
|
|
504
|
+
}
|
|
505
|
+
if (row.kind === "choice") {
|
|
506
|
+
const current =
|
|
507
|
+
row.options.find((o) => o.value === row.value)?.label ?? row.value;
|
|
508
|
+
return `${mark} ${row.label}: ${current} — ${row.description}`;
|
|
509
|
+
}
|
|
510
|
+
return `${mark} ${row.label} → ${row.actionLabel} — ${row.description}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function commandOpensPane(def: SlashDef): boolean {
|
|
514
|
+
return def.pane === true || (def.choiceOptions?.length ?? 0) > 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const MENU_ID: Record<string, string> = { lang: "language", byok: "provider" };
|
|
518
|
+
|
|
519
|
+
/** Menu id for a command id. `lang` is the language pane; `byok` is provider. */
|
|
520
|
+
export function menuIdForCommand(id: string): string {
|
|
521
|
+
return MENU_ID[id] ?? id;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export type PaletteKind = "closed" | "commands" | "pane";
|
|
525
|
+
|
|
526
|
+
export type PaletteFrame =
|
|
527
|
+
| { kind: "closed" }
|
|
528
|
+
| { kind: "commands"; filter: string }
|
|
529
|
+
| { kind: "pane"; command: SlashDef; menu: string; filter: string };
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* One composer string, one surface. A pane command plus a trailing space
|
|
533
|
+
* (`/language `, `/language English`, `/language /`) is that pane. The same
|
|
534
|
+
* token without the space is the parent command list, so backspacing out of
|
|
535
|
+
* the sequence returns there. Anything else that starts with `/` is the
|
|
536
|
+
* command list. A second palette is never a separate surface.
|
|
537
|
+
*/
|
|
538
|
+
export function paletteFrame(
|
|
539
|
+
text: string,
|
|
540
|
+
defs: readonly SlashDef[] = SLASH_COMMANDS,
|
|
541
|
+
): PaletteFrame {
|
|
542
|
+
if (!text.startsWith("/") || text.includes("\n")) return { kind: "closed" };
|
|
543
|
+
// First whitespace only. A single `\s` search stays linear.
|
|
544
|
+
const body = text.slice(1);
|
|
545
|
+
const splitAt = body.search(/\s/);
|
|
546
|
+
const token = splitAt === -1 ? body : body.slice(0, splitAt);
|
|
547
|
+
const rest = splitAt === -1 ? "" : body.slice(splitAt);
|
|
548
|
+
if (!token) return { kind: "commands", filter: "" };
|
|
549
|
+
const needle = `/${token.toLowerCase()}`;
|
|
550
|
+
const command = defs.find((def) => def.names.some((name) => name.toLowerCase() === needle));
|
|
551
|
+
if (command && commandOpensPane(command) && rest.startsWith(" ")) {
|
|
552
|
+
return {
|
|
553
|
+
kind: "pane",
|
|
554
|
+
command,
|
|
555
|
+
menu: menuIdForCommand(command.id),
|
|
556
|
+
filter: rest.slice(1),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return { kind: "commands", filter: token.toLowerCase() };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Slash popover and menu are mutually exclusive. */
|
|
563
|
+
export function paletteSurfaces(
|
|
564
|
+
text: string,
|
|
565
|
+
defs: readonly SlashDef[] = SLASH_COMMANDS,
|
|
566
|
+
): { slash: boolean; menu: string | null } {
|
|
567
|
+
const frame = paletteFrame(text, defs);
|
|
568
|
+
if (frame.kind === "pane") return { slash: false, menu: frame.menu };
|
|
569
|
+
if (frame.kind === "commands") return { slash: true, menu: null };
|
|
570
|
+
return { slash: false, menu: null };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export type PaletteApply = {
|
|
574
|
+
kind: PaletteKind;
|
|
575
|
+
menu: "close" | "keep" | string;
|
|
576
|
+
filter: string;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Apply a frame on top of the previous one. A button-opened menu (settings)
|
|
581
|
+
* stays while the composer is ordinary text. Leaving a pane closes it.
|
|
582
|
+
*/
|
|
583
|
+
export function applyPaletteFrame(
|
|
584
|
+
prevKind: PaletteKind,
|
|
585
|
+
frame: { kind: PaletteKind; menu?: string; filter?: string },
|
|
586
|
+
): PaletteApply {
|
|
587
|
+
if (frame.kind === "pane" && frame.menu) {
|
|
588
|
+
return { kind: "pane", menu: frame.menu, filter: frame.filter ?? "" };
|
|
589
|
+
}
|
|
590
|
+
if (frame.kind === "commands" || prevKind === "pane") {
|
|
591
|
+
return {
|
|
592
|
+
kind: frame.kind === "commands" ? "commands" : "closed",
|
|
593
|
+
menu: "close",
|
|
594
|
+
filter: "",
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
return { kind: "closed", menu: "keep", filter: "" };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Composer draft written when a pane command is chosen: `/language `. */
|
|
601
|
+
export function paneDraftForId(
|
|
602
|
+
id: string,
|
|
603
|
+
defs: readonly SlashDef[] = SLASH_COMMANDS,
|
|
604
|
+
): string | null {
|
|
605
|
+
const def = defs.find((command) => command.id === id || menuIdForCommand(command.id) === id);
|
|
606
|
+
if (!def || !commandOpensPane(def)) return null;
|
|
607
|
+
return `${def.names[0]} `;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Full sequence for an option: `/language English`. */
|
|
611
|
+
export function optionDraft(commandName: string, option: string): string {
|
|
612
|
+
const name = commandName.startsWith("/") ? commandName : `/${commandName}`;
|
|
613
|
+
const opt = option.trim();
|
|
614
|
+
return opt ? `${name} ${opt}` : `${name} `;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export type SubmenuOption = {
|
|
618
|
+
id: string;
|
|
619
|
+
/** Text after the command that selects this row (`English`, `default dark`). */
|
|
620
|
+
token: string;
|
|
621
|
+
label?: string;
|
|
622
|
+
aliases?: readonly string[];
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Filter a pane. An exact option token keeps the full list and highlights
|
|
627
|
+
* that row, so the next arrow still moves. A partial query uses startsWith
|
|
628
|
+
* on the token, id, label, and aliases. With `contains` (model lists), a
|
|
629
|
+
* case-insensitive substring match is enough, ranked: a name (label or
|
|
630
|
+
* alias) that starts with the query, then any key that starts with it, then
|
|
631
|
+
* a match inside a key; ties keep list order.
|
|
632
|
+
*/
|
|
633
|
+
export function submenuRows<T extends SubmenuOption>(
|
|
634
|
+
rows: readonly T[],
|
|
635
|
+
filter: string,
|
|
636
|
+
opts: { contains?: boolean } = {},
|
|
637
|
+
): { rows: readonly T[]; cursor: number } {
|
|
638
|
+
const q = filter.trim().toLowerCase();
|
|
639
|
+
if (!q) return { rows, cursor: -1 };
|
|
640
|
+
const exact = rows.findIndex((row) => optionKeys(row).includes(q));
|
|
641
|
+
if (exact >= 0) return { rows, cursor: exact };
|
|
642
|
+
const keys = (row: T) =>
|
|
643
|
+
[row.token, row.id, row.label ?? "", ...(row.aliases ?? [])].map((value) =>
|
|
644
|
+
value.trim().toLowerCase(),
|
|
645
|
+
);
|
|
646
|
+
if (!opts.contains) {
|
|
647
|
+
const matched = rows.filter((row) => keys(row).some((value) => value.startsWith(q)));
|
|
648
|
+
return { rows: matched, cursor: matched.length ? 0 : -1 };
|
|
649
|
+
}
|
|
650
|
+
const rank = (row: T): number => {
|
|
651
|
+
const names = [row.label ?? "", ...(row.aliases ?? [])].map((value) => value.trim().toLowerCase());
|
|
652
|
+
if (names.some((value) => value.startsWith(q))) return 0;
|
|
653
|
+
const all = keys(row);
|
|
654
|
+
if (all.some((value) => value.startsWith(q))) return 1;
|
|
655
|
+
return all.some((value) => value.includes(q)) ? 2 : -1;
|
|
656
|
+
};
|
|
657
|
+
const matched = rows
|
|
658
|
+
.map((row, index) => ({ row, index, rank: rank(row) }))
|
|
659
|
+
.filter((entry) => entry.rank >= 0)
|
|
660
|
+
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
|
661
|
+
.map((entry) => entry.row);
|
|
662
|
+
return { rows: matched, cursor: matched.length ? 0 : -1 };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function optionKeys(row: SubmenuOption): string[] {
|
|
666
|
+
return [row.token, ...(row.aliases ?? [])]
|
|
667
|
+
.map((value) => value.trim().toLowerCase())
|
|
668
|
+
.filter((value) => value.length > 0);
|
|
669
|
+
}
|