main
js 900 lines 29.3 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi, fetchApi } from "/js/api.js";
3 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4 import { store as chatInputStore } from "/components/chat/input/input-store.js";
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 import {
7 toastFrontendError,
8 toastFrontendInfo,
9 toastFrontendSuccess,
10 } from "/components/notifications/notification-store.js";
11 import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
12
13 const COMMANDS_API_PATH = "/plugins/_commands/commands";
14 const SKILLS_API_PATH = "/plugins/_skills/skills_catalog";
15 const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor";
16
17 function sanitizeCommandName(rawName) {
18 return (rawName || "")
19 .trim()
20 .toLowerCase()
21 .replace(/\s+/g, "-")
22 .replace(/[^a-z0-9_-]+/g, "-")
23 .replace(/-{2,}/g, "-")
24 .replace(/^[-_]+|[-_]+$/g, "");
25 }
26
27 function parseSlashInput(message, allowPostfix = true) {
28 const text = String(message || "");
29 const prefixMatch = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
30 const postfixMatch = prefixMatch || !allowPostfix
31 ? null
32 : text.match(/^([\s\S]*\S)\s+\/([^\s]*)\s*$/);
33 if (!prefixMatch && !postfixMatch) {
34 return {
35 active: false,
36 query: "",
37 rawArguments: "",
38 rawMessage: text,
39 };
40 }
41
42 return {
43 active: true,
44 query: (prefixMatch?.[1] || postfixMatch?.[2] || "").trim().toLowerCase(),
45 rawArguments: prefixMatch?.[2] || postfixMatch?.[1]?.trim() || "",
46 rawMessage: text,
47 };
48 }
49
50 function parseReferenceInput(message, caretOffset = undefined) {
51 const text = String(message || "");
52 if (caretOffset === null) return { active: false, query: "", start: 0, end: 0 };
53 const caret = Math.max(0, Math.min(text.length, caretOffset ?? text.length));
54 const match = text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
55 if (!match) return { active: false, query: "", start: caret, end: caret };
56 if (match[1].startsWith("[") && match[1].endsWith("]")) {
57 return { active: false, query: "", start: caret, end: caret };
58 }
59
60 const token = `@${match[1]}`;
61 return {
62 active: true,
63 query: match[1].toLowerCase(),
64 start: caret - token.length,
65 end: caret,
66 };
67 }
68
69 function normalizePath(value) {
70 return String(value || "").replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/\/$/, "");
71 }
72
73 function fileQueryDirectory(query) {
74 const value = String(query || "").replace(/^\.\//, "");
75 if (value.startsWith("agent/") || value.startsWith("skill/") || value.startsWith("mcp/") || value.split("/").includes("..")) {
76 return null;
77 }
78 const slash = value.lastIndexOf("/");
79 return slash < 0 ? "" : value.slice(0, slash);
80 }
81
82 function mcpPolicyAllows(policy, id) {
83 if (!policy || policy.mode !== "custom") return true;
84 if (policy.blocked?.includes(id)) return false;
85 if (policy.allowed?.includes(id)) return true;
86 return policy.mcp_default === "allow";
87 }
88
89 function getMcpReferences(state) {
90 const servers = new Map();
91 const policy = state?.tools?.effective_policy;
92 for (const tool of state?.tools?.catalog || []) {
93 const id = String(tool?.id || "");
94 const match = id.match(/^mcp:([^:]+):/);
95 if (!match || tool?.available === false || !mcpPolicyAllows(policy, id)) continue;
96 const name = match[1];
97 servers.set(name, (servers.get(name) || 0) + 1);
98 }
99 return [...servers].map(([name, toolCount]) => ({ name, toolCount }));
100 }
101
102 function notifyError(message) {
103 void toastFrontendError(message, "Commands");
104 }
105
106 function notifySuccess(message) {
107 void toastFrontendSuccess(message, "Commands");
108 }
109
110 const HTML_ESCAPE = {
111 "&": "&amp;",
112 "<": "&lt;",
113 ">": "&gt;",
114 '"': "&quot;",
115 "'": "&#39;",
116 };
117
118 function escapeHtml(value) {
119 return String(value || "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
120 }
121
122 function notifyInfo(title, message) {
123 const formatted = escapeHtml(message).replace(/\n/g, "<br>");
124 void toastFrontendInfo(formatted, title || "Commands", 3, "", undefined, true);
125 }
126
127 const model = {
128 loading: false,
129 applying: false,
130 commands: [],
131 references: [],
132 referenceContextId: null,
133 referenceDirectoryKey: "",
134 referenceRoot: "",
135 referenceCatalog: [],
136 referenceFiles: [],
137 referenceLoadGeneration: 0,
138 contextScope: { project_name: "" },
139 lastContextId: "",
140 active: false,
141 dismissed: false,
142 query: "",
143 rawArguments: "",
144 rawMessage: "",
145 mode: "",
146 referenceStart: 0,
147 referenceEnd: 0,
148 referenceRange: null,
149 selectedIndex: 0,
150 boundInput: null,
151 keydownHandler: null,
152 inputHandler: null,
153 focusHandler: null,
154 commandsUpdatedHandler: null,
155
156 get menuVisible() {
157 return this.active && !this.dismissed;
158 },
159
160 get filteredCommands() {
161 const needle = (this.query || "").trim().toLowerCase();
162 const commands = Array.isArray(this.commands) ? this.commands : [];
163
164 if (!needle) return commands;
165
166 return commands.filter((command) => {
167 const haystack = `${command?.name || ""} ${command?.description || ""}`.toLowerCase();
168 return haystack.includes(needle);
169 });
170 },
171
172 get filteredReferences() {
173 const needle = (this.query || "").trim().toLowerCase().replace(/^\.\//, "");
174 const references = Array.isArray(this.references) ? this.references : [];
175 if (!needle) return references;
176 return references.filter((reference) => reference.search.includes(needle));
177 },
178
179 get filteredItems() {
180 return this.mode === "reference" ? this.filteredReferences : this.filteredCommands;
181 },
182
183 get selectedCommand() {
184 const commands = this.filteredCommands;
185 if (!commands.length) return null;
186 return commands[this.selectedIndex] || commands[0] || null;
187 },
188
189 get selectedItem() {
190 const items = this.filteredItems;
191 if (!items.length) return null;
192 return items[this.selectedIndex] || items[0] || null;
193 },
194
195 get loadingLabel() {
196 return this.mode === "reference" ? "Loading references..." : "Loading slash commands...";
197 },
198
199 get emptyLabel() {
200 return this.mode === "reference" ? "No matching references." : "No matching slash commands.";
201 },
202
203 get emptyStateLabel() {
204 const name = sanitizeCommandName(this.query || "");
205 return name ? `Create /${name}` : "Create slash command";
206 },
207
208 onMount() {
209 this.ensureBindings();
210
211 this.keydownHandler = (event) => this.handleKeydown(event);
212 this.commandsUpdatedHandler = () => {
213 this.commands = [];
214 if (this.menuVisible) {
215 void this.loadCommands(true);
216 }
217 };
218
219 document.addEventListener("keydown", this.keydownHandler, true);
220 window.addEventListener("commands:updated", this.commandsUpdatedHandler);
221 this.handleInput();
222 },
223
224 cleanup() {
225 this.removeBindings();
226 if (this.keydownHandler) {
227 document.removeEventListener("keydown", this.keydownHandler, true);
228 }
229 if (this.commandsUpdatedHandler) {
230 window.removeEventListener("commands:updated", this.commandsUpdatedHandler);
231 }
232 this.keydownHandler = null;
233 this.commandsUpdatedHandler = null;
234 this.dismissed = false;
235 this.active = false;
236 this.query = "";
237 this.rawArguments = "";
238 this.rawMessage = "";
239 this.mode = "";
240 this.referenceRange = null;
241 this.references = [];
242 this.referenceContextId = null;
243 this.referenceDirectoryKey = "";
244 this.referenceRoot = "";
245 this.referenceCatalog = [];
246 this.referenceFiles = [];
247 this.referenceLoadGeneration += 1;
248 this.selectedIndex = 0;
249 this.applying = false;
250 },
251
252 ensureBindings() {
253 const input = this.getInputElement();
254 if (!input || input === this.boundInput) return;
255
256 this.removeBindings();
257
258 this.inputHandler = (event) => this.handleInput(event);
259 this.focusHandler = () => this.handleInput();
260 input.addEventListener("input", this.inputHandler);
261 input.addEventListener("focus", this.focusHandler);
262 this.boundInput = input;
263 },
264
265 removeBindings() {
266 if (this.boundInput && this.inputHandler) {
267 this.boundInput.removeEventListener("input", this.inputHandler);
268 }
269 if (this.boundInput && this.focusHandler) {
270 this.boundInput.removeEventListener("focus", this.focusHandler);
271 }
272 this.boundInput = null;
273 this.inputHandler = null;
274 this.focusHandler = null;
275 },
276
277 getInputElement() {
278 return document.getElementById("chat-input");
279 },
280
281 getInputMessage(event = null) {
282 const target = event?.target || null;
283 const targetEditor = target?.closest?.("#chat-input");
284 if (targetEditor?.isContentEditable || target?.isContentEditable) {
285 return (
286 chatInputStore?._editorToMarkdown?.() ||
287 targetEditor?.textContent ||
288 target?.textContent ||
289 ""
290 );
291 }
292 if (typeof target?.value === "string") return target.value;
293
294 const input = this.getInputElement();
295 if (input?.isContentEditable) {
296 return chatInputStore?._editorToMarkdown?.() ?? input.textContent ?? "";
297 }
298 if (typeof input?.value === "string") return input.value;
299 return chatInputStore?.message ?? "";
300 },
301
302 getContextId() {
303 return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
304 },
305
306 async loadCommands(force = false) {
307 const contextId = this.getContextId();
308
309 if (!force && this.commands.length && contextId === this.lastContextId) {
310 this.ensureSelection();
311 return;
312 }
313
314 this.loading = true;
315 try {
316 const response = await callJsonApi(COMMANDS_API_PATH, {
317 action: "list_effective",
318 context_id: contextId,
319 });
320 this.commands = Array.isArray(response?.commands) ? response.commands : [];
321 this.contextScope = response?.scope || {
322 project_name: "",
323 };
324 this.lastContextId = contextId;
325 this.ensureSelection();
326 } catch (error) {
327 console.error("Failed to load effective commands:", error);
328 this.commands = [];
329 this.contextScope = { project_name: "" };
330 } finally {
331 this.loading = false;
332 }
333 },
334
335 getCaretOffset() {
336 const input = this.getInputElement();
337 const selection = document.getSelection?.();
338 const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
339 if (range && chatInputStore?._isInCodeBlock?.(range.startContainer?.parentElement)) return null;
340 const offsets = chatInputStore?._selectionOffsets?.(input);
341 return offsets && offsets.start === offsets.end ? offsets.end : null;
342 },
343
344 captureReferenceRange(length) {
345 const input = this.getInputElement();
346 const selection = document.getSelection?.();
347 if (!input || !selection || selection.rangeCount === 0) return null;
348 const range = selection.getRangeAt(0);
349 if (
350 !range.collapsed ||
351 range.startContainer?.nodeType !== Node.TEXT_NODE ||
352 range.startOffset < length ||
353 !input.contains(range.startContainer)
354 ) return null;
355 const triggerRange = range.cloneRange();
356 triggerRange.setStart(range.startContainer, range.startOffset - length);
357 return triggerRange;
358 },
359
360 async loadReferences(force = false) {
361 const contextId = this.getContextId();
362 const directory = fileQueryDirectory(this.query);
363 const generation = ++this.referenceLoadGeneration;
364 this.loading = true;
365
366 try {
367 if (force || contextId !== this.referenceContextId) {
368 const [rootResult, settingsResult, skillsResult, profilesResult] = await Promise.allSettled([
369 contextId ? callJsonApi("/chat_files_path_get", { ctxid: contextId }) : Promise.resolve(null),
370 callJsonApi("settings_get", null),
371 callJsonApi(SKILLS_API_PATH, { action: "list", context_id: contextId }),
372 callJsonApi(AGENT_EDITOR_API_PATH, { action: "list", context_id: contextId }),
373 ]);
374 if (generation !== this.referenceLoadGeneration) return;
375
376 this.referenceRoot = normalizePath(
377 rootResult.value?.path || settingsResult.value?.settings?.workdir_path || "",
378 );
379 const skills = skillsResult.value?.ok && Array.isArray(skillsResult.value.skills)
380 ? skillsResult.value.skills
381 : [];
382 const profiles = profilesResult.value?.ok && Array.isArray(profilesResult.value.profiles)
383 ? profilesResult.value.profiles
384 : [];
385 const activeProfile = String(
386 chatsStore.selectedContext?.agent_profile
387 || settingsResult.value?.settings?.agent_profile
388 || "",
389 ).trim();
390 const activeProfileAvailable = profiles.some((profile) => (
391 profile?.id === activeProfile && profile?.enabled && profile?.available
392 ));
393 const mcpResult = activeProfileAvailable
394 ? await callJsonApi(AGENT_EDITOR_API_PATH, {
395 action: "load",
396 profile_id: activeProfile,
397 context_id: contextId,
398 }).catch((error) => {
399 console.error("Failed to load scoped MCP references:", error);
400 return null;
401 })
402 : null;
403 if (generation !== this.referenceLoadGeneration) return;
404 const mcpServers = getMcpReferences(mcpResult?.state);
405 this.referenceCatalog = [
406 ...profiles.filter((profile) => (
407 profile?.id !== "default" && profile?.enabled && profile?.available
408 )).map((profile) => {
409 const key = String(profile?.id || "").trim();
410 const label = String(profile?.title || key).trim();
411 return {
412 id: `agent:${key}`,
413 kind: "Agent",
414 icon: "person",
415 tone: "agent",
416 label,
417 value: `@[agent/${key}]`,
418 description: key === label ? "Agent profile" : `Agent profile · ${key}`,
419 search: `agent/${key} ${label}`.toLowerCase(),
420 };
421 }).filter((item) => item.id !== "agent:"),
422 ...skills.filter((skill) => !skill?.hidden).map((skill) => {
423 const name = String(skill?.name || "").trim();
424 return {
425 id: `skill:${String(skill?.path || name)}`,
426 kind: "Skill",
427 icon: "auto_awesome",
428 tone: "skill",
429 label: name,
430 value: `@[skill/${name}]`,
431 description: String(skill?.description || "Skill").trim(),
432 search: `skill/${name} ${skill?.description || ""} ${skill?.path || ""}`.toLowerCase(),
433 };
434 }).filter((item) => item.label),
435 ...mcpServers.map((server) => {
436 const name = String(server?.name || "").trim();
437 const description = `${Number(server?.toolCount || 0)} available MCP tools`;
438 return {
439 id: `mcp:${name}`,
440 kind: "MCP",
441 icon: "hub",
442 tone: "mcp",
443 label: name,
444 value: `@[mcp/${name}]`,
445 description,
446 search: `mcp/${name} ${name} ${description}`.toLowerCase(),
447 };
448 }).filter((item) => item.label),
449 ];
450 this.referenceFiles = [];
451 this.referenceContextId = contextId;
452 this.referenceDirectoryKey = "";
453 }
454
455 const directoryKey = directory === null || !this.referenceRoot
456 ? ""
457 : `${this.referenceRoot}/${directory}`.replace(/\/$/, "");
458 if (directory !== null && directoryKey && directoryKey !== this.referenceDirectoryKey) {
459 const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(directoryKey)}`);
460 const payload = await response.json().catch(() => ({}));
461 if (generation !== this.referenceLoadGeneration) return;
462 const entries = response.ok && Array.isArray(payload?.data?.entries) ? payload.data.entries : [];
463 const root = this.referenceRoot.replace(/^\//, "");
464 this.referenceFiles = entries.flatMap((entry) => {
465 const path = normalizePath(entry?.path).replace(/^\//, "");
466 if (!path || (path !== root && !path.startsWith(`${root}/`))) return [];
467 const relative = path === root ? "" : path.slice(root.length + 1);
468 if (!relative) return [];
469 const isDirectory = Boolean(entry?.is_dir);
470 const displayPath = `./${relative}${isDirectory ? "/" : ""}`;
471 return [{
472 id: `${isDirectory ? "folder" : "file"}:${path}`,
473 kind: isDirectory ? "Folder" : "File",
474 icon: isDirectory ? "folder" : "draft",
475 tone: isDirectory ? "folder" : "file",
476 label: displayPath,
477 value: `@[${displayPath}]`,
478 description: isDirectory ? "Folder in active workspace" : "File in active workspace",
479 search: displayPath.toLowerCase(),
480 }];
481 });
482 this.referenceDirectoryKey = directoryKey;
483 } else if (directory === null) {
484 this.referenceFiles = [];
485 this.referenceDirectoryKey = "";
486 }
487
488 if (generation === this.referenceLoadGeneration) {
489 this.references = [...this.referenceFiles, ...this.referenceCatalog];
490 this.ensureSelection();
491 }
492 } catch (error) {
493 console.error("Failed to load composer references:", error);
494 if (generation === this.referenceLoadGeneration) {
495 this.references = [...this.referenceCatalog];
496 }
497 } finally {
498 if (generation === this.referenceLoadGeneration) this.loading = false;
499 }
500 },
501
502 handleInput(event = null) {
503 this.ensureBindings();
504 this.dismissed = false;
505
506 const message = this.getInputMessage(event);
507 const reference = parseReferenceInput(message, this.getCaretOffset());
508 if (reference.active) {
509 const newReferenceSession = this.mode !== "reference";
510 this.mode = "reference";
511 this.active = true;
512 this.query = reference.query;
513 this.rawMessage = message;
514 this.referenceStart = reference.start;
515 this.referenceEnd = reference.end;
516 this.referenceRange = this.captureReferenceRange(reference.end - reference.start);
517 this.ensureSelection();
518 void this.loadReferences(newReferenceSession);
519 return;
520 }
521
522 const parsed = parseSlashInput(message, false);
523
524 this.referenceRange = null;
525 this.mode = parsed.active ? "slash" : "";
526 this.active = parsed.active;
527 this.query = parsed.query;
528 this.rawArguments = parsed.rawArguments;
529 this.rawMessage = parsed.rawMessage;
530
531 if (!this.active) {
532 this.selectedIndex = 0;
533 return;
534 }
535
536 this.ensureSelection();
537 void this.loadCommands();
538 },
539
540 async resolveBeforeSend(sendCtx) {
541 if (!sendCtx || this.applying) return;
542
543 const parsed = parseSlashInput(sendCtx.message);
544 const commandName = sanitizeCommandName(parsed.query);
545 if (!parsed.active || !commandName) return;
546
547 await this.loadCommands();
548 const command = this.commands.find((item) => item.name === commandName);
549 if (!command || !this.getInputElement()) return;
550
551 this.rawMessage = parsed.rawMessage;
552 this.rawArguments = parsed.rawArguments;
553 sendCtx.cancel = true;
554 await this.applySelection(command);
555 },
556
557 handleKeydown(event) {
558 const input = this.getInputElement();
559 if (!this.menuVisible || !input || document.activeElement !== input) return;
560 if (event.isComposing || event.keyCode === 229) return;
561
562 if (event.key === "ArrowDown") {
563 event.preventDefault();
564 event.stopPropagation();
565 this.moveSelection(1);
566 return;
567 }
568
569 if (event.key === "ArrowUp") {
570 event.preventDefault();
571 event.stopPropagation();
572 this.moveSelection(-1);
573 return;
574 }
575
576 if (event.key === "Escape") {
577 event.preventDefault();
578 event.stopPropagation();
579 this.dismissed = true;
580 return;
581 }
582
583 if (event.key === "Enter" && this.selectedItem) {
584 event.preventDefault();
585 event.stopPropagation();
586 void this.applySelectedItem(this.selectedItem);
587 }
588 },
589
590 ensureSelection() {
591 const items = this.filteredItems;
592 if (!items.length) {
593 this.selectedIndex = 0;
594 return;
595 }
596 if (this.selectedIndex >= items.length) {
597 this.selectedIndex = 0;
598 }
599 },
600
601 moveSelection(delta) {
602 const items = this.filteredItems;
603 if (!items.length) return;
604 const nextIndex =
605 (this.selectedIndex + delta + items.length) % items.length;
606 this.selectedIndex = nextIndex;
607 this.scrollSelectedIntoView();
608 },
609
610 applySelectedItem(item) {
611 return this.mode === "reference" ? this.applyReference(item) : this.applySelection(item);
612 },
613
614 applyReference(reference) {
615 const input = this.getInputElement();
616 if (!reference?.value || !input) return;
617
618 const current = this.getInputMessage();
619 const suffix = current.slice(this.referenceEnd);
620 const separator = suffix && /^\s/.test(suffix) ? "" : " ";
621 const nextText = `${current.slice(0, this.referenceStart)}${reference.value}${separator}${suffix}`;
622 const caret = this.referenceStart + reference.value.length + separator.length;
623 const range = this.referenceRange;
624 this.referenceRange = null;
625 if (range && input.contains(range.startContainer)) {
626 range.deleteContents();
627 const node = document.createElement("span");
628 node.className = `composer-reference is-${reference.tone}`;
629 node.dataset.reference = reference.value;
630 node.dataset.label = reference.label;
631 node.contentEditable = "false";
632 node.textContent = reference.value;
633 node.setAttribute("aria-label", `${reference.kind}: ${reference.label}`);
634 range.insertNode(node);
635 const space = separator ? document.createTextNode(separator) : null;
636 if (space) node.after(space);
637 range.setStartAfter(space || node);
638 range.collapse(true);
639 const selection = document.getSelection?.();
640 selection?.removeAllRanges();
641 selection?.addRange(range);
642 chatInputStore?._syncMessageFromEditor?.();
643 } else {
644 chatInputStore.message = nextText;
645 chatInputStore?._setEditorCaret?.(caret);
646 }
647 input.dispatchEvent(new Event("input", { bubbles: true }));
648 chatInputStore.adjustTextareaHeight();
649 this.active = false;
650 this.dismissed = false;
651 this.mode = "";
652 this.query = "";
653 this.selectedIndex = 0;
654 },
655
656 scrollSelectedIntoView() {
657 requestAnimationFrame(() => {
658 document
659 .querySelector(".commands-slash-results .commands-slash-item.active")
660 ?.scrollIntoView({ block: "nearest" });
661 });
662 },
663
664 async applySelection(command) {
665 if (!command || this.applying) return;
666 const input = this.getInputElement();
667 if (!input) return;
668
669 this.applying = true;
670 try {
671 const contextId = this.getContextId();
672 const fallbackSlash = this.rawMessage?.trim()
673 ? this.rawMessage
674 : this.rawArguments
675 ? `/${command.name} ${this.rawArguments}`
676 : `/${command.name}`;
677
678 const response = await callJsonApi(COMMANDS_API_PATH, {
679 action: "resolve",
680 path: command.path,
681 slash_text: fallbackSlash,
682 project_name: this.contextScope?.project_name || "",
683 context_id: contextId,
684 });
685
686 const applied = await this.applyResolution(response?.resolution, input);
687 if (!applied?.hadToast && !applied?.hadError) {
688 notifySuccess(`Applied /${command.name}`);
689 }
690 } catch (error) {
691 console.error("Failed to apply slash command:", error);
692 notifyError(error?.message || "Failed to apply slash command.");
693 } finally {
694 this.applying = false;
695 }
696 },
697
698 async applyResolution(resolution, input) {
699 const result = resolution?.result || {};
700 const hasText = typeof result.text === "string";
701 let nextText = hasText ? result.text : this.getInputMessage();
702 const effects = Array.isArray(result.effects) ? result.effects : [];
703 let hadToast = false;
704 let hadError = false;
705 let shouldSend = false;
706
707 for (const effect of effects) {
708 if (!effect || typeof effect !== "object") continue;
709 const type = String(effect.type || "").trim().toLowerCase();
710 if (type === "replace_input") {
711 nextText = String(effect.text || "");
712 continue;
713 }
714 if (type === "append_input") {
715 const chunk = String(effect.text || "");
716 nextText = nextText ? `${nextText}\n${chunk}` : chunk;
717 continue;
718 }
719 if (type === "send_message") {
720 nextText = String(effect.text || nextText || "");
721 shouldSend = true;
722 continue;
723 }
724 if (type === "toast") {
725 hadToast = true;
726 const level = String(effect.level || "info").toLowerCase();
727 const message = String(effect.message || "");
728 if (!message) continue;
729 if (level === "error") {
730 hadError = true;
731 notifyError(message);
732 } else {
733 notifySuccess(message);
734 }
735 continue;
736 }
737 if (type === "new_chat") {
738 await chatsStore?.newChat?.();
739 continue;
740 }
741 if (type === "select_chat") {
742 const contextId = String(effect.context_id || "").trim();
743 if (contextId) await chatsStore?.selectChat?.(contextId);
744 continue;
745 }
746 if (type === "reset_chat") {
747 await chatsStore?.resetChat?.(String(effect.context_id || "") || null);
748 continue;
749 }
750 if (type === "pause_agent") {
751 await chatInputStore?.pauseAgent?.(Boolean(effect.paused));
752 continue;
753 }
754 if (type === "nudge_agent") {
755 await chatInputStore?.nudge?.();
756 continue;
757 }
758 if (type === "open_modal") {
759 const path = String(effect.path || "").trim();
760 if (path) await window.openModal?.(path);
761 continue;
762 }
763 if (type === "open_agent_editor") {
764 await globalThis.openAgentEditor?.({
765 view: String(effect.view || "manage"),
766 profileId: String(effect.profile_id || ""),
767 });
768 continue;
769 }
770 if (type === "test_agent_profile") {
771 const profileId = String(effect.profile_id || "").trim();
772 if (profileId) {
773 await globalThis.testAgentProfile?.(
774 profileId,
775 String(effect.project_name || ""),
776 );
777 }
778 continue;
779 }
780 if (type === "show_markdown") {
781 hadToast = true;
782 notifyInfo(
783 String(effect.title || "Slash Command"),
784 String(effect.content || ""),
785 );
786 continue;
787 }
788 if (type === "computer_use") {
789 hadToast = true;
790 notifyInfo(
791 "Computer Use",
792 String(effect.fallback || "Use Host access in A0 Launcher, or run this command in A0 CLI."),
793 );
794 continue;
795 }
796 if (type === "goal_changed") {
797 window.dispatchEvent(new CustomEvent("goal:changed", { detail: effect }));
798 continue;
799 }
800 if (type === "open_plugin_config") {
801 const pluginName = String(effect.plugin || "").trim();
802 if (pluginName) {
803 const { store } = await import("/components/plugins/plugin-settings-store.js");
804 await store.openConfig(
805 pluginName,
806 String(effect.project_name || ""),
807 String(effect.agent_profile || ""),
808 );
809 }
810 continue;
811 }
812 if (type === "compact_chat") {
813 const { store } = await import("/plugins/_chat_compaction/webui/compact-store.js");
814 await store.fetchStats();
815 continue;
816 }
817 if (type === "attach_files") {
818 await this.openAttachmentPicker(effect);
819 continue;
820 }
821 if (type === "copy_transcript") {
822 await this.copyTranscript();
823 hadToast = true;
824 continue;
825 }
826 if (type === "clear_transcript") {
827 const history = document.getElementById("chat-history");
828 if (history) history.innerHTML = "";
829 continue;
830 }
831 }
832
833 if (typeof input.value === "string") input.value = nextText;
834 chatInputStore.message = nextText;
835 input.dispatchEvent(new Event("input", { bubbles: true }));
836 chatInputStore.adjustTextareaHeight();
837 input.focus();
838 if (typeof input.setSelectionRange === "function") {
839 input.setSelectionRange(nextText.length, nextText.length);
840 } else {
841 chatInputStore?._setEditorCaret?.(nextText.length);
842 }
843
844 this.active = false;
845 this.dismissed = false;
846 this.query = "";
847 this.rawArguments = "";
848 this.rawMessage = nextText;
849 this.selectedIndex = 0;
850 if (shouldSend && nextText.trim()) {
851 await chatInputStore?.sendMessage?.();
852 }
853 return { hadToast, hadError };
854 },
855
856 openAttachmentPicker(effect = {}) {
857 return new Promise((resolve) => {
858 const picker = document.createElement("input");
859 let settled = false;
860 const done = () => {
861 if (settled) return;
862 settled = true;
863 picker.remove();
864 resolve();
865 };
866 picker.type = "file";
867 picker.multiple = true;
868 picker.accept = String(effect.accept || "*");
869 picker.style.display = "none";
870 picker.addEventListener("change", () => {
871 attachmentsStore?.handleFiles?.(picker.files || []);
872 done();
873 }, { once: true });
874 window.addEventListener("focus", () => setTimeout(done, 500), { once: true });
875 document.body.appendChild(picker);
876 picker.click();
877 });
878 },
879
880 async copyTranscript() {
881 const text = document.getElementById("chat-history")?.innerText?.trim() || "";
882 if (!text) {
883 notifyError("No visible transcript to copy.");
884 return;
885 }
886 await navigator.clipboard.writeText(text);
887 notifySuccess("Transcript copied.");
888 },
889
890 openCreateCommand() {
891 commandsManagerStore.openManager({
892 projectName: this.contextScope?.project_name || "",
893 prefillName: sanitizeCommandName(this.query || ""),
894 openEditor: true,
895 });
896 this.dismissed = true;
897 },
898 };
899
900 export const store = createStore("commandsSlash", model);