nandi/oripublic Fork 0
db7446cc6b52b6bd9196294c1e66e5eb56754d5e
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

mentions.ts · 153 lines · 4.5 KBTypeScript Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1/**
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
12import type { Skill } from "./api";
13import type { AcpAvailableCommand, PromptAttachment } from "./protocol";
14
15/** An active selector: what the user typed after "@" or "/", and where. */
16export 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. */
25export 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 */
45export 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 */
69export 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. */
82export 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 */
96export 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>". */
123export 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 */
141export 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}