nandi/oripublic Fork 0
35061753be581c0ba47a3189520d64e7788c183d
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/**
 * 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<string, CompletionItem>();
	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 "@<relative path>". */
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<string>();
	return attachments.filter((attachment) => {
		if (seen.has(attachment.path) || !text.includes(`@${attachment.name}`)) {
			return false;
		}
		seen.add(attachment.path);
		return true;
	});
}