forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 2 | * Pure logic behind the composer's "@" file selector and "/" skill selector: | |
| 3 | * detecting a trigger at the caret, applying a completion, merging skills | |
| 4 | * and agent commands, and keeping only the attachments still mentioned. | |
| 5 | * | |
| 6 | * @example | |
| 7 | * detectTrigger("look at @src/ma", 15); // { kind: "file", query: "src/ma", start: 8 } | |
| 8 | * applyCompletion("look at @src/ma", trigger, "@src/main.go"); | |
| 9 | * // { text: "look at @src/main.go ", caret: 21 } | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Skill } from "./api"; | |
| 13 | import type { AcpAvailableCommand, PromptAttachment } from "./protocol"; | |
| 14 | ||
| 15 | /** An active selector: what the user typed after "@" or "/", and where. */ | |
| 16 | export interface Trigger { | |
| 17 | kind: "file" | "skill"; | |
| 18 | /** Text typed after the trigger character, up to the caret. */ | |
| 19 | query: string; | |
| 20 | /** Index of the trigger character in the text. */ | |
| 21 | start: number; | |
| 22 | } | |
| 23 | ||
| 24 | /** One row of the completion popup. */ | |
| 25 | export interface CompletionItem { | |
| 26 | /** Stable identifier (path for files, name for skills/commands). */ | |
| 27 | id: string; | |
| 28 | /** Main label. */ | |
| 29 | label: string; | |
| 30 | /** Secondary line (description, or nothing for files). */ | |
| 31 | description: string; | |
| 32 | /** Where it comes from; shown as a tag for skills and commands. */ | |
| 33 | source: "file" | "skill" | "command"; | |
| 34 | /** Text inserted in place of the trigger and its query. */ | |
| 35 | insert: string; | |
| 36 | /** For files: the attachment to send with the prompt. */ | |
| 37 | attachment?: PromptAttachment; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * detectTrigger finds the selector active at the caret: "/" at the very | |
| 42 | * start of the text (Claude Code slash-command style), or "@" preceded by a | |
| 43 | * whitespace or the start, with no whitespace between it and the caret. | |
| 44 | */ | |
| 45 | export function detectTrigger(text: string, caret: number): Trigger | null { | |
| 46 | const before = text.slice(0, caret); | |
| 47 | const slash = /^\/(\S*)$/.exec(before); | |
| 48 | if (slash) { | |
| 49 | return { kind: "skill", query: slash[1], start: 0 }; | |
| 50 | } | |
| 51 | const at = before.lastIndexOf("@"); | |
| 52 | if (at < 0) { | |
| 53 | return null; | |
| 54 | } | |
| 55 | const query = before.slice(at + 1); | |
| 56 | if (/\s/.test(query)) { | |
| 57 | return null; | |
| 58 | } | |
| 59 | if (at > 0 && !/\s/.test(before[at - 1])) { | |
| 60 | return null; | |
| 61 | } | |
| 62 | return { kind: "file", query, start: at }; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * applyCompletion replaces the trigger and its query with `insert` followed | |
| 67 | * by a space, and returns the new text and caret position. | |
| 68 | */ | |
| 69 | export function applyCompletion( | |
| 70 | text: string, | |
| 71 | trigger: Trigger, | |
| 72 | insert: string, | |
| 73 | ): { text: string; caret: number } { | |
| 74 | const caret = trigger.start + trigger.query.length + 1; | |
| 75 | const head = text.slice(0, trigger.start); | |
| 76 | const tail = text.slice(caret); | |
| 77 | const inserted = `${head}${insert} `; | |
| 78 | return { text: inserted + tail, caret: inserted.length }; | |
| 79 | } | |
| 80 | ||
| 81 | /** matchesQuery is the popup's filter: case-insensitive substring on label or description. */ | |
| 82 | export function matchesQuery(item: CompletionItem, query: string): boolean { | |
| 83 | const needle = query.toLowerCase(); | |
| 84 | return ( | |
| 85 | needle === "" || | |
| 86 | item.label.toLowerCase().includes(needle) || | |
| 87 | item.description.toLowerCase().includes(needle) | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * skillItems merges the skills discovered on disk with the commands the | |
| 93 | * agent announced, deduplicated by name (a skill wins over a command of the | |
| 94 | * same name, since it carries the richer description), sorted by name. | |
| 95 | */ | |
| 96 | export function skillItems( | |
| 97 | skills: Skill[], | |
| 98 | commands: AcpAvailableCommand[], | |
| 99 | ): CompletionItem[] { | |
| 100 | const byName = new Map<string, CompletionItem>(); | |
| 101 | for (const command of commands) { | |
| 102 | byName.set(command.name, { | |
| 103 | id: command.name, | |
| 104 | label: command.name, | |
| 105 | description: command.description, | |
| 106 | source: "command", | |
| 107 | insert: `/${command.name}`, | |
| 108 | }); | |
| 109 | } | |
| 110 | for (const skill of skills) { | |
| 111 | byName.set(skill.name, { | |
| 112 | id: skill.name, | |
| 113 | label: skill.name, | |
| 114 | description: skill.description, | |
| 115 | source: "skill", | |
| 116 | insert: `/${skill.name}`, | |
| 117 | }); | |
| 118 | } | |
| 119 | return [...byName.values()].sort((a, b) => a.label.localeCompare(b.label)); | |
| 120 | } | |
| 121 | ||
| 122 | /** fileItem turns a search hit into a popup row inserting "@<relative path>". */ | |
| 123 | export function fileItem(hit: { | |
| 124 | path: string; | |
| 125 | relPath: string; | |
| 126 | }): CompletionItem { | |
| 127 | return { | |
| 128 | id: hit.path, | |
| 129 | label: hit.relPath, | |
| 130 | description: "", | |
| 131 | source: "file", | |
| 132 | insert: `@${hit.relPath}`, | |
| 133 | attachment: { path: hit.path, name: hit.relPath }, | |
| 134 | }; | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * activeAttachments keeps the attachments whose "@name" mention is still | |
| 139 | * present in the text — the user may have deleted one before sending. | |
| 140 | */ | |
| 141 | export function activeAttachments( | |
| 142 | text: string, | |
| 143 | attachments: PromptAttachment[], | |
| 144 | ): PromptAttachment[] { | |
| 145 | const seen = new Set<string>(); | |
| 146 | return attachments.filter((attachment) => { | |
| 147 | if (seen.has(attachment.path) || !text.includes(`@${attachment.name}`)) { | |
| 148 | return false; | |
| 149 | } | |
| 150 | seen.add(attachment.path); | |
| 151 | return true; | |
| 152 | }); | |
| 153 | } |