@4djs/assistant 0.0.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.
Files changed (68) hide show
  1. package/README.md +322 -0
  2. package/package.json +41 -0
  3. package/src/core/chat-activity.ts +107 -0
  4. package/src/core/chat-commands.ts +173 -0
  5. package/src/core/chat-history.ts +113 -0
  6. package/src/core/chat-reply-suggestions-parse.ts +119 -0
  7. package/src/core/code-highlight.ts +20 -0
  8. package/src/core/create-assistant-store.ts +639 -0
  9. package/src/core/fetch-suggested-prompts.ts +53 -0
  10. package/src/core/index.ts +125 -0
  11. package/src/core/interactive-tools/choices.ts +155 -0
  12. package/src/core/interactive-tools/confirmation.ts +63 -0
  13. package/src/core/interactive-tools/constants.ts +22 -0
  14. package/src/core/interactive-tools/execute.ts +70 -0
  15. package/src/core/interactive-tools/index.ts +41 -0
  16. package/src/core/interactive-tools/suggestions.ts +87 -0
  17. package/src/core/interactive-tools/waiters.ts +55 -0
  18. package/src/core/llm-chat.ts +686 -0
  19. package/src/core/llm-config.ts +101 -0
  20. package/src/core/llm-models.ts +96 -0
  21. package/src/core/llm-provider.ts +99 -0
  22. package/src/core/llm-settings-storage.ts +331 -0
  23. package/src/core/llm-sse.ts +166 -0
  24. package/src/core/llm-types.ts +52 -0
  25. package/src/core/markdown-utils.ts +11 -0
  26. package/src/core/prepare-markdown.ts +38 -0
  27. package/src/core/types.ts +86 -0
  28. package/src/css.d.ts +1 -0
  29. package/src/react/Assistant.tsx +358 -0
  30. package/src/react/components/HighlightedJsonCode.tsx +24 -0
  31. package/src/react/components/MarkdownContent.tsx +98 -0
  32. package/src/react/components/MarkdownEditor.tsx +60 -0
  33. package/src/react/components/MermaidDiagram.tsx +139 -0
  34. package/src/react/components/ModelSelector.tsx +243 -0
  35. package/src/react/components/chat/AssistantErrorCallout.tsx +79 -0
  36. package/src/react/components/chat/ChatActivity.tsx +274 -0
  37. package/src/react/components/chat/ChatComposer.tsx +189 -0
  38. package/src/react/components/chat/ChatEmptyState.tsx +145 -0
  39. package/src/react/components/chat/ChatInteractivePrompt/choices-prompt.tsx +262 -0
  40. package/src/react/components/chat/ChatInteractivePrompt/confirmation-prompt.tsx +97 -0
  41. package/src/react/components/chat/ChatInteractivePrompt/index.tsx +60 -0
  42. package/src/react/components/chat/ChatInteractivePrompt/shell.tsx +60 -0
  43. package/src/react/components/chat/ChatInteractivePrompt/utils.ts +14 -0
  44. package/src/react/components/chat/ChatMessage.tsx +150 -0
  45. package/src/react/components/chat/ChatMessageScroll.tsx +116 -0
  46. package/src/react/components/chat/ChatReplySuggestions.tsx +231 -0
  47. package/src/react/components/chat/ComposerCommandMenu.tsx +69 -0
  48. package/src/react/components/chat/LlmSettingsStrip.tsx +348 -0
  49. package/src/react/components/chat/LlmSetupPrompt.tsx +58 -0
  50. package/src/react/components/chat/LlmUnavailableBanner.tsx +11 -0
  51. package/src/react/components/chat/SuggestedPromptsList.tsx +121 -0
  52. package/src/react/components/chat/SuggestedPromptsStrip.tsx +72 -0
  53. package/src/react/components/chat/SystemPromptField.tsx +107 -0
  54. package/src/react/components/highlighted-code.tsx +107 -0
  55. package/src/react/context.tsx +72 -0
  56. package/src/react/hooks/use-composer-commands.ts +129 -0
  57. package/src/react/hooks/use-suggested-prompts.ts +128 -0
  58. package/src/react/index.ts +39 -0
  59. package/src/react/lib/parse-assistant-error.ts +96 -0
  60. package/src/react/lib/prompt-icons.ts +40 -0
  61. package/src/react/types.ts +83 -0
  62. package/src/react/utils/cn.ts +5 -0
  63. package/src/styles/assistant.css +3009 -0
  64. package/test/buildLlmHistory.test.ts +95 -0
  65. package/test/llm-config.test.ts +72 -0
  66. package/test/llmSettingsStorage.test.ts +121 -0
  67. package/test/parse-assistant-error.test.ts +24 -0
  68. package/tsconfig.json +8 -0
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # @4d/assistant
2
+
3
+ Embeddable React chat assistant with LLM tool-calling, streaming, markdown rendering, and optional fallback mode when no LLM is configured.
4
+
5
+ ## Install
6
+
7
+ This package lives in the monorepo as a workspace dependency:
8
+
9
+ ```json
10
+ {
11
+ "dependencies": {
12
+ "@4d/assistant": "workspace:*"
13
+ }
14
+ }
15
+ ```
16
+
17
+ Peer dependencies: `react` and `react-dom` (^19).
18
+
19
+ Import styles once in your app entry (required for the UI):
20
+
21
+ ```ts
22
+ import "@4d/assistant/styles.css";
23
+ ```
24
+
25
+ The stylesheet uses CSS variables from your host app (`--brand`, `--surface-panel`, `--text-heading`, etc.). Define those tokens in your global CSS so the assistant matches your design system.
26
+
27
+ ## Quick start
28
+
29
+ The simplest integration is `AssistantRoot`, which wires the provider, store bootstrap, and default panel UI:
30
+
31
+ ```tsx
32
+ import { AssistantRoot, type AssistantConfig } from "@4d/assistant";
33
+
34
+ const config: AssistantConfig = {
35
+ llm: {
36
+ enabled: Boolean(import.meta.env.LLM_KEY),
37
+ baseUrl: import.meta.env.LLM_BASE_URL ?? "https://api.openai.com/v1",
38
+ apiKey: import.meta.env.LLM_KEY ?? null,
39
+ model: import.meta.env.LLM_MODEL ?? "gpt-4o-mini",
40
+ systemPrompt: "You are a helpful assistant…",
41
+ },
42
+ storageKeys: {
43
+ history: "my-app-chat-history",
44
+ llmSettings: "my-app-llm-settings",
45
+ },
46
+ welcomeMessage: ({ llmEnabled, model }) => ({
47
+ id: "welcome",
48
+ role: "assistant",
49
+ content: llmEnabled
50
+ ? `Hello! Using **${model ?? "LLM"}**.`
51
+ : "Hello! LLM is disabled — try `/clear` or use fallback commands.",
52
+ timestamp: Date.now(),
53
+ }),
54
+ listTools: async () => myTools,
55
+ invokeTool: async (name, args) => myToolRunner(name, args),
56
+ };
57
+
58
+ export function MyAssistant() {
59
+ return <AssistantRoot config={config} />;
60
+ }
61
+ ```
62
+
63
+ See [`apps/web/src/assistant/config.ts`](../../apps/web/src/assistant/config.ts) for a full production example wired to the 4D tool registry.
64
+
65
+ ## Configuration
66
+
67
+ `AssistantConfig` extends the store dependencies with UI options.
68
+
69
+ ### Required store hooks
70
+
71
+ | Option | Description |
72
+ | --- | --- |
73
+ | `welcomeMessage(ctx)` | Returns the initial assistant message. Called again when LLM status or model changes. |
74
+ | `listTools()` | Returns OpenAI-style tool definitions (`name`, `description`, `inputSchema`). |
75
+ | `invokeTool(name, args)` | Executes a tool and returns `{ content, isError?, structuredContent? }`. |
76
+
77
+ ### Optional store hooks
78
+
79
+ | Option | Description |
80
+ | --- | --- |
81
+ | `llm` | OpenAI-compatible provider settings (see below). |
82
+ | `storageKeys` | `localStorage` keys for chat history and LLM settings (including selected model). |
83
+ | `fallbackHandler` | Called when LLM is disabled. Return an `AssistantMessage` or `null`. |
84
+ | `onToolInvoked` | Side-effect hook after each tool completes (e.g. refresh app state). |
85
+ | `fetchSuggestedPrompts` | Async hook to generate contextual starter prompts via LLM. |
86
+ | `autoLoadLlmStatus` | Fetch LLM config on mount (default: `true`). |
87
+
88
+ ### UI options
89
+
90
+ ```ts
91
+ {
92
+ header: {
93
+ title: "Assistant",
94
+ subtitle: "Query and explore your datastore",
95
+ icon: Sparkles, // lucide-react icon
96
+ showClearButton: true, // footer toolbar (default: true)
97
+ showSuggestionsButton: true, // footer toolbar when fetchSuggestedPrompts is set
98
+ },
99
+ emptyState: {
100
+ title: "Get started",
101
+ description: "Ask a question or pick a suggestion below.",
102
+ dynamicSuggestedPrompts: true, // manual LLM fetch on welcome screen
103
+ suggestedPrompts: [ // static fallback when LLM is off
104
+ { id: "catalog", label: "Show catalog", prompt: "catalog", icon: Database },
105
+ ],
106
+ },
107
+ ui: {
108
+ composerPlaceholder: "Ask the assistant…",
109
+ showModelSelector: true,
110
+ maxWidth: "56rem",
111
+ className: "panel", // extra class on the root panel
112
+ },
113
+ }
114
+ ```
115
+
116
+ Per-instance overrides are supported via `Assistant` / `AssistantRoot` props: `header`, `emptyState`, `ui`.
117
+
118
+ ## LLM provider
119
+
120
+ The assistant calls an **OpenAI-compatible** chat completions API directly from the browser (or your host runtime). No proxy `/api/chat` routes are required.
121
+
122
+ Pass `llm` on `AssistantConfig`:
123
+
124
+ ```ts
125
+ import type { AssistantLlmSettings } from "@4d/assistant/core";
126
+
127
+ const llm: AssistantLlmSettings = {
128
+ enabled: true,
129
+ baseUrl: "https://api.openai.com/v1",
130
+ apiKey: import.meta.env.LLM_KEY ?? null,
131
+ model: "gpt-4o-mini",
132
+ systemPrompt: "You are a helpful assistant with access to tools.",
133
+ models: ["gpt-4o-mini", "gpt-4o"], // optional static list
134
+ };
135
+ ```
136
+
137
+ | Field | Description |
138
+ | --- | --- |
139
+ | `enabled` | When `false`, or when `apiKey` is missing, fallback mode is used. |
140
+ | `baseUrl` | Provider base URL (e.g. `https://api.openai.com/v1`). |
141
+ | `apiKey` | Bearer token for the provider. |
142
+ | `model` | Default model when the user has not picked one. |
143
+ | `models` | Optional static model list. When omitted, models are fetched from `{baseUrl}/models`. |
144
+ | `systemPrompt` | Prepended system message for each completion. |
145
+
146
+ You can also pass a resolver function for dynamic config:
147
+
148
+ ```ts
149
+ llm: async () => ({
150
+ enabled: true,
151
+ baseUrl: "https://api.openai.com/v1",
152
+ apiKey: await getApiKeyFromSecureStorage(),
153
+ model: "gpt-4o-mini",
154
+ }),
155
+ ```
156
+
157
+ Configure globally outside React:
158
+
159
+ ```ts
160
+ import { configureAssistantLlm } from "@4d/assistant/core";
161
+
162
+ configureAssistantLlm({ enabled: true, baseUrl: "…", apiKey: "…", model: "…" });
163
+ ```
164
+
165
+ ### Environment variables (4D web app)
166
+
167
+ The playground reads these from `.env.local` (exposed to the client via Vite `envPrefix`):
168
+
169
+ | Variable | Description |
170
+ | --- | --- |
171
+ | `LLM_KEY` | API key (required to enable LLM) |
172
+ | `LLM_BASE_URL` | Provider URL (default: OpenAI) |
173
+ | `LLM_MODEL` | Default model |
174
+ | `LLM_MODELS` | Comma-separated extra models for the selector |
175
+
176
+ **Note:** Because calls are made directly to the provider, the API key is available to the client bundle. Use this pattern for local/dev playgrounds or when the key is scoped and acceptable in the browser.
177
+
178
+ ## Wiring tools
179
+
180
+ Tools must match the shape expected by the LLM agent:
181
+
182
+ ```ts
183
+ import type { AssistantToolDefinition, AssistantToolResult } from "@4d/assistant";
184
+
185
+ const tools: AssistantToolDefinition[] = [
186
+ {
187
+ name: "query_entity",
188
+ description: "Query records from a dataclass",
189
+ inputSchema: {
190
+ type: "object",
191
+ properties: {
192
+ entity: { type: "string" },
193
+ filter: { type: "string" },
194
+ },
195
+ required: ["entity"],
196
+ },
197
+ },
198
+ ];
199
+
200
+ async function invokeTool(
201
+ name: string,
202
+ args: Record<string, unknown>,
203
+ ): Promise<AssistantToolResult> {
204
+ // run tool, return text content for the model
205
+ return {
206
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
207
+ };
208
+ }
209
+ ```
210
+
211
+ The store passes `listTools` output to the LLM on each turn and calls `invokeTool` for each tool call in the response.
212
+
213
+ ## Suggested prompts
214
+
215
+ Two modes:
216
+
217
+ 1. **Static** — set `emptyState.suggestedPrompts` with `label`, `hint`, `prompt`, and optional Lucide `icon`.
218
+ 2. **Dynamic** — implement `fetchSuggestedPrompts`. The UI does **not** auto-fetch; users click **Generate suggestions** on the welcome screen or the sparkles button in the composer footer.
219
+
220
+ Dynamic fetch receives `{ llmEnabled, model, tools }` and should return:
221
+
222
+ ```ts
223
+ Array<{
224
+ id: string;
225
+ label: string;
226
+ description?: string;
227
+ prompt: string;
228
+ icon?: string; // e.g. "database", "search", "sparkles"
229
+ }>
230
+ ```
231
+
232
+ Use `parseSuggestedPromptsResponse` from `@4d/assistant/core` to validate LLM JSON output.
233
+
234
+ ## Fallback mode (no LLM)
235
+
236
+ When `configUrl` reports `enabled: false`, the assistant skips the LLM and calls `fallbackHandler` instead:
237
+
238
+ ```ts
239
+ fallbackHandler: async ({ message, tools }) => {
240
+ if (message === "catalog") {
241
+ return {
242
+ id: crypto.randomUUID(),
243
+ role: "assistant",
244
+ content: "Here is your catalog…",
245
+ timestamp: Date.now(),
246
+ };
247
+ }
248
+ return null;
249
+ },
250
+ ```
251
+
252
+ Return `null` to show a generic “LLM disabled” message.
253
+
254
+ ## Custom composition
255
+
256
+ Use lower-level exports to build your own layout:
257
+
258
+ ```tsx
259
+ import {
260
+ AssistantProvider,
261
+ AssistantBootstrap,
262
+ Assistant,
263
+ useAssistant,
264
+ useAssistantActions,
265
+ } from "@4d/assistant";
266
+
267
+ function CustomShell() {
268
+ const messages = useAssistant((s) => s.messages);
269
+ const { sendChat } = useAssistantActions();
270
+ // render your own chrome around <Assistant /> or individual chat components
271
+ }
272
+
273
+ export function App() {
274
+ return (
275
+ <AssistantProvider config={config}>
276
+ <AssistantBootstrap>
277
+ <CustomShell />
278
+ </AssistantProvider>
279
+ </AssistantProvider>
280
+ );
281
+ }
282
+ ```
283
+
284
+ Exported building blocks include `ChatComposer`, `ChatMessageView`, `ChatEmptyState`, `ChatActivity`, `ModelSelector`, `MarkdownContent`, and `MermaidDiagram`.
285
+
286
+ ## Composer commands
287
+
288
+ The input supports slash commands (e.g. `/clear`). Extend commands in `@4d/assistant/core` via `chat-commands.ts` or handle custom logic before `sendChat` in your host app.
289
+
290
+ ## Core package
291
+
292
+ Import headless logic without React:
293
+
294
+ ```ts
295
+ import {
296
+ createAssistantStore,
297
+ runLlmAgent,
298
+ buildLlmHistory,
299
+ fetchLlmStatus,
300
+ runAssistantChatCommand,
301
+ } from "@4d/assistant/core";
302
+ ```
303
+
304
+ Useful for server routes, tests, or a fully custom UI.
305
+
306
+ ## Package exports
307
+
308
+ | Import | Contents |
309
+ | --- | --- |
310
+ | `@4d/assistant` | React components, hooks, types |
311
+ | `@4d/assistant/core` | Store, LLM client, commands, interactive tools |
312
+ | `@4d/assistant/styles.css` | Component styles |
313
+
314
+ ## Features
315
+
316
+ - Streaming LLM responses with tool-call activity timeline
317
+ - Interactive tool UI (confirmations, choices, reply suggestions)
318
+ - Markdown + GFM + math (KaTeX) + Mermaid diagrams
319
+ - Model selector with searchable dropdown
320
+ - Persistent chat history and model preference (`localStorage`)
321
+ - Welcome screen with static or LLM-generated starter prompts
322
+ - Composer footer toolbar: model selector, generate suggestions, clear chat
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@4djs/assistant",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/react/index.ts",
7
+ "./core": "./src/core/index.ts",
8
+ "./styles.css": "./src/styles/assistant.css"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "build": "bun run build:core && bun run build:react && bun run build:styles",
13
+ "build:core": "bun build ./src/core/index.ts --outdir ./dist/core --target node --format esm",
14
+ "build:react": "bun build ./src/react/index.ts --outdir ./dist --target browser --external react --external react-dom --format esm",
15
+ "build:styles": "mkdir -p dist && cp src/styles/assistant.css dist/styles.css",
16
+ "prepack": "bun run build"
17
+ },
18
+ "dependencies": {
19
+ "highlight.js": "11.11.1",
20
+ "katex": "0.17.0",
21
+ "lucide-react": "^0.511.0",
22
+ "mermaid": "11.15.0",
23
+ "react-markdown": "10.1.0",
24
+ "rehype-katex": "7.0.1",
25
+ "remark-gfm": "4.0.1",
26
+ "remark-math": "6.0.0",
27
+ "zustand": "^5.0.5"
28
+ },
29
+ "peerDependencies": {
30
+ "react": "^19.0.0",
31
+ "react-dom": "^19.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@4d/typescript-config": "workspace:*",
35
+ "@types/react": "^19.1.2",
36
+ "@types/react-dom": "^19.1.2",
37
+ "react": "^19.1.0",
38
+ "react-dom": "^19.1.0",
39
+ "typescript": "catalog:"
40
+ }
41
+ }
@@ -0,0 +1,107 @@
1
+ export type ChatActivityStatus = "active" | "done" | "error";
2
+
3
+ export interface ChatActivityStep {
4
+ id: string;
5
+ kind: "tool";
6
+ name: string;
7
+ args: Record<string, unknown>;
8
+ callId?: string;
9
+ status: ChatActivityStatus;
10
+ result?: unknown;
11
+ error?: string;
12
+ }
13
+
14
+ export function summarizeActivityResult(result: unknown): unknown {
15
+ if (result === undefined || result === null) return result;
16
+ if (typeof result !== "object") return result;
17
+
18
+ const value = result as Record<string, unknown>;
19
+ if ("structuredContent" in value) {
20
+ return value.structuredContent ?? value;
21
+ }
22
+ if ("content" in value && Array.isArray(value.content)) {
23
+ return value.content;
24
+ }
25
+ return result;
26
+ }
27
+
28
+ export function chatActivityStepLabel(step: ChatActivityStep): string {
29
+ if (step.status === "active") {
30
+ if (step.name === "request_confirmation")
31
+ return "Waiting for confirmation…";
32
+ if (step.name === "request_choices") return "Waiting for your choice…";
33
+ if (step.name === "suggest_replies") return "Suggesting replies…";
34
+ return `Running ${step.name}…`;
35
+ }
36
+ if (step.status === "error") return `${step.name} failed`;
37
+ if (step.name === "request_confirmation") return "Confirmation answered";
38
+ if (step.name === "request_choices") return "Choice submitted";
39
+ if (step.name === "suggest_replies") return "Reply suggestions shown";
40
+ return `Ran ${step.name}`;
41
+ }
42
+
43
+ export function formatJsonIfLarge(raw: string): string {
44
+ const trimmed = raw.trim();
45
+ const JSON_PRETTY_MIN_LENGTH = 80;
46
+
47
+ if (trimmed.length < JSON_PRETTY_MIN_LENGTH) {
48
+ return trimmed;
49
+ }
50
+
51
+ try {
52
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
53
+ } catch {
54
+ return trimmed;
55
+ }
56
+ }
57
+
58
+ export function formatActivityJson(raw?: string): string | null {
59
+ if (!raw?.trim()) return null;
60
+
61
+ try {
62
+ return JSON.stringify(JSON.parse(raw), null, 2);
63
+ } catch {
64
+ return raw;
65
+ }
66
+ }
67
+
68
+ export function formatActivityJsonValue(value: unknown): string | null {
69
+ if (value === undefined || value === null) return null;
70
+ if (typeof value === "string") return formatActivityJson(value);
71
+ try {
72
+ return JSON.stringify(value, null, 2);
73
+ } catch {
74
+ return String(value);
75
+ }
76
+ }
77
+
78
+ export function isEmptyActivityJson(value: unknown): boolean {
79
+ if (value === undefined || value === null) return true;
80
+
81
+ if (typeof value === "object" && !Array.isArray(value)) {
82
+ return Object.keys(value as Record<string, unknown>).length === 0;
83
+ }
84
+
85
+ if (typeof value === "string") {
86
+ if (!value.trim()) return true;
87
+ try {
88
+ const parsed = JSON.parse(value) as unknown;
89
+ return (
90
+ (typeof parsed === "object" &&
91
+ parsed !== null &&
92
+ Object.keys(parsed).length === 0) ||
93
+ parsed === null
94
+ );
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ return false;
101
+ }
102
+
103
+ export function stepHasDetails(step: ChatActivityStep): boolean {
104
+ if (step.error?.trim()) return true;
105
+ if (step.result !== undefined && step.result !== null) return true;
106
+ return !isEmptyActivityJson(step.args);
107
+ }
@@ -0,0 +1,173 @@
1
+ export type ChatCommandSurface = "assistant";
2
+
3
+ export type ChatCommandRunResult = {
4
+ handled: boolean;
5
+ clearInput?: boolean;
6
+ error?: string;
7
+ };
8
+
9
+ export type AssistantCommandDeps = {
10
+ clearMessages: () => void;
11
+ setError: (error: string | null) => void;
12
+ streaming: boolean;
13
+ };
14
+
15
+ const CHAT_COMMANDS = [
16
+ {
17
+ name: "clear",
18
+ description: "Clear the current conversation",
19
+ surfaces: ["assistant"] as const,
20
+ run: async (args: string, deps: AssistantCommandDeps) => {
21
+ if (args) {
22
+ throw new Error("The /clear command does not accept arguments.");
23
+ }
24
+ if (deps.streaming) {
25
+ throw new Error("Wait for the assistant to finish before clearing.");
26
+ }
27
+ deps.clearMessages();
28
+ deps.setError(null);
29
+ },
30
+ },
31
+ ] satisfies Array<{
32
+ name: string;
33
+ description: string;
34
+ surfaces: readonly ChatCommandSurface[];
35
+ run: (args: string, deps: AssistantCommandDeps) => void | Promise<void>;
36
+ }>;
37
+
38
+ export type ChatCommandSuggestion = {
39
+ name: string;
40
+ description: string;
41
+ usage: string;
42
+ };
43
+
44
+ export function getChatCommandSuggestions(
45
+ surface: ChatCommandSurface,
46
+ ): ChatCommandSuggestion[] {
47
+ return CHAT_COMMANDS.filter((command) =>
48
+ command.surfaces.includes(surface),
49
+ ).map(({ name, description }) => ({
50
+ name,
51
+ description,
52
+ usage: `/${name}`,
53
+ }));
54
+ }
55
+
56
+ export function isChatCommandInput(value: string): boolean {
57
+ return value.trimStart().startsWith("/");
58
+ }
59
+
60
+ export function filterChatCommands(
61
+ value: string,
62
+ surface: ChatCommandSurface,
63
+ ): ChatCommandSuggestion[] {
64
+ if (!isChatCommandInput(value)) {
65
+ return [];
66
+ }
67
+
68
+ const query = value.trimStart().slice(1).toLowerCase();
69
+ const commands = getChatCommandSuggestions(surface);
70
+
71
+ if (!query) {
72
+ return commands;
73
+ }
74
+
75
+ return commands.filter(
76
+ (command) =>
77
+ command.name.startsWith(query) ||
78
+ command.usage.toLowerCase().startsWith(`/${query}`),
79
+ );
80
+ }
81
+
82
+ export function shouldShowChatCommandMenu(
83
+ value: string,
84
+ surface: ChatCommandSurface,
85
+ ): boolean {
86
+ if (!isChatCommandInput(value) || value.includes("\n")) {
87
+ return false;
88
+ }
89
+
90
+ const trimmed = value.trimStart();
91
+ if (trimmed.includes(" ")) {
92
+ return false;
93
+ }
94
+
95
+ return filterChatCommands(value, surface).length > 0;
96
+ }
97
+
98
+ export function completionForChatCommand(
99
+ command: ChatCommandSuggestion,
100
+ ): string {
101
+ return `${command.usage} `;
102
+ }
103
+
104
+ export function listChatCommands(surface?: ChatCommandSurface): Array<{
105
+ name: string;
106
+ description: string;
107
+ }> {
108
+ return CHAT_COMMANDS.filter(
109
+ (command) => !surface || command.surfaces.includes(surface),
110
+ ).map(({ name, description }) => ({ name, description }));
111
+ }
112
+
113
+ export function parseChatCommand(
114
+ input: string,
115
+ ): { name: string; args: string } | null {
116
+ const trimmed = input.trim();
117
+ const match = /^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i.exec(trimmed);
118
+ if (!match) {
119
+ return null;
120
+ }
121
+
122
+ const name = match[1];
123
+ if (!name) {
124
+ return null;
125
+ }
126
+
127
+ return {
128
+ name: name.toLowerCase(),
129
+ args: (match[2] ?? "").trim(),
130
+ };
131
+ }
132
+
133
+ async function executeCommand(
134
+ parsed: { name: string; args: string },
135
+ deps: AssistantCommandDeps,
136
+ ): Promise<ChatCommandRunResult> {
137
+ const command = CHAT_COMMANDS.find((entry) => entry.name === parsed.name);
138
+ if (!command?.surfaces.includes("assistant")) {
139
+ return {
140
+ handled: true,
141
+ clearInput: true,
142
+ error: `Unknown command: /${parsed.name}`,
143
+ };
144
+ }
145
+
146
+ try {
147
+ await command.run(parsed.args, deps);
148
+ return { handled: true, clearInput: true };
149
+ } catch (error) {
150
+ return {
151
+ handled: true,
152
+ clearInput: true,
153
+ error: error instanceof Error ? error.message : "Command failed",
154
+ };
155
+ }
156
+ }
157
+
158
+ export async function runAssistantChatCommand(
159
+ input: string,
160
+ deps: AssistantCommandDeps,
161
+ ): Promise<ChatCommandRunResult> {
162
+ const parsed = parseChatCommand(input);
163
+ if (!parsed) {
164
+ return { handled: false };
165
+ }
166
+
167
+ return executeCommand(parsed, deps);
168
+ }
169
+
170
+ /** @deprecated Use runAssistantChatCommand instead */
171
+ export function isClearCommand(text: string): boolean {
172
+ return parseChatCommand(text)?.name === "clear";
173
+ }