mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
The `/` completion of the New Task page only listed the commands the agent offered in its last session, cached once for all projects, and matched them by substring. Before a project's first session, or after a session in another project, its skills were missing, and `/trig` never matched `/triage`. - Remember the commands an agent offered per project root (settings stay shared). - New `agent_commands` command: the agent's last list for the project (or the task's own list in a session) merged with, for Claude Code, the skills (`.claude/skills/*/SKILL.md`) and custom commands (`.claude/commands/**/*.md`, nested as `dir:name`) found from the working directory upward and in `~/.claude`. The agent's entries win on a name; skills with `user-invocable: false` are left out. Discovery runs off the command thread. - Sessions discover from the task's own directory (worktree or checkout). - Command matching ranks prefix, plugin-name prefix, substring, then letters in order. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
153 lines
6.4 KiB
TypeScript
153 lines
6.4 KiB
TypeScript
/** Pure composer helpers: `@` / `/` completion, attachments, history recall, settings. */
|
||
import type { AgentCommand, AgentConfigOption, AgentImage, AgentPrompt, AgentUsage } from "@/bindings";
|
||
import { tx } from "@/platform/i18n";
|
||
|
||
export interface FileRef {
|
||
/** Absolute path (in the task's directory). */
|
||
path: string;
|
||
/** As written after `@`. */
|
||
rel: string;
|
||
}
|
||
|
||
export interface ComposerDraft {
|
||
text: string;
|
||
files: FileRef[];
|
||
images: AgentImage[];
|
||
}
|
||
|
||
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
|
||
|
||
export type Completion =
|
||
| { kind: "file"; query: string; from: number; to: number }
|
||
| { kind: "command"; query: string; from: number; to: number };
|
||
|
||
/** The `@file` or leading `/command` being typed at `caret`, if any. */
|
||
export function completionAt(text: string, caret: number): Completion | null {
|
||
const before = text.slice(0, caret);
|
||
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
|
||
if (!word) return null;
|
||
const from = caret - word[3]!.length - 1;
|
||
// Commands only as the message's first word.
|
||
if (word[2] === "/") return before.slice(0, from).trim() === "" ? { kind: "command", query: word[3]!, from, to: caret } : null;
|
||
return { kind: "file", query: word[3]!, from, to: caret };
|
||
}
|
||
|
||
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
|
||
export function applyCompletion(text: string, c: Completion, insert: string): { text: string; caret: number } {
|
||
const after = text.slice(c.to);
|
||
const glue = after.startsWith(" ") ? "" : " ";
|
||
const next = text.slice(0, c.from) + insert + glue + after;
|
||
return { text: next, caret: c.from + insert.length + 1 };
|
||
}
|
||
|
||
/**
|
||
* Commands matching `query`: prefix matches first (a plugin's "plugin:name" also by its
|
||
* name), then substrings, then names holding its letters in order ("trig" → "triage").
|
||
*/
|
||
export function matchCommands(commands: readonly AgentCommand[], query: string): AgentCommand[] {
|
||
const q = query.toLowerCase();
|
||
const rank = (name: string) => {
|
||
const n = name.toLowerCase();
|
||
if (n.startsWith(q) || n.slice(n.lastIndexOf(":") + 1).startsWith(q)) return 0;
|
||
if (n.includes(q)) return 1;
|
||
return inOrder(n, q) ? 2 : -1;
|
||
};
|
||
return commands
|
||
.map((command) => ({ command, rank: rank(command.name) }))
|
||
.filter((m) => m.rank >= 0)
|
||
.sort((a, b) => a.rank - b.rank)
|
||
.map((m) => m.command);
|
||
}
|
||
|
||
function inOrder(text: string, query: string): boolean {
|
||
let i = 0;
|
||
for (const ch of text) if (ch === query[i]) i++;
|
||
return i >= query.length;
|
||
}
|
||
|
||
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
|
||
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
|
||
if (!lines) return `@${rel}`;
|
||
return lines.from === lines.to ? `@${rel}:${lines.from}` : `@${rel}:${lines.from}-${lines.to}`;
|
||
}
|
||
|
||
/** The prompt to send: files still mentioned in the text (each once), and the images. */
|
||
export function toPrompt(draft: ComposerDraft): AgentPrompt {
|
||
const seen = new Set<string>();
|
||
const files = draft.files.filter((f) => {
|
||
if (seen.has(f.path) || !new RegExp(`@${escapeRe(f.rel)}(?![\\w./-])|@${escapeRe(f.rel)}:`).test(draft.text)) return false;
|
||
seen.add(f.path);
|
||
return true;
|
||
});
|
||
return { text: draft.text.trim(), files: files.map((f) => f.path), images: draft.images };
|
||
}
|
||
|
||
export const canSend = (d: ComposerDraft) => !!d.text.trim() || d.images.length > 0;
|
||
|
||
/**
|
||
* ↑ / ↓ through sent messages (newest first). `index` −1 is the unsent draft. Returns the
|
||
* new index, or `null` when the key should move the caret instead.
|
||
*/
|
||
export function recall(history: readonly string[], index: number, dir: -1 | 1): number | null {
|
||
const next = index + (dir < 0 ? 1 : -1);
|
||
if (next < -1 || next >= history.length) return null;
|
||
return next;
|
||
}
|
||
|
||
/** Whether ↑ / ↓ recalls history: the caret is on the first / last line. */
|
||
export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean {
|
||
return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n");
|
||
}
|
||
|
||
/** The settings shown as dropdowns: model, mode, thinking effort, in that order. */
|
||
export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
|
||
const order = ["model", "mode", "thought_level"];
|
||
return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1));
|
||
}
|
||
|
||
export const modeOption = (config: readonly AgentConfigOption[]) => config.find((o) => o.category === "mode");
|
||
|
||
/**
|
||
* An icon per permission mode, by what it lets the agent do (mode ids differ per agent:
|
||
* Claude `acceptEdits`, Gemini `auto_edit`, Codex `full-access`…). Unknown modes ask.
|
||
*/
|
||
export function modeIcon(mode: string): string {
|
||
const m = mode.toLowerCase().replace(/[^a-z]/g, "");
|
||
if (m.includes("plan") || m === "architect") return "todo";
|
||
if (m.includes("bypass") || m === "yolo" || m.includes("fullaccess") || m === "dangerous") return "unlock";
|
||
if (m.includes("readonly") || m === "ask" || m === "chat") return "eye";
|
||
if (m.includes("edit") || m === "code") return "edit";
|
||
if (m === "auto" || m === "agent") return "run";
|
||
return "lock";
|
||
}
|
||
|
||
/** Shift+Tab: the mode after the current one. */
|
||
export function nextValue(option: AgentConfigOption, current = option.current): string | null {
|
||
if (!option.values.length) return null;
|
||
const i = option.values.findIndex((v) => v.value === current);
|
||
return option.values[(i + 1) % option.values.length]!.value;
|
||
}
|
||
|
||
/** "12%" of the context window and a tooltip with the numbers. */
|
||
export function usageLabel(u: AgentUsage): { short: string; detail: string } {
|
||
const pct = Math.min(100, Math.round((u.used / u.size) * 100));
|
||
const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${Math.round(n)}`);
|
||
const cost = u.cost != null ? ` · ${u.currency === "USD" || !u.currency ? "$" : `${u.currency} `}${u.cost.toFixed(2)}` : "";
|
||
return { short: `${pct}%`, detail: tx`${k(u.used)} / ${k(u.size)} tokens${cost}` };
|
||
}
|
||
|
||
/** `data:image/png;base64,AAA` → an image attachment. */
|
||
export function imageFromDataUrl(url: string, name?: string): AgentImage | null {
|
||
const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);
|
||
if (!m || !m[1]!.startsWith("image/")) return null;
|
||
return { mimeType: m[1]!, data: m[2]!, name: name ?? null };
|
||
}
|
||
|
||
export const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp)$/i;
|
||
/** Larger images are refused (agents reject them anyway). */
|
||
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||
|
||
function escapeRe(s: string) {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|