Add scoped composer references

Use the existing scoped profile, tool policy, skill, and workspace catalogs for inert @ references in the WebUI composer.
This commit is contained in:
Alessandro 2026-08-18 12:40:51 +02:00
parent add781d3b3
commit 9c241bdd2c
4 changed files with 493 additions and 30 deletions

View file

@ -2,7 +2,7 @@
## Purpose
- Own the built-in slash command manager and chat composer slash picker.
- Own the built-in slash command manager and chat composer slash/reference picker.
- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
## Ownership
@ -12,7 +12,7 @@
- `api/commands.py` owns the Commands API actions used by the WebUI.
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control.
- `extensions/` owns the chat composer slash picker and incoming-message command resolution.
- `extensions/` owns the chat composer slash and `@` reference picker plus incoming-message command resolution.
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
@ -31,6 +31,9 @@
- 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.
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
- 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.
- 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.
- Selected reference icons may use the composer highlight color while their labels keep the normal text color; serialized prompt text remains unchanged.
- 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.
- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
- `/profile` opens Manage agents without arguments, keeps existing profile

View file

@ -12,46 +12,54 @@
<template x-if="$store.commandsSlash.loading">
<div class="commands-slash-loading">
<x-icon class="spinning" name="progress_activity"></x-icon>
<span>Loading slash commands...</span>
<span x-text="$store.commandsSlash.loadingLabel"></span>
</div>
</template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length > 0">
<div class="commands-slash-results">
<template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
<template x-for="(item, index) in $store.commandsSlash.filteredItems" :key="item.id || item.path">
<button type="button"
class="commands-slash-item"
:class="{ active: index === $store.commandsSlash.selectedIndex }"
@mouseenter="$store.commandsSlash.selectedIndex = index"
@mousedown.prevent
@click.prevent="$store.commandsSlash.applySelection(command)">
@click.prevent="$store.commandsSlash.applySelectedItem(item)">
<div class="commands-slash-item-header">
<div class="commands-slash-item-name">
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
<div class="commands-slash-item-name"
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''">
<template x-if="$store.commandsSlash.mode === 'reference'">
<x-icon class="commands-reference-icon" :name="item.icon"></x-icon>
</template>
<span class="commands-slash-prefix" x-text="$store.commandsSlash.mode === 'reference' ? '@' : '/'"></span><span x-text="$store.commandsSlash.mode === 'reference' ? item.label : item.name"></span>
</div>
<span class="commands-slash-scope" x-text="command.source_scope_label"></span>
<span class="commands-slash-scope"
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''"
x-text="$store.commandsSlash.mode === 'reference' ? item.kind : item.source_scope_label"></span>
</div>
<div class="commands-slash-item-description" x-text="command.description"></div>
<template x-if="command.argument_hint">
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
<div class="commands-slash-item-description" x-text="item.description"></div>
<template x-if="$store.commandsSlash.mode !== 'reference' && item.argument_hint">
<div class="commands-slash-item-hint" x-text="item.argument_hint"></div>
</template>
</button>
</template>
</div>
</template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length === 0">
<div class="commands-slash-empty">
<div class="commands-slash-empty-copy">
No matching slash commands.
<span x-text="$store.commandsSlash.emptyLabel"></span>
</div>
<button type="button"
class="commands-slash-create"
@mousedown.prevent
@click.prevent="$store.commandsSlash.openCreateCommand()">
<x-icon name="add"></x-icon>
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
<template x-if="$store.commandsSlash.mode !== 'reference'">
<button type="button"
class="commands-slash-create"
@mousedown.prevent
@click.prevent="$store.commandsSlash.openCreateCommand()">
<x-icon name="add"></x-icon>
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
</template>
</div>
</template>
</div>
@ -113,10 +121,26 @@
font-weight: 600;
}
.commands-slash-item-name.is-reference {
display: inline-flex;
align-items: center;
}
.commands-reference-icon {
margin-right: 0.38rem;
font-size: 1.08rem;
color: var(--color-highlight);
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
}
.commands-slash-prefix {
color: var(--color-highlight);
}
.commands-slash-item-name.is-reference .commands-slash-prefix {
color: inherit;
}
.commands-slash-scope {
padding: 0.18rem 0.45rem;
border-radius: 999px;
@ -126,6 +150,55 @@
white-space: nowrap;
}
.commands-slash-scope.is-reference {
padding: 0;
background: transparent;
}
.commands-slash-item-name.is-reference,
#chat-input .composer-reference {
color: var(--color-text);
}
#chat-input .composer-reference {
display: inline;
font-size: 0;
font-weight: 600;
line-height: inherit;
white-space: nowrap;
vertical-align: baseline;
}
#chat-input .composer-reference::before {
display: inline-block;
color: var(--color-highlight);
font-family: 'Material Symbols Outlined';
margin-right: 0.22rem;
font-size: 1rem;
font-weight: normal;
line-height: 1;
vertical-align: -0.1em;
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
}
#chat-input .composer-reference::after {
content: attr(data-label);
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
font-size: 1rem;
line-height: inherit;
vertical-align: baseline;
}
#chat-input .composer-reference.is-folder::before { content: 'folder'; }
#chat-input .composer-reference.is-file::before { content: 'draft'; }
#chat-input .composer-reference.is-agent::before { content: 'person'; }
#chat-input .composer-reference.is-skill::before { content: 'auto_awesome'; }
#chat-input .composer-reference.is-mcp::before { content: 'hub'; }
html:not(.material-icons-ready) #chat-input .composer-reference::before {
visibility: hidden;
}
.commands-slash-item-description {
color: var(--color-text-secondary);
font-size: 0.86rem;

View file

@ -90,10 +90,68 @@ if (path.active) throw new Error("path opened the picker");
const resolvable = parseSlashInput("objective /goal");
if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
const reference = parseReferenceInput("Compare @src/app", 16);
if (!reference.active || reference.query !== "src/app" || reference.start !== 8 || reference.end !== 16) throw new Error("reference token not found");
const middle = parseReferenceInput("Use @src/app then", 12);
if (!middle.active || middle.query !== "src/app") throw new Error("caret-local reference not found");
if (parseReferenceInput("mail@example.test").active) throw new Error("email opened reference picker");
if (parseReferenceInput("Use @[./src/app.py]").active) throw new Error("completed reference reopened picker");
if (fileQueryDirectory("../secret") !== null) throw new Error("parent traversal accepted");
if (fileQueryDirectory("mcp/server") !== null) throw new Error("MCP reference opened file browser");
const mcp = getMcpReferences({{
tools: {{
effective_policy: {{ mode: "custom", mcp_default: "block", allowed: ["mcp:allowed:read"], blocked: ["mcp:blocked:read"] }},
catalog: [
{{ id: "mcp:allowed:read", available: true }},
{{ id: "mcp:blocked:read", available: true }},
{{ id: "mcp:default-blocked:read", available: true }},
{{ id: "mcp:missing:read", available: false }},
],
}},
}});
if (JSON.stringify(mcp) !== JSON.stringify([{{ name: "allowed", toolCount: 1 }}])) throw new Error("MCP policy scope leaked");
"""
subprocess.run(["node", "-e", script], check=True, text=True)
def test_composer_reference_picker_uses_plain_reference_tokens() -> None:
plugin_root = Path(__file__).resolve().parents[1]
store = (plugin_root / "webui" / "commands-slash-store.js").read_text(encoding="utf-8")
menu = (
plugin_root / "extensions" / "webui" / "chat-input-box-start" / "commands-menu.html"
).read_text(encoding="utf-8")
assert "@[agent/${key}]" in store
assert "@[skill/${name}]" in store
assert "value: `@[${displayPath}]`" in store
assert 'icon: isDirectory ? "folder" : "draft"' in store
assert 'icon: "person"' in store
assert 'icon: "auto_awesome"' in store
assert "skills.filter((skill) => !skill?.hidden)" in store
assert 'icon: "hub"' in store
assert "@[mcp/${name}]" in store
assert 'const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor"' in store
assert 'action: "list", context_id: contextId' in store
assert 'action: "load",' in store
assert "getMcpReferences(mcpResult?.state)" in store
assert 'mcp_servers_status' not in store
assert "composer-reference" in store
assert "node.dataset.label = reference.label" in store
assert 'callJsonApi("/chat_files_path_get"' in store
assert 'callJsonApi("/agents"' not in store
assert "filteredItems" in menu
assert "#chat-input .composer-reference" in menu
assert "content: attr(data-label)" in menu
assert "color: var(--color-highlight)" in menu
assert "color: var(--color-text)" in menu
assert "composer-reference.is-mcp" in menu
assert "background: transparent" in menu
@pytest.fixture
def scope_fixture() -> ScopeFixture:
suffix = uuid.uuid4().hex[:8]

View file

@ -1,5 +1,5 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { callJsonApi, fetchApi } from "/js/api.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as chatInputStore } from "/components/chat/input/input-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
@ -11,6 +11,8 @@ import {
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands";
const SKILLS_API_PATH = "/plugins/_skills/skills_catalog";
const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor";
function sanitizeCommandName(rawName) {
return (rawName || "")
@ -45,6 +47,58 @@ function parseSlashInput(message, allowPostfix = true) {
};
}
function parseReferenceInput(message, caretOffset = undefined) {
const text = String(message || "");
if (caretOffset === null) return { active: false, query: "", start: 0, end: 0 };
const caret = Math.max(0, Math.min(text.length, caretOffset ?? text.length));
const match = text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
if (!match) return { active: false, query: "", start: caret, end: caret };
if (match[1].startsWith("[") && match[1].endsWith("]")) {
return { active: false, query: "", start: caret, end: caret };
}
const token = `@${match[1]}`;
return {
active: true,
query: match[1].toLowerCase(),
start: caret - token.length,
end: caret,
};
}
function normalizePath(value) {
return String(value || "").replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/\/$/, "");
}
function fileQueryDirectory(query) {
const value = String(query || "").replace(/^\.\//, "");
if (value.startsWith("agent/") || value.startsWith("skill/") || value.startsWith("mcp/") || value.split("/").includes("..")) {
return null;
}
const slash = value.lastIndexOf("/");
return slash < 0 ? "" : value.slice(0, slash);
}
function mcpPolicyAllows(policy, id) {
if (!policy || policy.mode !== "custom") return true;
if (policy.blocked?.includes(id)) return false;
if (policy.allowed?.includes(id)) return true;
return policy.mcp_default === "allow";
}
function getMcpReferences(state) {
const servers = new Map();
const policy = state?.tools?.effective_policy;
for (const tool of state?.tools?.catalog || []) {
const id = String(tool?.id || "");
const match = id.match(/^mcp:([^:]+):/);
if (!match || tool?.available === false || !mcpPolicyAllows(policy, id)) continue;
const name = match[1];
servers.set(name, (servers.get(name) || 0) + 1);
}
return [...servers].map(([name, toolCount]) => ({ name, toolCount }));
}
function notifyError(message) {
void toastFrontendError(message, "Commands");
}
@ -74,6 +128,13 @@ const model = {
loading: false,
applying: false,
commands: [],
references: [],
referenceContextId: null,
referenceDirectoryKey: "",
referenceRoot: "",
referenceCatalog: [],
referenceFiles: [],
referenceLoadGeneration: 0,
contextScope: { project_name: "" },
lastContextId: "",
active: false,
@ -81,6 +142,10 @@ const model = {
query: "",
rawArguments: "",
rawMessage: "",
mode: "",
referenceStart: 0,
referenceEnd: 0,
referenceRange: null,
selectedIndex: 0,
boundInput: null,
keydownHandler: null,
@ -104,12 +169,37 @@ const model = {
});
},
get filteredReferences() {
const needle = (this.query || "").trim().toLowerCase().replace(/^\.\//, "");
const references = Array.isArray(this.references) ? this.references : [];
if (!needle) return references;
return references.filter((reference) => reference.search.includes(needle));
},
get filteredItems() {
return this.mode === "reference" ? this.filteredReferences : this.filteredCommands;
},
get selectedCommand() {
const commands = this.filteredCommands;
if (!commands.length) return null;
return commands[this.selectedIndex] || commands[0] || null;
},
get selectedItem() {
const items = this.filteredItems;
if (!items.length) return null;
return items[this.selectedIndex] || items[0] || null;
},
get loadingLabel() {
return this.mode === "reference" ? "Loading references..." : "Loading slash commands...";
},
get emptyLabel() {
return this.mode === "reference" ? "No matching references." : "No matching slash commands.";
},
get emptyStateLabel() {
const name = sanitizeCommandName(this.query || "");
return name ? `Create /${name}` : "Create slash command";
@ -146,6 +236,15 @@ const model = {
this.query = "";
this.rawArguments = "";
this.rawMessage = "";
this.mode = "";
this.referenceRange = null;
this.references = [];
this.referenceContextId = null;
this.referenceDirectoryKey = "";
this.referenceRoot = "";
this.referenceCatalog = [];
this.referenceFiles = [];
this.referenceLoadGeneration += 1;
this.selectedIndex = 0;
this.applying = false;
},
@ -233,13 +332,197 @@ const model = {
}
},
getCaretOffset() {
const input = this.getInputElement();
const selection = document.getSelection?.();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (range && chatInputStore?._isInCodeBlock?.(range.startContainer?.parentElement)) return null;
const offsets = chatInputStore?._selectionOffsets?.(input);
return offsets && offsets.start === offsets.end ? offsets.end : null;
},
captureReferenceRange(length) {
const input = this.getInputElement();
const selection = document.getSelection?.();
if (!input || !selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (
!range.collapsed ||
range.startContainer?.nodeType !== Node.TEXT_NODE ||
range.startOffset < length ||
!input.contains(range.startContainer)
) return null;
const triggerRange = range.cloneRange();
triggerRange.setStart(range.startContainer, range.startOffset - length);
return triggerRange;
},
async loadReferences(force = false) {
const contextId = this.getContextId();
const directory = fileQueryDirectory(this.query);
const generation = ++this.referenceLoadGeneration;
this.loading = true;
try {
if (force || contextId !== this.referenceContextId) {
const [rootResult, settingsResult, skillsResult, profilesResult] = await Promise.allSettled([
contextId ? callJsonApi("/chat_files_path_get", { ctxid: contextId }) : Promise.resolve(null),
callJsonApi("settings_get", null),
callJsonApi(SKILLS_API_PATH, { action: "list", context_id: contextId }),
callJsonApi(AGENT_EDITOR_API_PATH, { action: "list", context_id: contextId }),
]);
if (generation !== this.referenceLoadGeneration) return;
this.referenceRoot = normalizePath(
rootResult.value?.path || settingsResult.value?.settings?.workdir_path || "",
);
const skills = skillsResult.value?.ok && Array.isArray(skillsResult.value.skills)
? skillsResult.value.skills
: [];
const profiles = profilesResult.value?.ok && Array.isArray(profilesResult.value.profiles)
? profilesResult.value.profiles
: [];
const activeProfile = String(
chatsStore.selectedContext?.agent_profile
|| settingsResult.value?.settings?.agent_profile
|| "",
).trim();
const activeProfileAvailable = profiles.some((profile) => (
profile?.id === activeProfile && profile?.enabled && profile?.available
));
const mcpResult = activeProfileAvailable
? await callJsonApi(AGENT_EDITOR_API_PATH, {
action: "load",
profile_id: activeProfile,
context_id: contextId,
}).catch((error) => {
console.error("Failed to load scoped MCP references:", error);
return null;
})
: null;
if (generation !== this.referenceLoadGeneration) return;
const mcpServers = getMcpReferences(mcpResult?.state);
this.referenceCatalog = [
...profiles.filter((profile) => (
profile?.id !== "default" && profile?.enabled && profile?.available
)).map((profile) => {
const key = String(profile?.id || "").trim();
const label = String(profile?.title || key).trim();
return {
id: `agent:${key}`,
kind: "Agent",
icon: "person",
tone: "agent",
label,
value: `@[agent/${key}]`,
description: key === label ? "Agent profile" : `Agent profile · ${key}`,
search: `agent/${key} ${label}`.toLowerCase(),
};
}).filter((item) => item.id !== "agent:"),
...skills.filter((skill) => !skill?.hidden).map((skill) => {
const name = String(skill?.name || "").trim();
return {
id: `skill:${String(skill?.path || name)}`,
kind: "Skill",
icon: "auto_awesome",
tone: "skill",
label: name,
value: `@[skill/${name}]`,
description: String(skill?.description || "Skill").trim(),
search: `skill/${name} ${skill?.description || ""} ${skill?.path || ""}`.toLowerCase(),
};
}).filter((item) => item.label),
...mcpServers.map((server) => {
const name = String(server?.name || "").trim();
const description = `${Number(server?.toolCount || 0)} available MCP tools`;
return {
id: `mcp:${name}`,
kind: "MCP",
icon: "hub",
tone: "mcp",
label: name,
value: `@[mcp/${name}]`,
description,
search: `mcp/${name} ${name} ${description}`.toLowerCase(),
};
}).filter((item) => item.label),
];
this.referenceFiles = [];
this.referenceContextId = contextId;
this.referenceDirectoryKey = "";
}
const directoryKey = directory === null || !this.referenceRoot
? ""
: `${this.referenceRoot}/${directory}`.replace(/\/$/, "");
if (directory !== null && directoryKey && directoryKey !== this.referenceDirectoryKey) {
const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(directoryKey)}`);
const payload = await response.json().catch(() => ({}));
if (generation !== this.referenceLoadGeneration) return;
const entries = response.ok && Array.isArray(payload?.data?.entries) ? payload.data.entries : [];
const root = this.referenceRoot.replace(/^\//, "");
this.referenceFiles = entries.flatMap((entry) => {
const path = normalizePath(entry?.path).replace(/^\//, "");
if (!path || (path !== root && !path.startsWith(`${root}/`))) return [];
const relative = path === root ? "" : path.slice(root.length + 1);
if (!relative) return [];
const isDirectory = Boolean(entry?.is_dir);
const displayPath = `./${relative}${isDirectory ? "/" : ""}`;
return [{
id: `${isDirectory ? "folder" : "file"}:${path}`,
kind: isDirectory ? "Folder" : "File",
icon: isDirectory ? "folder" : "draft",
tone: isDirectory ? "folder" : "file",
label: displayPath,
value: `@[${displayPath}]`,
description: isDirectory ? "Folder in active workspace" : "File in active workspace",
search: displayPath.toLowerCase(),
}];
});
this.referenceDirectoryKey = directoryKey;
} else if (directory === null) {
this.referenceFiles = [];
this.referenceDirectoryKey = "";
}
if (generation === this.referenceLoadGeneration) {
this.references = [...this.referenceFiles, ...this.referenceCatalog];
this.ensureSelection();
}
} catch (error) {
console.error("Failed to load composer references:", error);
if (generation === this.referenceLoadGeneration) {
this.references = [...this.referenceCatalog];
}
} finally {
if (generation === this.referenceLoadGeneration) this.loading = false;
}
},
handleInput(event = null) {
this.ensureBindings();
this.dismissed = false;
const message = this.getInputMessage(event);
const reference = parseReferenceInput(message, this.getCaretOffset());
if (reference.active) {
const newReferenceSession = this.mode !== "reference";
this.mode = "reference";
this.active = true;
this.query = reference.query;
this.rawMessage = message;
this.referenceStart = reference.start;
this.referenceEnd = reference.end;
this.referenceRange = this.captureReferenceRange(reference.end - reference.start);
this.ensureSelection();
void this.loadReferences(newReferenceSession);
return;
}
const parsed = parseSlashInput(message, false);
this.referenceRange = null;
this.mode = parsed.active ? "slash" : "";
this.active = parsed.active;
this.query = parsed.query;
this.rawArguments = parsed.rawArguments;
@ -297,33 +580,79 @@ const model = {
return;
}
if (event.key === "Enter" && this.selectedCommand) {
if (event.key === "Enter" && this.selectedItem) {
event.preventDefault();
event.stopPropagation();
void this.applySelection(this.selectedCommand);
void this.applySelectedItem(this.selectedItem);
}
},
ensureSelection() {
const commands = this.filteredCommands;
if (!commands.length) {
const items = this.filteredItems;
if (!items.length) {
this.selectedIndex = 0;
return;
}
if (this.selectedIndex >= commands.length) {
if (this.selectedIndex >= items.length) {
this.selectedIndex = 0;
}
},
moveSelection(delta) {
const commands = this.filteredCommands;
if (!commands.length) return;
const items = this.filteredItems;
if (!items.length) return;
const nextIndex =
(this.selectedIndex + delta + commands.length) % commands.length;
(this.selectedIndex + delta + items.length) % items.length;
this.selectedIndex = nextIndex;
this.scrollSelectedIntoView();
},
applySelectedItem(item) {
return this.mode === "reference" ? this.applyReference(item) : this.applySelection(item);
},
applyReference(reference) {
const input = this.getInputElement();
if (!reference?.value || !input) return;
const current = this.getInputMessage();
const suffix = current.slice(this.referenceEnd);
const separator = suffix && /^\s/.test(suffix) ? "" : " ";
const nextText = `${current.slice(0, this.referenceStart)}${reference.value}${separator}${suffix}`;
const caret = this.referenceStart + reference.value.length + separator.length;
const range = this.referenceRange;
this.referenceRange = null;
if (range && input.contains(range.startContainer)) {
range.deleteContents();
const node = document.createElement("span");
node.className = `composer-reference is-${reference.tone}`;
node.dataset.reference = reference.value;
node.dataset.label = reference.label;
node.contentEditable = "false";
node.textContent = reference.value;
node.setAttribute("aria-label", `${reference.kind}: ${reference.label}`);
range.insertNode(node);
const space = separator ? document.createTextNode(separator) : null;
if (space) node.after(space);
range.setStartAfter(space || node);
range.collapse(true);
const selection = document.getSelection?.();
selection?.removeAllRanges();
selection?.addRange(range);
chatInputStore?._syncMessageFromEditor?.();
} else {
chatInputStore.message = nextText;
chatInputStore?._setEditorCaret?.(caret);
}
input.dispatchEvent(new Event("input", { bubbles: true }));
chatInputStore.adjustTextareaHeight();
this.active = false;
this.dismissed = false;
this.mode = "";
this.query = "";
this.selectedIndex = 0;
},
scrollSelectedIntoView() {
requestAnimationFrame(() => {
document