Add scoped composer references

Use the existing scoped profile, tool policy, skill, and workspace catalogs for inert @ references in the WebUI composer.

Alessandro committed Aug 18, 2026 at 12:40 UTC 9c241bdd2ce294f2b14e075ccc72f7f03746d00c
4 files changed +493 -30
plugins/_commands/AGENTS.md
+5 -2
@@ -2,7 +2,7 @@
2
3 ## Purpose
4
5 -- Own the built-in slash command manager and chat composer slash picker.
5 +- Own the built-in slash command manager and chat composer slash/reference picker.
6 - Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
7
8 ## Ownership
@@ -12,7 +12,7 @@
12 - `api/commands.py` owns the Commands API actions used by the WebUI.
13 - `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
14 - `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control.
15 -- `extensions/` owns the chat composer slash picker and incoming-message command resolution.
15 +- `extensions/` owns the chat composer slash and `@` reference picker plus incoming-message command resolution.
16 - `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
17 - `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
18 - `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
@@ -31,6 +31,9 @@
31 - Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
32 - Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
33 - Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent.
34 +- Composer `@` selections insert plain references only: `@[./path]`, `@[./folder/]`, `@[agent/profile]`, `@[skill/name]`, or `@[mcp/server]`. They never load content, activate skills, call MCP, or delegate by themselves.
35 +- Selected reference icons may use the composer highlight color while their labels keep the normal text color; serialized prompt text remains unchanged.
36 +- File and folder references stay inside the active chat workdir and list one directory at a time through the existing file-browser and chat-path APIs. Profile and effective MCP server references reuse their scoped catalogs; skill references use only entries visible in the active chat scope.
37 - WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
38 - `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
39 - `/profile` opens Manage agents without arguments, keeps existing profile
plugins/_commands/extensions/webui/chat-input-box-start/commands-menu.html
+92 -19
@@ -12,46 +12,54 @@
12 <template x-if="$store.commandsSlash.loading">
13 <div class="commands-slash-loading">
14 <x-icon class="spinning" name="progress_activity"></x-icon>
15 - <span>Loading slash commands...</span>
15 + <span x-text="$store.commandsSlash.loadingLabel"></span>
16 </div>
17 </template>
18
19 - <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
19 + <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length > 0">
20 <div class="commands-slash-results">
21 - <template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
21 + <template x-for="(item, index) in $store.commandsSlash.filteredItems" :key="item.id || item.path">
22 <button type="button"
23 class="commands-slash-item"
24 :class="{ active: index === $store.commandsSlash.selectedIndex }"
25 @mouseenter="$store.commandsSlash.selectedIndex = index"
26 @mousedown.prevent
27 - @click.prevent="$store.commandsSlash.applySelection(command)">
27 + @click.prevent="$store.commandsSlash.applySelectedItem(item)">
28 <div class="commands-slash-item-header">
29 - <div class="commands-slash-item-name">
30 - <span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
29 + <div class="commands-slash-item-name"
30 + :class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''">
31 + <template x-if="$store.commandsSlash.mode === 'reference'">
32 + <x-icon class="commands-reference-icon" :name="item.icon"></x-icon>
33 + </template>
34 + <span class="commands-slash-prefix" x-text="$store.commandsSlash.mode === 'reference' ? '@' : '/'"></span><span x-text="$store.commandsSlash.mode === 'reference' ? item.label : item.name"></span>
35 </div>
32 - <span class="commands-slash-scope" x-text="command.source_scope_label"></span>
36 + <span class="commands-slash-scope"
37 + :class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''"
38 + x-text="$store.commandsSlash.mode === 'reference' ? item.kind : item.source_scope_label"></span>
39 </div>
34 - <div class="commands-slash-item-description" x-text="command.description"></div>
35 - <template x-if="command.argument_hint">
36 - <div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
40 + <div class="commands-slash-item-description" x-text="item.description"></div>
41 + <template x-if="$store.commandsSlash.mode !== 'reference' && item.argument_hint">
42 + <div class="commands-slash-item-hint" x-text="item.argument_hint"></div>
43 </template>
44 </button>
45 </template>
46 </div>
47 </template>
48
43 - <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
49 + <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length === 0">
50 <div class="commands-slash-empty">
51 <div class="commands-slash-empty-copy">
46 - No matching slash commands.
52 + <span x-text="$store.commandsSlash.emptyLabel"></span>
53 </div>
48 - <button type="button"
49 - class="commands-slash-create"
50 - @mousedown.prevent
51 - @click.prevent="$store.commandsSlash.openCreateCommand()">
52 - <x-icon name="add"></x-icon>
53 - <span x-text="$store.commandsSlash.emptyStateLabel"></span>
54 - </button>
54 + <template x-if="$store.commandsSlash.mode !== 'reference'">
55 + <button type="button"
56 + class="commands-slash-create"
57 + @mousedown.prevent
58 + @click.prevent="$store.commandsSlash.openCreateCommand()">
59 + <x-icon name="add"></x-icon>
60 + <span x-text="$store.commandsSlash.emptyStateLabel"></span>
61 + </button>
62 + </template>
63 </div>
64 </template>
65 </div>
@@ -113,10 +121,26 @@
121 font-weight: 600;
122 }
123
124 + .commands-slash-item-name.is-reference {
125 + display: inline-flex;
126 + align-items: center;
127 + }
128 +
129 + .commands-reference-icon {
130 + margin-right: 0.38rem;
131 + font-size: 1.08rem;
132 + color: var(--color-highlight);
133 + font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
134 + }
135 +
136 .commands-slash-prefix {
137 color: var(--color-highlight);
138 }
139
140 + .commands-slash-item-name.is-reference .commands-slash-prefix {
141 + color: inherit;
142 + }
143 +
144 .commands-slash-scope {
145 padding: 0.18rem 0.45rem;
146 border-radius: 999px;
@@ -126,6 +150,55 @@
150 white-space: nowrap;
151 }
152
153 + .commands-slash-scope.is-reference {
154 + padding: 0;
155 + background: transparent;
156 + }
157 +
158 + .commands-slash-item-name.is-reference,
159 + #chat-input .composer-reference {
160 + color: var(--color-text);
161 + }
162 +
163 + #chat-input .composer-reference {
164 + display: inline;
165 + font-size: 0;
166 + font-weight: 600;
167 + line-height: inherit;
168 + white-space: nowrap;
169 + vertical-align: baseline;
170 + }
171 +
172 + #chat-input .composer-reference::before {
173 + display: inline-block;
174 + color: var(--color-highlight);
175 + font-family: 'Material Symbols Outlined';
176 + margin-right: 0.22rem;
177 + font-size: 1rem;
178 + font-weight: normal;
179 + line-height: 1;
180 + vertical-align: -0.1em;
181 + font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
182 + }
183 +
184 + #chat-input .composer-reference::after {
185 + content: attr(data-label);
186 + font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
187 + font-size: 1rem;
188 + line-height: inherit;
189 + vertical-align: baseline;
190 + }
191 +
192 + #chat-input .composer-reference.is-folder::before { content: 'folder'; }
193 + #chat-input .composer-reference.is-file::before { content: 'draft'; }
194 + #chat-input .composer-reference.is-agent::before { content: 'person'; }
195 + #chat-input .composer-reference.is-skill::before { content: 'auto_awesome'; }
196 + #chat-input .composer-reference.is-mcp::before { content: 'hub'; }
197 +
198 + html:not(.material-icons-ready) #chat-input .composer-reference::before {
199 + visibility: hidden;
200 + }
201 +
202 .commands-slash-item-description {
203 color: var(--color-text-secondary);
204 font-size: 0.86rem;
plugins/_commands/tests/test_commands_plugin.py
+58
@@ -90,10 +90,68 @@ if (path.active) throw new Error("path opened the picker");
90
91 const resolvable = parseSlashInput("objective /goal");
92 if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
93 +
94 +const reference = parseReferenceInput("Compare @src/app", 16);
95 +if (!reference.active || reference.query !== "src/app" || reference.start !== 8 || reference.end !== 16) throw new Error("reference token not found");
96 +
97 +const middle = parseReferenceInput("Use @src/app then", 12);
98 +if (!middle.active || middle.query !== "src/app") throw new Error("caret-local reference not found");
99 +
100 +if (parseReferenceInput("mail@example.test").active) throw new Error("email opened reference picker");
101 +if (parseReferenceInput("Use @[./src/app.py]").active) throw new Error("completed reference reopened picker");
102 +if (fileQueryDirectory("../secret") !== null) throw new Error("parent traversal accepted");
103 +if (fileQueryDirectory("mcp/server") !== null) throw new Error("MCP reference opened file browser");
104 +
105 +const mcp = getMcpReferences({{
106 + tools: {{
107 + effective_policy: {{ mode: "custom", mcp_default: "block", allowed: ["mcp:allowed:read"], blocked: ["mcp:blocked:read"] }},
108 + catalog: [
109 + {{ id: "mcp:allowed:read", available: true }},
110 + {{ id: "mcp:blocked:read", available: true }},
111 + {{ id: "mcp:default-blocked:read", available: true }},
112 + {{ id: "mcp:missing:read", available: false }},
113 + ],
114 + }},
115 +}});
116 +if (JSON.stringify(mcp) !== JSON.stringify([{{ name: "allowed", toolCount: 1 }}])) throw new Error("MCP policy scope leaked");
117 """
118 subprocess.run(["node", "-e", script], check=True, text=True)
119
120
121 +def test_composer_reference_picker_uses_plain_reference_tokens() -> None:
122 + plugin_root = Path(__file__).resolve().parents[1]
123 + store = (plugin_root / "webui" / "commands-slash-store.js").read_text(encoding="utf-8")
124 + menu = (
125 + plugin_root / "extensions" / "webui" / "chat-input-box-start" / "commands-menu.html"
126 + ).read_text(encoding="utf-8")
127 +
128 + assert "@[agent/${key}]" in store
129 + assert "@[skill/${name}]" in store
130 + assert "value: `@[${displayPath}]`" in store
131 + assert 'icon: isDirectory ? "folder" : "draft"' in store
132 + assert 'icon: "person"' in store
133 + assert 'icon: "auto_awesome"' in store
134 + assert "skills.filter((skill) => !skill?.hidden)" in store
135 + assert 'icon: "hub"' in store
136 + assert "@[mcp/${name}]" in store
137 + assert 'const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor"' in store
138 + assert 'action: "list", context_id: contextId' in store
139 + assert 'action: "load",' in store
140 + assert "getMcpReferences(mcpResult?.state)" in store
141 + assert 'mcp_servers_status' not in store
142 + assert "composer-reference" in store
143 + assert "node.dataset.label = reference.label" in store
144 + assert 'callJsonApi("/chat_files_path_get"' in store
145 + assert 'callJsonApi("/agents"' not in store
146 + assert "filteredItems" in menu
147 + assert "#chat-input .composer-reference" in menu
148 + assert "content: attr(data-label)" in menu
149 + assert "color: var(--color-highlight)" in menu
150 + assert "color: var(--color-text)" in menu
151 + assert "composer-reference.is-mcp" in menu
152 + assert "background: transparent" in menu
153 +
154 +
155 @pytest.fixture
156 def scope_fixture() -> ScopeFixture:
157 suffix = uuid.uuid4().hex[:8]
plugins/_commands/webui/commands-slash-store.js
+338 -9
@@ -1,5 +1,5 @@
1 import { createStore } from "/js/AlpineStore.js";
2 -import { callJsonApi } from "/js/api.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";
@@ -11,6 +11,8 @@ import {
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 || "")
@@ -45,6 +47,58 @@ function parseSlashInput(message, allowPostfix = true) {
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 }
@@ -74,6 +128,13 @@ 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,
@@ -81,6 +142,10 @@ const model = {
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,
@@ -104,12 +169,37 @@ const model = {
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";
@@ -146,6 +236,15 @@ const model = {
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 },
@@ -233,13 +332,197 @@ const model = {
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;
@@ -297,33 +580,79 @@ const model = {
580 return;
581 }
582
300 - if (event.key === "Enter" && this.selectedCommand) {
583 + if (event.key === "Enter" && this.selectedItem) {
584 event.preventDefault();
585 event.stopPropagation();
303 - void this.applySelection(this.selectedCommand);
586 + void this.applySelectedItem(this.selectedItem);
587 }
588 },
589
590 ensureSelection() {
308 - const commands = this.filteredCommands;
309 - if (!commands.length) {
591 + const items = this.filteredItems;
592 + if (!items.length) {
593 this.selectedIndex = 0;
594 return;
595 }
313 - if (this.selectedIndex >= commands.length) {
596 + if (this.selectedIndex >= items.length) {
597 this.selectedIndex = 0;
598 }
599 },
600
601 moveSelection(delta) {
319 - const commands = this.filteredCommands;
320 - if (!commands.length) return;
602 + const items = this.filteredItems;
603 + if (!items.length) return;
604 const nextIndex =
322 - (this.selectedIndex + delta + commands.length) % commands.length;
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