/** * Pure logic behind the composer's "@" file selector and "/" skill selector: * detecting a trigger at the caret, applying a completion, merging skills * and agent commands, and keeping only the attachments still mentioned. * * @example * detectTrigger("look at @src/ma", 15); // { kind: "file", query: "src/ma", start: 8 } * applyCompletion("look at @src/ma", trigger, "@src/main.go"); * // { text: "look at @src/main.go ", caret: 21 } */ import type { Skill } from "./api"; import type { AcpAvailableCommand, PromptAttachment } from "./protocol"; /** An active selector: what the user typed after "@" or "/", and where. */ export interface Trigger { kind: "file" | "skill"; /** Text typed after the trigger character, up to the caret. */ query: string; /** Index of the trigger character in the text. */ start: number; } /** One row of the completion popup. */ export interface CompletionItem { /** Stable identifier (path for files, name for skills/commands). */ id: string; /** Main label. */ label: string; /** Secondary line (description, or nothing for files). */ description: string; /** Where it comes from; shown as a tag for skills and commands. */ source: "file" | "skill" | "command"; /** Text inserted in place of the trigger and its query. */ insert: string; /** For files: the attachment to send with the prompt. */ attachment?: PromptAttachment; } /** * detectTrigger finds the selector active at the caret: "/" at the very * start of the text (Claude Code slash-command style), or "@" preceded by a * whitespace or the start, with no whitespace between it and the caret. */ export function detectTrigger(text: string, caret: number): Trigger | null { const before = text.slice(0, caret); const slash = /^\/(\S*)$/.exec(before); if (slash) { return { kind: "skill", query: slash[1], start: 0 }; } const at = before.lastIndexOf("@"); if (at < 0) { return null; } const query = before.slice(at + 1); if (/\s/.test(query)) { return null; } if (at > 0 && !/\s/.test(before[at - 1])) { return null; } return { kind: "file", query, start: at }; } /** * applyCompletion replaces the trigger and its query with `insert` followed * by a space, and returns the new text and caret position. */ export function applyCompletion( text: string, trigger: Trigger, insert: string, ): { text: string; caret: number } { const caret = trigger.start + trigger.query.length + 1; const head = text.slice(0, trigger.start); const tail = text.slice(caret); const inserted = `${head}${insert} `; return { text: inserted + tail, caret: inserted.length }; } /** matchesQuery is the popup's filter: case-insensitive substring on label or description. */ export function matchesQuery(item: CompletionItem, query: string): boolean { const needle = query.toLowerCase(); return ( needle === "" || item.label.toLowerCase().includes(needle) || item.description.toLowerCase().includes(needle) ); } /** * skillItems merges the skills discovered on disk with the commands the * agent announced, deduplicated by name (a skill wins over a command of the * same name, since it carries the richer description), sorted by name. */ export function skillItems( skills: Skill[], commands: AcpAvailableCommand[], ): CompletionItem[] { const byName = new Map(); for (const command of commands) { byName.set(command.name, { id: command.name, label: command.name, description: command.description, source: "command", insert: `/${command.name}`, }); } for (const skill of skills) { byName.set(skill.name, { id: skill.name, label: skill.name, description: skill.description, source: "skill", insert: `/${skill.name}`, }); } return [...byName.values()].sort((a, b) => a.label.localeCompare(b.label)); } /** fileItem turns a search hit into a popup row inserting "@". */ export function fileItem(hit: { path: string; relPath: string; }): CompletionItem { return { id: hit.path, label: hit.relPath, description: "", source: "file", insert: `@${hit.relPath}`, attachment: { path: hit.path, name: hit.relPath }, }; } /** * activeAttachments keeps the attachments whose "@name" mention is still * present in the text — the user may have deleted one before sending. */ export function activeAttachments( text: string, attachments: PromptAttachment[], ): PromptAttachment[] { const seen = new Set(); return attachments.filter((attachment) => { if (seen.has(attachment.path) || !text.includes(`@${attachment.name}`)) { return false; } seen.add(attachment.path); return true; }); }