| 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 | "&": "&", |
| 112 | "<": "<", |
| 113 | ">": ">", |
| 114 | '"': """, |
| 115 | "'": "'", |
| 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); |