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