Files
openidea/src/features/agents/composerModel.ts
T
l0ng-ai 34828bd710 fix(agents): Complete project skills and custom commands in the agent composer
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
2026-10-27 20:59:52 +08:00

153 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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, "\\$&");
}