From bfdb7a1eaf431b919c18a13fb006aed750a85631 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:36:23 -0600 Subject: [PATCH 1/9] feat(chat): trigger parsing, fuzzy ranking and palette commands for the composer The pure half of #22, with no DOM and no remote calls, so one spec pins it. - composer-trigger: where an `@` mention or a `/` command starts and ends around the caret. `@` only opens at the start of a word, so an email address does not; `/` only as the first character, and `/usr/bin` is not a command. Accepting a mention writes the path as inline code, not `@path`, so what the model receives does not depend on how the SDK treats an `@`. - mention-match: an fzf-style subsequence score (word starts and runs score, gaps cost) with the best alignment rather than the first, file-name matches ahead of folder matches, and a greedy fallback for pathological lengths. - composer-commands: a command is plain data plus the handler its button already calls (compact, model, agent, research, plan, effort, attach, voice), with a `source` so the SDK's own commands can join the list later. /plan only ever toggles between Plan only and Ask. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/composer-commands.ts | 253 ++++++++++++++++ src/lib/chat/composer-trigger.ts | 220 ++++++++++++++ src/lib/chat/mention-match.ts | 349 +++++++++++++++++++++++ tests/chat.composer-suggest.unit.spec.ts | 343 ++++++++++++++++++++++ 4 files changed, 1165 insertions(+) create mode 100644 src/lib/chat/composer-commands.ts create mode 100644 src/lib/chat/composer-trigger.ts create mode 100644 src/lib/chat/mention-match.ts create mode 100644 tests/chat.composer-suggest.unit.spec.ts diff --git a/src/lib/chat/composer-commands.ts b/src/lib/chat/composer-commands.ts new file mode 100644 index 0000000..2b0d4ad --- /dev/null +++ b/src/lib/chat/composer-commands.ts @@ -0,0 +1,253 @@ +/** + * #22 — the composer's `/` command palette: what a command is, and the commands the app + * offers today. + * + * Every command here is an action the app already has a button for. The palette is a + * keyboard route to them, not a second implementation: each one calls the same handler its + * button calls. A command is plain data plus a `run`, so commands the Agent SDK reports (a + * trusted project's own `.claude/commands`, skills) can be listed beside these later, with + * `source: 'sdk'`, without changing the palette. + * + * Pure: no DOM, no remote calls, relative imports only, so the unit spec imports it directly. + * Whoever builds a command hands it the functions it needs. + */ + +import { rankItems } from './mention-match' +import type { ReasoningEffort } from './reasoning-effort' +import { normalizePermissionMode, type ConversationPermissionMode } from '../engine/permission-mode' + +export type ComposerChoice = { + id: string + label: string + detail?: string + /** The value in effect now; marked in the list. */ + current?: boolean +} + +export type CommandArgument = + /** Free text typed after the command, run when the message is sent: `/research `. */ + | { kind: 'text'; placeholder: string; hint: string } + /** One of a list, picked from a second menu: `/model `. */ + | { + kind: 'choice' + placeholder: string + /** What one choice is called, for "no model matches …". */ + noun: string + /** The choices, or null while they are still loading. */ + choices: () => ComposerChoice[] | null + } + +/** A line to show the user after the command ran, or nothing. */ +export type CommandOutcome = string | void + +export type ComposerCommand = { + /** Typed after the slash. Lowercase, no spaces. */ + name: string + aliases?: readonly string[] + description: string + /** Only the app's own commands exist today; the SDK's will say 'sdk'. */ + source?: 'app' | 'sdk' + argument?: CommandArgument + /** A short state shown beside the name, such as "on". */ + status?: () => string | null + /** Why the command cannot run right now, or null when it can. */ + unavailable?: () => string | null + run: (argument: string) => CommandOutcome | Promise +} + +/** The palette's order. Anything not listed goes after these, in the order it was given. */ +export const COMMAND_ORDER = ['compact', 'model', 'agent', 'research', 'plan', 'effort', 'attach', 'voice'] as const + +export function orderCommands(commands: readonly ComposerCommand[]): ComposerCommand[] { + const rank = (name: string) => { + const index = (COMMAND_ORDER as readonly string[]).indexOf(name) + return index < 0 ? COMMAND_ORDER.length : index + } + // A later command with the same name replaces an earlier one (a page overriding a default). + const byName = new Map() + for (const command of commands) byName.set(command.name, command) + return [...byName.values()].sort((a, b) => rank(a.name) - rank(b.name)) +} + +/** The command called `name`, or known by it as an alias. Case-insensitive. */ +export function findCommand(commands: readonly ComposerCommand[], name: string): ComposerCommand | null { + const wanted = name.toLowerCase() + return ( + commands.find((command) => command.name === wanted) ?? + commands.find((command) => command.aliases?.some((alias) => alias.toLowerCase() === wanted)) ?? + null + ) +} + +export function rankCommands(query: string, commands: readonly ComposerCommand[]) { + return rankItems(query, commands, (command) => [command.name, ...(command.aliases ?? [])]) +} + +export function rankChoices(query: string, choices: readonly ComposerChoice[], limit = 50) { + return rankItems(query, choices, (choice) => [choice.label, choice.id], limit) +} + +/** + * The choice a typed name means: an exact label or id first, then the best fuzzy match. + * `/model sonnet` and `/effort high` work without opening the list. + */ +export function resolveChoice(choices: readonly ComposerChoice[], typed: string): ComposerChoice | null { + const wanted = typed.trim().toLowerCase() + if (!wanted) return null + const exact = choices.find((choice) => choice.id.toLowerCase() === wanted || choice.label.toLowerCase() === wanted) + return exact ?? rankChoices(wanted, choices, 1)[0]?.item ?? null +} + +// ─────────── The commands ─────────── + +export const REASONING_OPTIONS: ReadonlyArray<{ value: ReasoningEffort; label: string }> = [ + { value: 'none', label: 'off' }, + { value: 'minimal', label: 'min' }, + { value: 'low', label: 'low' }, + { value: 'medium', label: 'med' }, + { value: 'high', label: 'high' }, + { value: 'xhigh', label: 'max' }, +] + +type Chooser = (value: T) => unknown + +export function modelCommand(input: { + current: () => string + models: () => ReadonlyArray<{ id: string; name: string }> | null + pick: Chooser +}): ComposerCommand { + const choices = (): ComposerChoice[] | null => { + const models = input.models() + if (!models) return null + const current = input.current() + return models.map((model) => ({ id: model.id, label: model.name, detail: model.id, current: model.id === current })) + } + return { + name: 'model', + description: 'Switch the model for the next message', + source: 'app', + argument: { kind: 'choice', placeholder: '', noun: 'model', choices }, + async run(id) { + await input.pick(id) + const label = choices()?.find((choice) => choice.id === id)?.label ?? id + return `Model: ${label}` + }, + } +} + +export function agentCommand(input: { + current: () => string | null + agents: () => ReadonlyArray<{ id: string; name: string; role: string }> + pick: Chooser +}): ComposerCommand { + const choices = (): ComposerChoice[] => { + const current = input.current() + return input.agents().map((agent) => ({ id: agent.id, label: agent.name, detail: agent.role, current: agent.id === current })) + } + return { + name: 'agent', + description: 'Hand this conversation to another agent', + source: 'app', + argument: { kind: 'choice', placeholder: '', noun: 'agent', choices }, + async run(id) { + await input.pick(id) + return `Agent: ${choices().find((choice) => choice.id === id)?.label ?? id}` + }, + } +} + +export function effortCommand(input: { current: () => ReasoningEffort; pick: Chooser }): ComposerCommand { + return { + name: 'effort', + aliases: ['reasoning', 'think'], + description: 'Set how hard the model thinks before answering', + source: 'app', + argument: { + kind: 'choice', + placeholder: '', + noun: 'level', + choices: () => { + const current = input.current() + return REASONING_OPTIONS.map((option) => ({ id: option.value, label: option.label, current: option.value === current })) + }, + }, + async run(id) { + const option = REASONING_OPTIONS.find((candidate) => candidate.value === id) + if (!option) return + await input.pick(option.value) + return `Reasoning effort: ${option.label}` + }, + } +} + +export function attachCommand(open: () => unknown): ComposerCommand { + return { + name: 'attach', + aliases: ['upload'], + description: 'Attach files to the next message', + source: 'app', + async run() { + await open() + }, + } +} + +export function voiceCommand(input: { recording: () => boolean; toggle: () => unknown }): ComposerCommand { + return { + name: 'voice', + aliases: ['dictate', 'mic'], + description: 'Start or stop dictating into the message box', + source: 'app', + status: () => (input.recording() ? 'recording' : null), + async run() { + await input.toggle() + }, + } +} + +export function compactCommand(compact: () => unknown): ComposerCommand { + return { + name: 'compact', + description: 'Summarise the conversation so far to free up context', + source: 'app', + async run() { + await compact() + }, + } +} + +export function researchCommand(start: (question: string) => unknown): ComposerCommand { + return { + name: 'research', + description: 'Start a deep research run on a question', + source: 'app', + argument: { kind: 'text', placeholder: '', hint: 'Type the question to research, then press Enter.' }, + async run(question) { + await start(question) + }, + } +} + +/** Plan mode toggles against Ask: leaving plan never lands on a looser mode than the default. */ +export function nextPlanMode(current: unknown): ConversationPermissionMode { + return normalizePermissionMode(current) === 'plan' ? 'default' : 'plan' +} + +export function planModeCommand(input: { + current: () => unknown + setMode: (mode: ConversationPermissionMode) => Promise | unknown +}): ComposerCommand { + return { + name: 'plan', + description: 'Turn plan mode on or off (read-only: the agent plans instead of acting)', + source: 'app', + status: () => (normalizePermissionMode(input.current()) === 'plan' ? 'on' : null), + async run() { + const next = nextPlanMode(input.current()) + await input.setMode(next) + return next === 'plan' + ? 'Plan mode is on: the agent reads and plans, and changes nothing.' + : 'Plan mode is off: tools ask for approval as usual.' + }, + } +} diff --git a/src/lib/chat/composer-trigger.ts b/src/lib/chat/composer-trigger.ts new file mode 100644 index 0000000..dc5c0aa --- /dev/null +++ b/src/lib/chat/composer-trigger.ts @@ -0,0 +1,220 @@ +/** + * #22 — where the caret is, as far as the composer's suggestion menus are concerned. + * + * Pure string work on the textarea's value and caret, with no DOM, so the unit spec pins it. + * + * Two triggers: + * + * - `@` starts a file mention anywhere a word can start: at the very beginning, or after + * whitespace, `(` or `[`. So `see @src/app.ts` triggers and `me@example.com` does not. + * The query is everything after the `@` up to the next space. + * - `/` starts a command, but only as the first character of the message. A command lives + * on the first line: `/model son` is the model command with "son" typed after it, and + * anything on later lines is left alone. `/usr/bin is broken` is not a command, because + * the word after the slash is followed by another slash, not by a space. + */ + +/** Longest query a trigger will carry; past this the menu closes rather than searching. */ +export const MAX_TRIGGER_QUERY = 200 + +export type MentionTrigger = { + kind: 'mention' + /** Index of the `@`. */ + start: number + /** Index just past the last character of the query. */ + end: number + query: string +} + +export type CommandTrigger = { + kind: 'command' + start: 0 + /** Index just past the command word. */ + end: number + /** The command word typed so far, without the slash. */ + query: string +} + +export type ArgumentTrigger = { + kind: 'argument' + /** The command word, lowercased. */ + name: string + /** Where the argument starts on the first line (after the whitespace that follows the word). */ + start: number + /** The end of the first line. */ + end: number + /** What has been typed after the command word on the first line, trimmed. */ + query: string +} + +export type ComposerTrigger = MentionTrigger | CommandTrigger | ArgumentTrigger + +const MENTION_LEAD = /[\s([]/ +const MENTION_CHAR = /[^\s@]/ + +export function findMentionTrigger(value: string, caret: number): MentionTrigger | null { + if (caret < 1 || caret > value.length) return null + // Walk back from the caret to the `@` that opens this token, if there is one. + let at = -1 + for (let i = caret - 1; i >= 0; i--) { + const ch = value[i] + if (ch === '@') { + at = i + break + } + if (!MENTION_CHAR.test(ch)) return null + if (caret - i > MAX_TRIGGER_QUERY) return null + } + if (at < 0) return null + if (at > 0 && !MENTION_LEAD.test(value[at - 1])) return null + + let end = caret + while (end < value.length && MENTION_CHAR.test(value[end])) end++ + const query = value.slice(at + 1, end) + if (query.length > MAX_TRIGGER_QUERY) return null + return { kind: 'mention', start: at, end, query } +} + +/** The command word at the start of `value`, or null when the value is not a command. */ +function commandWord(value: string): { word: string; end: number } | null { + if (value[0] !== '/') return null + let end = 1 + while (end < value.length && !/[\s/]/.test(value[end])) end++ + // `/usr/bin`: the word is followed by a slash, not by whitespace or the end. + if (end < value.length && value[end] === '/') return null + const word = value.slice(1, end) + if (word.length > MAX_TRIGGER_QUERY) return null + return { word, end } +} + +function firstLineEnd(value: string): number { + const newline = value.indexOf('\n') + return newline < 0 ? value.length : newline +} + +export function findSlashTrigger(value: string, caret: number): CommandTrigger | ArgumentTrigger | null { + const command = commandWord(value) + if (!command) return null + const lineEnd = firstLineEnd(value) + if (caret < 1 || caret > lineEnd) return null + if (caret <= command.end) return { kind: 'command', start: 0, end: command.end, query: command.word } + if (!command.word) return null + let start = command.end + while (start < lineEnd && /[ \t]/.test(value[start])) start++ + const query = value.slice(start, lineEnd).trim() + if (query.length > MAX_TRIGGER_QUERY) return null + return { kind: 'argument', name: command.word.toLowerCase(), start, end: lineEnd, query } +} + +/** + * The trigger at the caret. A mention wins over a command's argument, so `@` still completes + * a file inside `/research compare @src/a.ts with …`. + */ +export function findComposerTrigger(value: string, caret: number): ComposerTrigger | null { + return findMentionTrigger(value, caret) ?? findSlashTrigger(value, caret) +} + +export function sameTrigger(a: ComposerTrigger | null, b: ComposerTrigger | null): boolean { + if (!a || !b) return a === b + if (a.kind !== b.kind || a.start !== b.start || a.end !== b.end || a.query !== b.query) return false + return a.kind !== 'argument' || (b.kind === 'argument' && a.name === b.name) +} + +// ─────────── Accepting a suggestion ─────────── + +export type Edit = { value: string; caret: number } + +/** + * How a mentioned path is written into the message: as inline code, which reads as a path in + * the rendered transcript and is unambiguous to the model. Deliberately not `@path` — whether + * the Agent SDK expands a literal `@path` in a prompt, and against which directory, is not + * something this app controls. + */ +export function formatMentionPath(path: string): string { + if (!path.includes('`')) return `\`${path}\`` + // A backtick in the name: a longer fence, padded so a leading or trailing tick survives. + return `\`\` ${path} \`\`` +} + +/** Replace `@query` with the formatted path and a space, and put the caret after it. */ +export function applyMention(value: string, trigger: MentionTrigger, path: string): Edit { + const before = value.slice(0, trigger.start) + const after = value.slice(trigger.end) + const insert = formatMentionPath(path) + const spacer = after.length > 0 && /^\s/.test(after) ? '' : ' ' + const next = `${before}${insert}${spacer}${after}` + return { value: next, caret: before.length + insert.length + spacer.length } +} + +/** + * Insert a bare `@` at the caret, as the `@ Context` button does, with a space in front when + * it would otherwise be glued to a word. + */ +export function insertMentionTrigger(value: string, caret: number): Edit { + const at = Math.max(0, Math.min(caret, value.length)) + const before = value.slice(0, at) + const lead = before.length > 0 && !MENTION_LEAD.test(before[before.length - 1]) ? ' ' : '' + const next = `${before}${lead}@${value.slice(at)}` + return { value: next, caret: at + lead.length + 1 } +} + +/** + * Start a command, as the `/ Commands` button does. An empty message becomes `/`; a draft + * moves down a line under it, so it survives whichever command is picked. + */ +export function insertCommandTrigger(value: string): Edit { + const command = commandWord(value) + if (command) { + // Already a command: the caret goes to the end of its word, which reopens the palette on it. + return { value, caret: command.end } + } + return { value: value.trim() ? `/\n${value}` : '/', caret: 1 } +} + +/** + * Picking a command that takes an argument: `/name ` on the first line with the caret after + * it, and everything the user had after the command word kept. + */ +export function applyCommandName(value: string, trigger: CommandTrigger, name: string): Edit { + const rest = value.slice(trigger.end).replace(/^[ \t]+/, '') + const head = `/${name} ` + // A draft that sat on the same line becomes the argument; one on the next line stays put. + return { value: `${head}${rest}`, caret: head.length } +} + +/** + * The message with the command removed: the command word goes, and so does the rest of its + * line when that line was the command's argument (`consumeLine`). What remains is the draft. + */ +export function stripCommand(value: string, options: { consumeLine: boolean }): string { + const command = commandWord(value) + if (!command) return value + const lineEnd = firstLineEnd(value) + const restOfLine = options.consumeLine ? '' : value.slice(command.end, lineEnd).trim() + const below = lineEnd < value.length ? value.slice(lineEnd + 1) : '' + if (restOfLine && below) return `${restOfLine}\n${below}` + return restOfLine || below +} + +// ─────────── Sending a command ─────────── + +export type ParsedCommand = { + /** The command word, lowercased. */ + name: string + /** Everything after the word, across lines, trimmed: a free-text argument. */ + argument: string + /** What follows the word on its own line, trimmed: a choice's name. */ + lineArgument: string +} + +/** The command a message starts with, when it starts with one. */ +export function parseSlashCommand(value: string): ParsedCommand | null { + const command = commandWord(value) + if (!command || !command.word) return null + const lineEnd = firstLineEnd(value) + return { + name: command.word.toLowerCase(), + argument: value.slice(command.end).trim(), + lineArgument: value.slice(command.end, lineEnd).trim(), + } +} diff --git a/src/lib/chat/mention-match.ts b/src/lib/chat/mention-match.ts new file mode 100644 index 0000000..0a7f4ea --- /dev/null +++ b/src/lib/chat/mention-match.ts @@ -0,0 +1,349 @@ +/** + * #22 — fuzzy matching for the composer's suggestion menus: `@` file mentions and the `/` + * command palette. + * + * Pure: no `node:` imports and no `$lib` aliases, so the server's file search and the + * browser's command palette rank with the same rules, and the unit spec imports it directly. + * + * A query matches when its characters appear in order in the candidate, case-insensitively + * ("mm" matches "mention-match"). Among the ways a query can line up with a candidate, the + * best-scoring one wins: matches at the start of a word (after `/`, `-`, `_`, `.`, a space, + * or a camelCase hump) and runs of adjacent characters score higher; gaps cost a little. + */ + +export type FuzzyMatch = { + score: number + /** Positions in the candidate that matched, one per query character, ascending. */ + indices: number[] +} + +const SCORE_MATCH = 16 +const GAP_START = 3 +const GAP_EXTENSION = 1 +const BONUS_CONSECUTIVE = 5 +const BONUS_FIRST_CHAR = 10 +const BONUS_AFTER_SLASH = 10 +const BONUS_AFTER_SEPARATOR = 8 +const BONUS_CAMEL = 7 +const BONUS_DIGIT = 4 + +/** Above this many query × candidate cells the exact alignment is skipped for a greedy one. */ +const MAX_ALIGNMENT_CELLS = 32_768 + +const NEG = -1e9 + +function isUpper(c: string) { + return c >= 'A' && c <= 'Z' +} +function isLower(c: string) { + return c >= 'a' && c <= 'z' +} +function isDigit(c: string) { + return c >= '0' && c <= '9' +} + +/** What matching the character at `i` is worth on top of the match itself. */ +function positionBonus(text: string, i: number): number { + if (i === 0) return BONUS_FIRST_CHAR + const prev = text[i - 1] + if (prev === '/' || prev === '\\') return BONUS_AFTER_SLASH + if (prev === '-' || prev === '_' || prev === '.' || prev === ' ') return BONUS_AFTER_SEPARATOR + const ch = text[i] + if (isLower(prev) && isUpper(ch)) return BONUS_CAMEL + if (!isDigit(prev) && isDigit(ch)) return BONUS_DIGIT + return 0 +} + +function isSubsequence(q: string, t: string): boolean { + let j = 0 + for (let i = 0; i < t.length && j < q.length; i++) if (t[i] === q[j]) j++ + return j === q.length +} + +/** First-fit alignment, for pathological lengths only. Still a valid match, just not the best one. */ +function greedyMatch(q: string, t: string, text: string): FuzzyMatch { + const indices: number[] = [] + let score = 0 + let prev = -2 + for (let i = 0, j = 0; i < t.length && j < q.length; i++) { + if (t[i] !== q[j]) continue + score += SCORE_MATCH + positionBonus(text, i) + (i === prev + 1 ? BONUS_CONSECUTIVE : prev >= 0 ? -GAP_START : 0) + indices.push(i) + prev = i + j++ + } + return { score, indices } +} + +/** + * Score `query` against `text`, or `null` when it does not match at all. + * + * An empty query matches everything with a score of 0. + */ +export function fuzzyMatch(query: string, text: string): FuzzyMatch | null { + const m = query.length + if (m === 0) return { score: 0, indices: [] } + const n = text.length + if (m > n) return null + const q = query.toLowerCase() + const t = text.toLowerCase() + if (!isSubsequence(q, t)) return null + if (m * n > MAX_ALIGNMENT_CELLS) return greedyMatch(q, t, text) + + const bonus = new Int8Array(n) + for (let j = 0; j < n; j++) bonus[j] = positionBonus(text, j) + + // best[i*n + j]: the best score with query[i] matched at text[j]. from[…]: where query[i-1] sat. + const best = new Float64Array(m * n).fill(NEG) + const from = new Int32Array(m * n).fill(-1) + + for (let i = 0; i < m; i++) { + const row = i * n + const prevRow = row - n + // The best predecessor at least two characters back, already charged for the gap. + let gapBest = NEG + let gapFrom = -1 + for (let j = i; j < n; j++) { + if (i > 0 && j >= 2) { + const entering = best[prevRow + j - 2] - GAP_START + const extended = gapBest - GAP_EXTENSION + if (entering >= extended) { + gapBest = entering + gapFrom = j - 2 + } else { + gapBest = extended + } + } + if (t[j] !== q[i]) continue + const own = SCORE_MATCH + bonus[j] + if (i === 0) { + best[row + j] = own + continue + } + let score = NEG + let source = -1 + const adjacent = best[prevRow + j - 1] + if (adjacent > NEG / 2) { + score = adjacent + BONUS_CONSECUTIVE + source = j - 1 + } + if (gapBest > NEG / 2 && gapBest > score) { + score = gapBest + source = gapFrom + } + if (source < 0) continue + best[row + j] = score + own + from[row + j] = source + } + } + + const lastRow = (m - 1) * n + let end = -1 + let top = NEG + for (let j = m - 1; j < n; j++) { + if (best[lastRow + j] > top) { + top = best[lastRow + j] + end = j + } + } + if (end < 0) return null + + const indices = new Array(m) + for (let i = m - 1, j = end; i >= 0; i--) { + indices[i] = j + j = from[i * n + j] + } + return { score: top, indices } +} + +// ─────────── File paths (`@` mentions) ─────────── + +/** A match inside the file name beats the same match spread across the directories. */ +const BONUS_BASENAME = 24 +const BONUS_PREFIX = 12 +const BONUS_EXACT = 24 +const PENALTY_DEPTH = 1.5 +const PENALTY_LENGTH = 0.05 + +export type RankedPath = { + /** Workspace-relative, `/`-separated. A directory keeps its trailing `/`. */ + path: string + isDirectory: boolean + score: number + /** Matched positions in `path`, for highlighting. */ + indices: number[] +} + +function depthOf(path: string): number { + let depth = 0 + for (let i = 0; i < path.length; i++) if (path[i] === '/') depth++ + return depth +} + +function stem(name: string): string { + const dot = name.lastIndexOf('.') + return dot > 0 ? name.slice(0, dot) : name +} + +/** + * Rank workspace paths against what was typed after `@`. + * + * - No query: the shallowest entries first, directories before files, then by name — a + * look at the top of the tree. + * - A query with no `/` is matched against the file name first, and only against the whole + * path when the name alone does not contain it. + * - A query with a `/` is matched against the whole path, so `lib/chat` narrows by folder. + * + * Shorter, shallower paths win ties. + */ +export function rankPaths(query: string, paths: readonly string[], limit = 20): RankedPath[] { + const q = query.trim() + if (!q) { + return paths + .map((path) => ({ path, isDirectory: path.endsWith('/'), depth: depthOf(path.endsWith('/') ? path.slice(0, -1) : path) })) + .sort( + (a, b) => + a.depth - b.depth || Number(b.isDirectory) - Number(a.isDirectory) || a.path.localeCompare(b.path), + ) + .slice(0, limit) + .map(({ path, isDirectory }) => ({ path, isDirectory, score: 0, indices: [] })) + } + + const lowerQuery = q.toLowerCase() + const wholePath = q.includes('/') + const ranked: RankedPath[] = [] + for (const path of paths) { + const isDirectory = path.endsWith('/') + const bare = isDirectory ? path.slice(0, -1) : path + let score = 0 + let indices: number[] | null = null + + if (!wholePath) { + const slash = bare.lastIndexOf('/') + const name = bare.slice(slash + 1) + const inName = fuzzyMatch(q, name) + if (inName) { + const lowerName = name.toLowerCase() + score = inName.score + BONUS_BASENAME + if (lowerName.startsWith(lowerQuery)) score += BONUS_PREFIX + if (lowerName === lowerQuery || stem(lowerName) === lowerQuery) score += BONUS_EXACT + indices = inName.indices.map((i) => i + slash + 1) + } + } + if (!indices) { + const inPath = fuzzyMatch(q, bare) + if (!inPath) continue + score = inPath.score + indices = inPath.indices + } + + score -= depthOf(bare) * PENALTY_DEPTH + bare.length * PENALTY_LENGTH + ranked.push({ path, isDirectory, score, indices }) + } + + ranked.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path)) + return ranked.slice(0, limit) +} + +// ─────────── Anything else (commands, choices) ─────────── + +export type RankedItem = { + item: T + score: number + /** Matched positions in the key that matched best. */ + indices: number[] + /** Which of the item's keys matched best; 0 is its primary name. */ + key: number +} + +/** + * Rank items by their best-matching key (a command's name and aliases, a choice's label and + * id). An empty query keeps the given order. The sort is stable, so equal scores keep it too. + */ +export function rankItems( + query: string, + items: readonly T[], + keys: (item: T) => readonly string[], + limit = Number.POSITIVE_INFINITY, +): RankedItem[] { + const q = query.trim() + if (!q) return items.slice(0, limit).map((item) => ({ item, score: 0, indices: [], key: 0 })) + const lowerQuery = q.toLowerCase() + const ranked: RankedItem[] = [] + for (const item of items) { + let top: RankedItem | null = null + keys(item).forEach((candidate, key) => { + const match = fuzzyMatch(q, candidate) + if (!match) return + const lower = candidate.toLowerCase() + let score = match.score + if (lower.startsWith(lowerQuery)) score += BONUS_PREFIX + if (lower === lowerQuery) score += BONUS_EXACT + // The primary name wins a tie with an alias. + if (key > 0) score -= 1 + if (!top || score > top.score) top = { item, score, indices: match.indices, key } + }) + if (top) ranked.push(top) + } + ranked.sort((a, b) => b.score - a.score) + return ranked.slice(0, limit) +} + +// ─────────── Highlighting ─────────── + +export type Segment = { text: string; match: boolean } + +/** Split `text` into runs of matched and unmatched characters, for rendering a highlight. */ +export function highlightSegments(text: string, indices: readonly number[] = []): Segment[] { + if (indices.length === 0) return text ? [{ text, match: false }] : [] + const hit = new Set(indices) + const segments: Segment[] = [] + for (let i = 0; i < text.length; i++) { + const match = hit.has(i) + const last = segments[segments.length - 1] + if (last && last.match === match) last.text += text[i] + else segments.push({ text: text[i], match }) + } + return segments +} + +/** + * A path as the menu shows it: the name on top, the folder it is in underneath, each with + * its own share of the matched positions. + */ +export function splitPathForDisplay( + path: string, + indices: readonly number[] = [], +): { name: string; nameIndices: number[]; folder: string; folderIndices: number[] } { + const bare = path.endsWith('/') ? path.slice(0, -1) : path + const slash = bare.lastIndexOf('/') + const name = path.slice(slash + 1) + const folder = slash < 0 ? '' : bare.slice(0, slash + 1) + return { + name, + nameIndices: indices.filter((i) => i > slash).map((i) => i - slash - 1), + folder, + folderIndices: indices.filter((i) => i <= slash), + } +} + +// ─────────── The `@` search's wire shape ─────────── + +export type MentionResult = { + path: string + isDirectory: boolean + indices: number[] +} + +export type MentionSearchResult = + | { ok: true; results: MentionResult[]; truncated: boolean } + | { + ok: false + /** + * - `no-workspace`: there is no directory whose files a turn could open by these paths + * - `not-found`: no such conversation for this user + * - `invalid`: the query itself was refused + */ + reason: 'no-workspace' | 'not-found' | 'invalid' + message: string + } diff --git a/tests/chat.composer-suggest.unit.spec.ts b/tests/chat.composer-suggest.unit.spec.ts new file mode 100644 index 0000000..f0bcd00 --- /dev/null +++ b/tests/chat.composer-suggest.unit.spec.ts @@ -0,0 +1,343 @@ +import { expect, test } from '@playwright/test' +import { + applyCommandName, + applyMention, + findComposerTrigger, + findMentionTrigger, + findSlashTrigger, + formatMentionPath, + insertCommandTrigger, + insertMentionTrigger, + MAX_TRIGGER_QUERY, + parseSlashCommand, + stripCommand, + type CommandTrigger, + type MentionTrigger, +} from '../src/lib/chat/composer-trigger' +import { fuzzyMatch, highlightSegments, rankItems, rankPaths, splitPathForDisplay } from '../src/lib/chat/mention-match' +import { + agentCommand, + compactCommand, + effortCommand, + findCommand, + modelCommand, + nextPlanMode, + orderCommands, + planModeCommand, + rankCommands, + researchCommand, + resolveChoice, + type ComposerCommand, +} from '../src/lib/chat/composer-commands' + +/** + * #22 — the composer's `@` mentions and `/` palette, as pure functions: where a trigger is, + * what accepting a suggestion does to the text, how candidates rank, and what each command + * runs. The UI spec (chat.composer-mentions.spec.ts) drives the same code through the page. + */ + +test.describe('composer trigger — @ mentions', () => { + test('an @ at the start or after whitespace, ( or [ opens a mention', () => { + expect(findMentionTrigger('@src', 4)).toEqual({ kind: 'mention', start: 0, end: 4, query: 'src' }) + expect(findMentionTrigger('see @pin', 8)).toEqual({ kind: 'mention', start: 4, end: 8, query: 'pin' }) + expect(findMentionTrigger('line one\n@a', 11)).toMatchObject({ start: 9, query: 'a' }) + expect(findMentionTrigger('(@lib', 5)).toMatchObject({ start: 1, query: 'lib' }) + expect(findMentionTrigger('[@lib', 5)).toMatchObject({ start: 1, query: 'lib' }) + // Just the @: an empty query, which lists the top of the tree. + expect(findMentionTrigger('look at @', 9)).toMatchObject({ start: 8, query: '' }) + }) + + test('an email address or an @ inside a word is not a mention', () => { + expect(findMentionTrigger('me@example.com', 14)).toBeNull() + expect(findMentionTrigger('a@b', 3)).toBeNull() + expect(findMentionTrigger('x`@y', 4)).toBeNull() + }) + + test('the caret must be inside the token; the query is the whole token', () => { + // Caret in the middle: still that token, and the query runs to its end. + expect(findMentionTrigger('@src/lib next', 3)).toEqual({ kind: 'mention', start: 0, end: 8, query: 'src/lib' }) + // Caret after the space that ends it: no longer a mention. + expect(findMentionTrigger('@src ', 5)).toBeNull() + expect(findMentionTrigger('@src', 0)).toBeNull() + }) + + test('a query longer than the limit closes the menu instead of searching', () => { + const long = `@${'a'.repeat(MAX_TRIGGER_QUERY + 1)}` + expect(findMentionTrigger(long, long.length)).toBeNull() + const fits = `@${'a'.repeat(MAX_TRIGGER_QUERY)}` + expect(findMentionTrigger(fits, fits.length)?.query.length).toBe(MAX_TRIGGER_QUERY) + }) + + test('accepting replaces @query with the backticked path and one space', () => { + const trigger = findMentionTrigger('check @pin please', 10) as MentionTrigger + const edit = applyMention('check @pin please', trigger, 'docs/pinned.md') + // The existing space after the token is reused, not doubled. + expect(edit.value).toBe('check `docs/pinned.md` please') + expect(edit.value.slice(0, edit.caret)).toBe('check `docs/pinned.md`') + + const atEnd = findMentionTrigger('open @ap', 8) as MentionTrigger + const second = applyMention('open @ap', atEnd, 'src/app.ts') + expect(second).toEqual({ value: 'open `src/app.ts` ', caret: 'open `src/app.ts` '.length }) + }) + + test('a path with a backtick in it gets a longer fence', () => { + expect(formatMentionPath('a/b.ts')).toBe('`a/b.ts`') + expect(formatMentionPath('odd`name.md')).toBe('`` odd`name.md ``') + }) + + test('the @ Context button inserts an @, spaced off the previous word', () => { + expect(insertMentionTrigger('', 0)).toEqual({ value: '@', caret: 1 }) + expect(insertMentionTrigger('fix', 3)).toEqual({ value: 'fix @', caret: 5 }) + expect(insertMentionTrigger('fix ', 4)).toEqual({ value: 'fix @', caret: 5 }) + expect(insertMentionTrigger('ab', 1)).toEqual({ value: 'a @b', caret: 3 }) + }) +}) + +test.describe('composer trigger — / commands', () => { + test('a slash opens the palette only as the first character', () => { + expect(findSlashTrigger('/', 1)).toEqual({ kind: 'command', start: 0, end: 1, query: '' }) + expect(findSlashTrigger('/mo', 3)).toEqual({ kind: 'command', start: 0, end: 3, query: 'mo' }) + expect(findSlashTrigger('hello /mo', 9)).toBeNull() + expect(findSlashTrigger(' /mo', 4)).toBeNull() + }) + + test('a path is not a command', () => { + expect(findSlashTrigger('/usr/bin is broken', 4)).toBeNull() + expect(parseSlashCommand('/usr/bin is broken')).toBeNull() + }) + + test('after the command word, the rest of the first line is its argument', () => { + expect(findSlashTrigger('/model son', 10)).toEqual({ kind: 'argument', name: 'model', start: 7, end: 10, query: 'son' }) + expect(findSlashTrigger('/Model ', 8)).toMatchObject({ kind: 'argument', name: 'model', query: '' }) + // The caret on a later line is outside the command. + expect(findSlashTrigger('/model\nhello', 10)).toBeNull() + }) + + test('a mention inside a command argument still completes', () => { + const value = '/research compare @src' + expect(findComposerTrigger(value, value.length)).toMatchObject({ kind: 'mention', query: 'src' }) + expect(findComposerTrigger('/research compare', 17)).toMatchObject({ kind: 'argument', name: 'research' }) + }) + + test('the / Commands button keeps a draft on the line below', () => { + expect(insertCommandTrigger('')).toEqual({ value: '/', caret: 1 }) + expect(insertCommandTrigger(' ')).toEqual({ value: '/', caret: 1 }) + expect(insertCommandTrigger('my draft')).toEqual({ value: '/\nmy draft', caret: 1 }) + // Already a command: back to the end of its word, not a second slash. + expect(insertCommandTrigger('/mod x')).toEqual({ value: '/mod x', caret: 4 }) + }) + + test('picking a command that takes an argument writes `/name ` and keeps the rest', () => { + const trigger = findSlashTrigger('/res', 4) as CommandTrigger + expect(applyCommandName('/res', trigger, 'research')).toEqual({ value: '/research ', caret: 10 }) + const withDraft = findSlashTrigger('/m\ndraft', 2) as CommandTrigger + expect(applyCommandName('/m\ndraft', withDraft, 'model')).toEqual({ value: '/model \ndraft', caret: 7 }) + }) + + test('removing a command leaves the draft', () => { + expect(stripCommand('/compact', { consumeLine: false })).toBe('') + expect(stripCommand('/compact\nmy draft', { consumeLine: false })).toBe('my draft') + expect(stripCommand('/compact and more', { consumeLine: false })).toBe('and more') + // A choice consumes its own line. + expect(stripCommand('/model sonnet\nmy draft', { consumeLine: true })).toBe('my draft') + expect(stripCommand('not a command', { consumeLine: true })).toBe('not a command') + }) + + test('parsing a sent message', () => { + expect(parseSlashCommand('/Research why\nand how')).toEqual({ + name: 'research', + argument: 'why\nand how', + lineArgument: 'why', + }) + expect(parseSlashCommand('/compact')).toEqual({ name: 'compact', argument: '', lineArgument: '' }) + expect(parseSlashCommand('/')).toBeNull() + expect(parseSlashCommand('hello')).toBeNull() + }) +}) + +test.describe('fuzzy ranking', () => { + test('a subsequence matches case-insensitively; anything else does not', () => { + expect(fuzzyMatch('mm', 'mention-match')?.indices).toEqual([0, 8]) + expect(fuzzyMatch('PIN', 'PinnedTodo')).not.toBeNull() + expect(fuzzyMatch('xyz', 'mention-match')).toBeNull() + expect(fuzzyMatch('', 'anything')).toEqual({ score: 0, indices: [] }) + }) + + test('word starts and adjacent runs beat scattered letters', () => { + const boundary = fuzzyMatch('ct', 'ChatTodo')! + const scattered = fuzzyMatch('ct', 'accent')! + expect(boundary.score).toBeGreaterThan(scattered.score) + // The best alignment, not the first one: `st` should land on "s-tream" at the boundary. + expect(fuzzyMatch('st', 'last/stream')!.indices).toEqual([5, 6]) + }) + + test('a match in the file name beats the same letters spread over folders', () => { + const ranked = rankPaths('pin', [ + 'docs/deep/nested/pin/readme.md', + 'src/lib/chat/PinnedTodoPanel.svelte', + 'spinner.css', + 'pin.ts', + ]) + expect(ranked[0].path).toBe('pin.ts') + expect(ranked[1].path).toBe('src/lib/chat/PinnedTodoPanel.svelte') + expect(ranked.map((r) => r.path)).toContain('spinner.css') + // Highlight positions point into the full path. + const pinned = ranked.find((r) => r.path.endsWith('PinnedTodoPanel.svelte'))! + expect(pinned.indices.map((i) => pinned.path[i]).join('').toLowerCase()).toBe('pin') + }) + + test('a query with a slash narrows by folder', () => { + const ranked = rankPaths('chat/in', ['src/lib/chat/ChatInput.svelte', 'src/lib/chat-console/Icon.svelte', 'docs/index.md']) + expect(ranked[0].path).toBe('src/lib/chat/ChatInput.svelte') + expect(ranked.map((r) => r.path)).not.toContain('docs/index.md') + }) + + test('no query shows the top of the tree, folders first', () => { + const ranked = rankPaths('', ['src/app.ts', 'README.md', 'src/', 'docs/', 'docs/a.md', 'package.json']) + expect(ranked.map((r) => r.path)).toEqual(['docs/', 'src/', 'package.json', 'README.md', 'docs/a.md', 'src/app.ts']) + expect(ranked[0].isDirectory).toBe(true) + }) + + test('results are capped', () => { + const paths = Array.from({ length: 100 }, (_, i) => `file-${i}.ts`) + expect(rankPaths('file', paths, 20)).toHaveLength(20) + expect(rankPaths('', paths, 5)).toHaveLength(5) + }) + + test('a very long candidate still matches (greedy fallback)', () => { + const long = `${'a/'.repeat(3000)}target.ts` + expect(fuzzyMatch('tgt', long)).not.toBeNull() + }) + + test('highlight segments and the name/folder split', () => { + expect(highlightSegments('abc', [0, 2])).toEqual([ + { text: 'a', match: true }, + { text: 'b', match: false }, + { text: 'c', match: true }, + ]) + expect(highlightSegments('abc')).toEqual([{ text: 'abc', match: false }]) + expect(splitPathForDisplay('src/lib/app.ts', [0, 8])).toEqual({ + name: 'app.ts', + nameIndices: [0], + folder: 'src/lib/', + folderIndices: [0], + }) + expect(splitPathForDisplay('src/lib/', [])).toMatchObject({ name: 'lib/', folder: 'src/' }) + }) + + test('rankItems prefers the primary key over an alias, and keeps order for an empty query', () => { + const items = [ + { name: 'effort', aliases: ['reasoning'] }, + { name: 'research', aliases: [] }, + ] + const keys = (item: (typeof items)[number]) => [item.name, ...item.aliases] + expect(rankItems('', items, keys).map((r) => r.item.name)).toEqual(['effort', 'research']) + const byAlias = rankItems('reason', items, keys) + expect(byAlias[0].item.name).toBe('effort') + expect(byAlias[0].key).toBe(1) + }) +}) + +test.describe('palette commands', () => { + function recorder() { + const calls: string[] = [] + return { calls, push: (label: string) => calls.push(label) } + } + + test('ordered, deduplicated by name, and found by alias', () => { + const noop = () => {} + const commands = orderCommands([ + effortCommand({ current: () => 'none', pick: noop }), + researchCommand(noop), + compactCommand(noop), + { name: 'custom', description: 'x', run: noop }, + // A page's command replaces a default of the same name. + { ...compactCommand(noop), description: 'page compact' }, + ]) + expect(commands.map((c) => c.name)).toEqual(['compact', 'research', 'effort', 'custom']) + expect(commands[0].description).toBe('page compact') + expect(findCommand(commands, 'REASONING')?.name).toBe('effort') + expect(findCommand(commands, 'nope')).toBeNull() + expect(rankCommands('comp', commands)[0].item.name).toBe('compact') + }) + + test('/compact and /research call the page handlers they were given', async () => { + const log = recorder() + await compactCommand(() => log.push('compact')).run('') + await researchCommand((q) => log.push(`research:${q}`)).run('why is the sky blue') + expect(log.calls).toEqual(['compact', 'research:why is the sky blue']) + }) + + test('/plan flips between Plan only and Ask, and never lands on a looser mode', async () => { + expect(nextPlanMode('plan')).toBe('default') + expect(nextPlanMode('default')).toBe('plan') + expect(nextPlanMode('acceptEdits')).toBe('plan') + expect(nextPlanMode('bypassPermissions')).toBe('plan') + expect(nextPlanMode('garbage')).toBe('plan') + + let mode: string = 'default' + const plan = planModeCommand({ current: () => mode, setMode: (next) => void (mode = next) }) + expect(plan.status?.()).toBeNull() + expect(await plan.run('')).toMatch(/Plan mode is on/) + expect(mode).toBe('plan') + expect(plan.status?.()).toBe('on') + expect(await plan.run('')).toMatch(/Plan mode is off/) + expect(mode).toBe('default') + }) + + test('/model, /agent and /effort offer choices and mark the current one', async () => { + const log = recorder() + const model = modelCommand({ + current: () => 'anthropic/claude-sonnet-5', + models: () => [ + { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5' }, + { id: 'openai/gpt-6', name: 'GPT-6' }, + ], + pick: (id) => log.push(`model:${id}`), + }) + const choices = model.argument?.kind === 'choice' ? model.argument.choices() : null + expect(choices?.find((c) => c.current)?.id).toBe('anthropic/claude-sonnet-5') + expect(await model.run('openai/gpt-6')).toBe('Model: GPT-6') + + const loading = modelCommand({ current: () => 'x', models: () => null, pick: () => {} }) + expect(loading.argument?.kind === 'choice' && loading.argument.choices()).toBeNull() + + const agent = agentCommand({ + current: () => 'a1', + agents: () => [ + { id: 'a1', name: 'Chat', role: 'General' }, + { id: 'a2', name: 'Plan', role: 'Planner' }, + ], + pick: (id) => log.push(`agent:${id}`), + }) + expect(await agent.run('a2')).toBe('Agent: Plan') + + const effort = effortCommand({ current: () => 'none', pick: (e) => log.push(`effort:${e}`) }) + expect(await effort.run('high')).toBe('Reasoning effort: high') + // Not one of the levels: nothing happens. + expect(await effort.run('turbo')).toBeUndefined() + + expect(log.calls).toEqual(['model:openai/gpt-6', 'agent:a2', 'effort:high']) + }) + + test('a typed choice resolves by exact label or id first, then fuzzily', () => { + const choices = [ + { id: 'anthropic/claude-sonnet-5', label: 'Claude Sonnet 5' }, + { id: 'anthropic/claude-opus-5', label: 'Claude Opus 5' }, + { id: 'none', label: 'off' }, + ] + expect(resolveChoice(choices, 'claude opus 5')?.id).toBe('anthropic/claude-opus-5') + expect(resolveChoice(choices, 'anthropic/claude-sonnet-5')?.id).toBe('anthropic/claude-sonnet-5') + expect(resolveChoice(choices, 'opus')?.id).toBe('anthropic/claude-opus-5') + expect(resolveChoice(choices, 'off')?.id).toBe('none') + expect(resolveChoice(choices, 'zzz')).toBeNull() + expect(resolveChoice(choices, '')).toBeNull() + }) + + test('a command is plain data, so an SDK-reported one can sit beside the app’s', () => { + const sdk: ComposerCommand = { name: 'review', description: 'Review the diff', source: 'sdk', run: () => {} } + const ordered = orderCommands([compactCommand(() => {}), sdk]) + expect(ordered.map((c) => c.name)).toEqual(['compact', 'review']) + expect(ordered[1].source).toBe('sdk') + }) +}) From d8c79c72fc5822fb6ee4534dfbfb90ddc1073d27 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:36:23 -0600 Subject: [PATCH 2/9] feat(chat-console): search the next turn's workspace for `@` mentions searchWorkspaceFiles lists files in the folder the conversation's next turn will start in, so a mentioned path is one that turn can open as written. That is the bound project's checkout or an agent's persistent workspace. A chat whose every turn gets a fresh runs/ directory or worktree has nothing to list, and says so, rather than offering paths from the last run that the next one will not find. workspaceKind names the five workspace shapes in the priority order resolveWorkspaceRoot already used, which now switches on it, and resolveConversationWorkspace reports the kind of the latest run and of the next turn. The walk runs over a tree the agent writes to: symlinks are never followed or listed, each directory's real path is re-checked before it is read, .git, node_modules and build output are skipped, entries, depth and time are capped, and nothing but relative names leaves it. It never runs git (an agent-written .git/config can make git execute a program on the host) or rg. A finished listing is cached per root for 15 seconds, with one walk shared by concurrent callers. Someone else's conversation is answered like a missing one. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat-console/mentions.remote.ts | 25 ++ src/lib/chat-console/mentions.server.ts | 73 +++++ src/lib/chat-console/preview.server.ts | 25 +- .../chat-console/workspace-files.server.ts | 171 +++++++++++ src/lib/workspace/workspace.server.ts | 59 ++-- tests/chat.workspace-files.spec.ts | 281 ++++++++++++++++++ 6 files changed, 612 insertions(+), 22 deletions(-) create mode 100644 src/lib/chat-console/mentions.remote.ts create mode 100644 src/lib/chat-console/mentions.server.ts create mode 100644 src/lib/chat-console/workspace-files.server.ts create mode 100644 tests/chat.workspace-files.spec.ts diff --git a/src/lib/chat-console/mentions.remote.ts b/src/lib/chat-console/mentions.remote.ts new file mode 100644 index 0000000..13a4ccf --- /dev/null +++ b/src/lib/chat-console/mentions.remote.ts @@ -0,0 +1,25 @@ +import { query } from '$app/server' +import { z } from 'zod' +import { requireAuthenticatedRequestUser } from '$lib/auth/auth.server' +import { MAX_TRIGGER_QUERY } from '$lib/chat/composer-trigger' +import { searchConversationFiles } from './mentions.server' + +/** + * #22 — the composer's `@` file search, for the conversation the caller owns. + * + * Returns workspace-relative paths only. What is listed, and when there is nothing to list, + * is decided in `mentions.server.ts`. + */ + +const searchSchema = z.object({ + conversationId: z.string().uuid(), + q: z + .string() + .max(MAX_TRIGGER_QUERY) + .refine((q) => !q.includes('\0'), 'Invalid query'), +}) + +export const searchWorkspaceFiles = query(searchSchema, async ({ conversationId, q }) => { + const user = requireAuthenticatedRequestUser() + return searchConversationFiles({ conversationId, userId: user.id, query: q }) +}) diff --git a/src/lib/chat-console/mentions.server.ts b/src/lib/chat-console/mentions.server.ts new file mode 100644 index 0000000..ade9f68 --- /dev/null +++ b/src/lib/chat-console/mentions.server.ts @@ -0,0 +1,73 @@ +import { safePathWithin, workspaceCarriesOver } from '$lib/workspace/workspace.server' +import { rankPaths, type MentionSearchResult } from '$lib/chat/mention-match' +import { MAX_TRIGGER_QUERY } from '$lib/chat/composer-trigger' +import { PreviewError, resolveConversationWorkspace } from './preview.server' +import { getWorkspaceFileIndex } from './workspace-files.server' + +/** + * #22 — the `@` search: files in the directory this conversation's next turn will run in. + * + * Which directory, and when there is none: + * + * - A chat bound to a project: the project's checkout. + * - A chat whose agent keeps a persistent workspace: that workspace. + * - Anything else starts every turn in a fresh folder (a new `runs/` directory, or a new + * git worktree), so there is nothing to mention ahead of time — a file the last turn wrote + * is not where the next turn will look. That is answered with `no-workspace` and a reason, + * rather than a list of paths the agent will not find. + * + * The directory is re-resolved on every call (ownership included), exactly as the rail + * preview does, and must still sit inside the user's own sandbox once symlinks are resolved. + */ + +export const MENTION_RESULT_LIMIT = 20 + +const FRESH_EACH_TURN = + 'Each turn in this chat starts in a fresh folder, so there are no files to mention. Bind the chat to a project to mention its files.' + +export async function searchConversationFiles(input: { + conversationId: string + userId: string + query: string + limit?: number +}): Promise { + const query = input.query.trim() + if (query.length > MAX_TRIGGER_QUERY || query.includes('\0')) { + return { ok: false, reason: 'invalid', message: 'That search is not valid.' } + } + + let workspace + try { + workspace = await resolveConversationWorkspace(input.conversationId, input.userId) + } catch (error) { + // Someone else's conversation is answered exactly like a missing one. + if (error instanceof PreviewError) return { ok: false, reason: 'not-found', message: 'Conversation not found.' } + throw error + } + + if (!workspaceCarriesOver(workspace.nextTurnKind)) { + return { ok: false, reason: 'no-workspace', message: FRESH_EACH_TURN } + } + + try { + safePathWithin(workspace.containmentRoot, workspace.defaultRoot) + } catch { + return { ok: false, reason: 'no-workspace', message: 'This chat’s workspace is not available.' } + } + + let index + try { + index = await getWorkspaceFileIndex(workspace.defaultRoot) + } catch (error) { + // A project whose directory has not been created yet has no files, which is not an error. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, results: [], truncated: false } + throw error + } + + const results = rankPaths(query, index.entries, input.limit ?? MENTION_RESULT_LIMIT).map((ranked) => ({ + path: ranked.path, + isDirectory: ranked.isDirectory, + indices: ranked.indices, + })) + return { ok: true, results, truncated: index.truncated } +} diff --git a/src/lib/chat-console/preview.server.ts b/src/lib/chat-console/preview.server.ts index cc155c1..5d0cc0c 100644 --- a/src/lib/chat-console/preview.server.ts +++ b/src/lib/chat-console/preview.server.ts @@ -6,7 +6,7 @@ import { conversations } from '$lib/sessions/sessions.schema' import { agents } from '$lib/agents/agents.schema' import { chatRuns } from '$lib/runs/runs.schema' import { extractAgentWorkspaceConfig } from '$lib/chat/stream-prep.server' -import { resolveWorkspaceRoot, safePathWithin } from '$lib/workspace/workspace.server' +import { resolveWorkspaceRoot, safePathWithin, workspaceKind, type WorkspaceKind } from '$lib/workspace/workspace.server' import { baseName, classifyExtension, @@ -56,6 +56,18 @@ export type ConversationWorkspace = { defaultRoot: string } +/** What `resolveConversationWorkspace` knows beyond the two roots. */ +export type ResolvedConversationWorkspace = ConversationWorkspace & { + /** The shape of `defaultRoot`: the latest run's workspace, or the user root before any run. */ + kind: WorkspaceKind + /** + * The shape the conversation's NEXT turn will run in. For `project` and `persistent` + * that is `defaultRoot` itself; for `run` and `worktree` every turn gets a fresh + * directory, so what is in `defaultRoot` now will not be there when the turn starts. + */ + nextTurnKind: WorkspaceKind +} + /** * Rebuild the workspace the conversation's tools run in. * @@ -66,7 +78,7 @@ export type ConversationWorkspace = { export async function resolveConversationWorkspace( conversationId: string, userId: string, -): Promise { +): Promise { const [conversation] = await db .select({ id: conversations.id, userId: conversations.userId, projectId: conversations.projectId, agentId: conversations.agentId }) .from(conversations) @@ -101,16 +113,19 @@ export async function resolveConversationWorkspace( const sandboxRoot = process.env.SANDBOX_WORKSPACE const containmentRoot = resolveWorkspaceRoot({ userId, sandboxRoot }) - const defaultRoot = resolveWorkspaceRoot({ + const context = { userId, runId: latestRun?.id ?? null, persistentKey: workspaceConfig.persistentKey, worktree: workspaceConfig.worktreeConfig, projectId: conversation.projectId ?? null, sandboxRoot, - }) + } + const defaultRoot = resolveWorkspaceRoot(context) + // Any id stands in for the run that does not exist yet: only whether there is one matters. + const nextTurnKind = workspaceKind({ ...context, runId: 'next' }) - return { containmentRoot, defaultRoot } + return { containmentRoot, defaultRoot, kind: workspaceKind(context), nextTurnKind } } function assertSaneInput(userPath: string) { diff --git a/src/lib/chat-console/workspace-files.server.ts b/src/lib/chat-console/workspace-files.server.ts new file mode 100644 index 0000000..4c50ec8 --- /dev/null +++ b/src/lib/chat-console/workspace-files.server.ts @@ -0,0 +1,171 @@ +import { readdir, realpath } from 'node:fs/promises' +import { join, sep } from 'node:path' + +/** + * #22 — the list of files `@` can mention: every file and folder under a workspace root. + * + * Containment, because this walks a tree the agent can write to: + * + * - Symlinks are never followed, or listed. `readdir` reports a link as a link, so a link + * the agent's Bash made (or a cloned repo committed) to `/`, the server's environment or + * another user's tree is skipped, not walked. Before each directory is read, its real + * path is checked to still be under the root, which catches a directory swapped for a + * link after it was listed. + * - Only names leave this module, as `/`-separated paths relative to the root. No absolute + * path, and no file contents. + * - No child process. Not `git ls-files` — git honours the repo's own `.git/config`, which + * the agent can write, and options like `core.fsmonitor` run a program on the host + * outside the sandbox — and not `rg`, which the image does not ship. + * + * Cost is bounded three ways (entries, depth and time), and a finished listing is cached per + * root for a few seconds, because the menu searches it again on every keystroke. + */ + +/** Folders that are never worth mentioning and are often enormous. Other dotfolders stay. */ +export const IGNORED_DIRECTORIES: ReadonlySet = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + '.svelte-kit', + 'build', + 'dist', + '.next', + '.turbo', + '.cache', + 'coverage', + 'target', + '__pycache__', + '.venv', + 'venv', +]) + +export type WalkOptions = { + /** Stop after this many entries (files and folders together). */ + maxEntries: number + /** Do not descend more than this many folders below the root. */ + maxDepth: number + /** Give up and return what was found after this long. */ + timeBudgetMs: number +} + +export const DEFAULT_WALK: WalkOptions = { maxEntries: 20_000, maxDepth: 16, timeBudgetMs: 1_500 } + +export type WorkspaceFileIndex = { + /** Relative, `/`-separated; folders end in `/`. Shallow entries come first. */ + entries: string[] + /** True when a limit cut the walk short, so some entries are missing. */ + truncated: boolean +} + +// Control characters would put a line break or worse into the message box. +const UNPRINTABLE = /[\u0000-\u001f\u007f]/ + +function isWithin(realRoot: string, candidate: string): boolean { + const prefix = realRoot.endsWith(sep) ? realRoot : `${realRoot}${sep}` + return candidate === realRoot || candidate.startsWith(prefix) +} + +/** + * Walk `root` breadth-first, so when a limit is reached the entries kept are the shallow + * ones people mention most. Throws if `root` itself cannot be resolved (it does not exist). + */ +export async function listWorkspaceFiles(root: string, options: Partial = {}): Promise { + const { maxEntries, maxDepth, timeBudgetMs } = { ...DEFAULT_WALK, ...options } + const realRoot = await realpath(root) + const startedAt = Date.now() + const entries: string[] = [] + let truncated = false + + const queue: Array<{ absolute: string; relative: string; depth: number }> = [ + { absolute: realRoot, relative: '', depth: 0 }, + ] + walk: for (let head = 0; head < queue.length; head++) { + if (Date.now() - startedAt > timeBudgetMs) { + truncated = true + break + } + const dir = queue[head] + if (dir.depth > 0) { + // Listed as a directory, but it could have been replaced by a link since. + const real = await realpath(dir.absolute).catch(() => null) + if (!real || !isWithin(realRoot, real)) continue + } + let dirents + try { + dirents = await readdir(dir.absolute, { withFileTypes: true }) + } catch { + continue + } + dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + for (const dirent of dirents) { + if (dirent.isSymbolicLink() || UNPRINTABLE.test(dirent.name)) continue + const isDirectory = dirent.isDirectory() + if (!isDirectory && !dirent.isFile()) continue + if (isDirectory && IGNORED_DIRECTORIES.has(dirent.name)) continue + if (entries.length >= maxEntries) { + truncated = true + break walk + } + const relative = dir.relative ? `${dir.relative}/${dirent.name}` : dirent.name + if (isDirectory) { + entries.push(`${relative}/`) + if (dir.depth + 1 < maxDepth) { + queue.push({ absolute: join(dir.absolute, dirent.name), relative, depth: dir.depth + 1 }) + } else { + truncated = true + } + } else { + entries.push(relative) + } + } + } + + return { entries, truncated } +} + +// ─────────── Cache ─────────── + +/** Long enough to cover a burst of keystrokes; short enough that a new file shows up soon. */ +export const INDEX_TTL_MS = 15_000 +const MAX_CACHED_ROOTS = 32 + +const cache = new Map() +const building = new Map>() + +/** + * The listing for `root`, from the cache when it is fresh. Concurrent callers for the same + * root share one walk. `now` is injectable for the spec. + */ +export async function getWorkspaceFileIndex( + root: string, + options: { now?: () => number; walk?: Partial } = {}, +): Promise { + const now = options.now ?? Date.now + const hit = cache.get(root) + if (hit && now() - hit.builtAt < INDEX_TTL_MS) { + // Most recently used moves to the back, so eviction takes the stalest root. + cache.delete(root) + cache.set(root, hit) + return hit.index + } + + const pending = building.get(root) + if (pending) return pending + + const walk = listWorkspaceFiles(root, options.walk) + .then((index) => { + cache.delete(root) + cache.set(root, { builtAt: now(), index }) + while (cache.size > MAX_CACHED_ROOTS) cache.delete(cache.keys().next().value!) + return index + }) + .finally(() => building.delete(root)) + building.set(root, walk) + return walk +} + +/** For specs: forget every cached listing. */ +export function clearWorkspaceFileIndex() { + cache.clear() +} diff --git a/src/lib/workspace/workspace.server.ts b/src/lib/workspace/workspace.server.ts index 946ebed..95b0ab1 100644 --- a/src/lib/workspace/workspace.server.ts +++ b/src/lib/workspace/workspace.server.ts @@ -66,10 +66,40 @@ export type WorkspaceContext = { sandboxRoot?: string } +/** + * Which of the five workspace shapes a context resolves to. This is the one place the + * priority order lives; `resolveWorkspaceRoot` builds its path from the answer. + * + * - `persistent` — the agent's opt-in stable directory; every turn starts in the same one + * - `worktree` — a fresh git worktree per run + * - `project` — the bound project's checkout; every turn starts in the same one + * - `run` — a fresh, empty directory per run + * - `user` — the bare per-user root (no run, no project: admin and legacy callers) + */ +export type WorkspaceKind = 'persistent' | 'worktree' | 'project' | 'run' | 'user' + +export function workspaceKind( + ctx: Pick, +): WorkspaceKind { + if (ctx.persistentKey) return 'persistent' + if (ctx.worktree && ctx.runId) return 'worktree' + if (ctx.projectId) return 'project' + if (ctx.runId) return 'run' + return 'user' +} + +/** + * Whether the next turn starts in the same directory as the last one. Only then does a + * path someone picked from that directory still mean something when the turn runs. + */ +export function workspaceCarriesOver(kind: WorkspaceKind): boolean { + return kind === 'persistent' || kind === 'project' +} + /** * Resolve the absolute root directory for a tool execution context. * - * Resolution priority (most-specific wins): + * Resolution priority (most-specific wins, see `workspaceKind`): * - With `persistentKey`: ${sandboxRoot}//persistent/ (Phase 2 — opt-in stable) * - Else with `worktree` + runId: ${sandboxRoot}//worktrees/ (Phase 4 — git worktree) * - Else with `projectId`: ${sandboxRoot}//projects/ (project-bound chat) @@ -83,23 +113,18 @@ export type WorkspaceContext = { export function resolveWorkspaceRoot(ctx: WorkspaceContext): string { const userId = sanitize(ctx.userId, 'userId') const root = ctx.sandboxRoot || DEFAULT_SANDBOX_ROOT - if (ctx.persistentKey) { - const key = sanitize(ctx.persistentKey, 'persistentKey') - return resolve(root, userId, 'persistent', key) - } - if (ctx.worktree && ctx.runId) { - const runId = sanitize(ctx.runId, 'runId') - return resolve(root, userId, 'worktrees', runId) - } - if (ctx.projectId) { - const projectId = sanitize(ctx.projectId, 'projectId') - return resolve(root, userId, 'projects', projectId) - } - if (ctx.runId) { - const runId = sanitize(ctx.runId, 'runId') - return resolve(root, userId, 'runs', runId) + switch (workspaceKind(ctx)) { + case 'persistent': + return resolve(root, userId, 'persistent', sanitize(ctx.persistentKey!, 'persistentKey')) + case 'worktree': + return resolve(root, userId, 'worktrees', sanitize(ctx.runId!, 'runId')) + case 'project': + return resolve(root, userId, 'projects', sanitize(ctx.projectId!, 'projectId')) + case 'run': + return resolve(root, userId, 'runs', sanitize(ctx.runId!, 'runId')) + case 'user': + return resolve(root, userId) } - return resolve(root, userId) } /** A run's workspace, resolved once so every consumer agrees on it. */ diff --git a/tests/chat.workspace-files.spec.ts b/tests/chat.workspace-files.spec.ts new file mode 100644 index 0000000..d76236d --- /dev/null +++ b/tests/chat.workspace-files.spec.ts @@ -0,0 +1,281 @@ +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { expect, test } from '@playwright/test' +import { + clearWorkspaceFileIndex, + getWorkspaceFileIndex, + INDEX_TTL_MS, + listWorkspaceFiles, +} from '../src/lib/chat-console/workspace-files.server' +import { searchConversationFiles } from '../src/lib/chat-console/mentions.server' +import { getProjectPath } from '../src/lib/projects/project-fs.server' +import { resolveWorkspaceRoot, workspaceCarriesOver, workspaceKind } from '../src/lib/workspace/workspace.server' +import { + cleanupExtendedPrefix, + getActiveUserId, + getBuiltinChatAgentId, + getSql, + seedAgent, + seedProject, + uniquePrefix, +} from './helpers' + +/** + * #22 — what `@` can list, and from where. + * + * The walk runs over a tree the agent can write to, so the containment half matters as much + * as the listing: links are never followed, the heavy folders are skipped, the cost is + * capped, and only relative `/`-separated names come back. The search is scoped to the + * caller's own conversation and to a directory the next turn will actually run in. + */ + +let base = '' + +function link(target: string, path: string) { + try { + symlinkSync(target, path, 'dir') + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (process.platform === 'win32' && code === 'EPERM') return symlinkSync(target, path, 'junction') + if (code === 'EPERM') test.skip(true, 'this host cannot create symlinks') + throw error + } +} + +function touch(root: string, relative: string, body = 'x') { + const full = join(root, ...relative.split('/')) + mkdirSync(join(full, '..'), { recursive: true }) + writeFileSync(full, body) +} + +test.describe('workspace files — the walk', () => { + test.beforeEach(() => { + base = resolve(tmpdir(), `agentstudio-mentions-${randomUUID()}`) + mkdirSync(base, { recursive: true }) + clearWorkspaceFileIndex() + }) + test.afterEach(() => rmSync(base, { recursive: true, force: true })) + + test('lists files and folders as relative, /-separated paths, shallow ones first', async () => { + const root = join(base, 'ws') + touch(root, 'README.md') + touch(root, 'src/lib/app.ts') + touch(root, 'src/index.ts') + + const index = await listWorkspaceFiles(root) + expect(index.truncated).toBe(false) + expect(index.entries).toEqual(['README.md', 'src/', 'src/index.ts', 'src/lib/', 'src/lib/app.ts']) + // Never an absolute path or a backslash, on any platform. + for (const entry of index.entries) { + expect(entry.includes('\\')).toBe(false) + expect(entry.startsWith('/')).toBe(false) + expect(entry.includes(base)).toBe(false) + } + }) + + test('skips .git and node_modules, keeps other dot-folders such as project knowledge', async () => { + const root = join(base, 'ws') + touch(root, '.git/config', '[core]\n\tfsmonitor = evil') + touch(root, 'node_modules/pkg/index.js') + touch(root, 'build/out.js') + touch(root, '.agentstudio/knowledge/spec.md') + touch(root, '.github/workflows/ci.yml') + touch(root, 'app.ts') + + const { entries } = await listWorkspaceFiles(root) + expect(entries).toContain('.agentstudio/knowledge/spec.md') + expect(entries).toContain('.github/workflows/ci.yml') + expect(entries).toContain('app.ts') + expect(entries.some((e) => e.startsWith('.git/'))).toBe(false) + expect(entries.some((e) => e.startsWith('node_modules'))).toBe(false) + expect(entries.some((e) => e.startsWith('build'))).toBe(false) + }) + + test('a link out of the workspace is neither listed nor walked', async () => { + const root = join(base, 'ws') + const outside = join(base, 'host') + touch(outside, 'environ', 'SESSION_SECRET=do-not-leak') + touch(outside, 'nested/deep.txt') + touch(root, 'mine.txt') + link(outside, join(root, 'escape')) + + const { entries } = await listWorkspaceFiles(root) + expect(entries).toEqual(['mine.txt']) + }) + + test('limits cut the walk short and say so', async () => { + const root = join(base, 'ws') + for (let i = 0; i < 30; i++) touch(root, `f${String(i).padStart(2, '0')}.txt`) + touch(root, 'a/b/c/d/e.txt') + + const capped = await listWorkspaceFiles(root, { maxEntries: 10 }) + expect(capped.entries).toHaveLength(10) + expect(capped.truncated).toBe(true) + + const shallow = await listWorkspaceFiles(root, { maxDepth: 2 }) + expect(shallow.entries).toContain('a/b/') + expect(shallow.entries).not.toContain('a/b/c/') + expect(shallow.truncated).toBe(true) + }) + + test('a missing root throws, which the search turns into an empty list', async () => { + await expect(listWorkspaceFiles(join(base, 'nope'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('the listing is cached per root for a few seconds, and walked once for concurrent callers', async () => { + const root = join(base, 'ws') + touch(root, 'one.txt') + let clock = 1_000_000 + const now = () => clock + + const [a, b] = await Promise.all([getWorkspaceFileIndex(root, { now }), getWorkspaceFileIndex(root, { now })]) + expect(a).toBe(b) + expect(a.entries).toEqual(['one.txt']) + + // A new file inside the TTL is not seen: the cached listing is reused. + touch(root, 'two.txt') + clock += INDEX_TTL_MS - 1 + expect(await getWorkspaceFileIndex(root, { now })).toBe(a) + + // After it, the tree is walked again. + clock += 2 + const fresh = await getWorkspaceFileIndex(root, { now }) + expect(fresh).not.toBe(a) + expect(fresh.entries).toEqual(['one.txt', 'two.txt']) + }) +}) + +test.describe('workspace kind — which directory a turn runs in', () => { + test('one priority order, and only project and persistent directories carry over', () => { + expect(workspaceKind({ persistentKey: 'k', worktree: { repoPath: '/r' }, runId: 'r', projectId: 'p' })).toBe('persistent') + expect(workspaceKind({ worktree: { repoPath: '/r' }, runId: 'r', projectId: 'p' })).toBe('worktree') + expect(workspaceKind({ worktree: { repoPath: '/r' }, projectId: 'p' })).toBe('project') + expect(workspaceKind({ runId: 'r' })).toBe('run') + expect(workspaceKind({})).toBe('user') + expect(['persistent', 'project'].every((k) => workspaceCarriesOver(k as never))).toBe(true) + expect(['worktree', 'run', 'user'].some((k) => workspaceCarriesOver(k as never))).toBe(false) + }) +}) + +test.describe('searchConversationFiles — scoped to the caller and to the next turn', () => { + async function seedConversationRow(prefix: string, userId: string, fields: { projectId?: string; agentId?: string }) { + const sql = getSql() + const agentId = fields.agentId ?? (await getBuiltinChatAgentId()) + const [row] = await sql<{ id: string }[]>` + insert into conversations (user_id, agent_id, project_id, title, model, total_tokens, total_cost) + values (${userId}, ${agentId}, ${fields.projectId ?? null}, ${`${prefix} convo`}, 'anthropic/claude-sonnet-4', 0, '0') + returning id + ` + return row.id + } + + test("a project-bound chat searches the project's checkout", async () => { + const prefix = uniquePrefix('mentions-project') + const userId = await getActiveUserId() + let projectPath = '' + try { + const project = await seedProject(prefix) + projectPath = getProjectPath(userId, project.id) + await mkdir(join(projectPath, 'docs'), { recursive: true }) + await writeFile(join(projectPath, 'docs', 'pinned-notes.md'), '# notes') + await writeFile(join(projectPath, 'main.ts'), 'export {}') + const conversationId = await seedConversationRow(prefix, userId, { projectId: project.id }) + + const result = await searchConversationFiles({ conversationId, userId, query: 'pin' }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.results[0]).toMatchObject({ path: 'docs/pinned-notes.md', isDirectory: false }) + expect(result.results[0].indices.length).toBe(3) + + const top = await searchConversationFiles({ conversationId, userId, query: '' }) + expect(top.ok && top.results.map((r) => r.path)).toEqual(['docs/', 'main.ts', 'docs/pinned-notes.md']) + } finally { + if (projectPath) await rm(projectPath, { recursive: true, force: true }).catch(() => {}) + await cleanupExtendedPrefix(prefix) + } + }) + + test("someone else's conversation is answered like a missing one", async () => { + const prefix = uniquePrefix('mentions-foreign') + const userId = await getActiveUserId() + try { + const project = await seedProject(prefix) + const conversationId = await seedConversationRow(prefix, userId, { projectId: project.id }) + const stranger = randomUUID() + expect(await searchConversationFiles({ conversationId, userId: stranger, query: '' })).toMatchObject({ + ok: false, + reason: 'not-found', + }) + expect(await searchConversationFiles({ conversationId: randomUUID(), userId, query: '' })).toMatchObject({ + ok: false, + reason: 'not-found', + }) + } finally { + await cleanupExtendedPrefix(prefix) + } + }) + + test('a chat whose every turn starts in a fresh folder has nothing to mention, and says why', async () => { + const prefix = uniquePrefix('mentions-unbound') + const userId = await getActiveUserId() + try { + const conversationId = await seedConversationRow(prefix, userId, {}) + const result = await searchConversationFiles({ conversationId, userId, query: '' }) + expect(result).toMatchObject({ ok: false, reason: 'no-workspace' }) + expect(!result.ok && result.message).toMatch(/Bind the chat to a project/) + } finally { + await cleanupExtendedPrefix(prefix) + } + }) + + test("an unbound chat with a persistent-workspace agent searches that workspace", async () => { + const prefix = uniquePrefix('mentions-persistent') + const userId = await getActiveUserId() + const key = `mentions-${randomUUID().slice(0, 8)}` + const root = resolveWorkspaceRoot({ userId, persistentKey: key, sandboxRoot: process.env.SANDBOX_WORKSPACE }) + try { + const agent = await seedAgent(prefix) + await getSql()`update agents set config = ${getSql().json({ workspace: { mode: 'persistent', key } })} where id = ${agent.id}` + await mkdir(root, { recursive: true }) + await writeFile(join(root, 'kept.txt'), 'still here next turn') + const conversationId = await seedConversationRow(prefix, userId, { agentId: agent.id }) + + const result = await searchConversationFiles({ conversationId, userId, query: 'kept' }) + expect(result.ok && result.results.map((r) => r.path)).toEqual(['kept.txt']) + } finally { + await rm(root, { recursive: true, force: true }).catch(() => {}) + await cleanupExtendedPrefix(prefix) + } + }) + + test('a project whose directory is not there yet has no files, which is not an error', async () => { + const prefix = uniquePrefix('mentions-nodir') + const userId = await getActiveUserId() + try { + const project = await seedProject(prefix) + const conversationId = await seedConversationRow(prefix, userId, { projectId: project.id }) + expect(await searchConversationFiles({ conversationId, userId, query: 'x' })).toEqual({ + ok: true, + results: [], + truncated: false, + }) + } finally { + await cleanupExtendedPrefix(prefix) + } + }) + + test('an oversized or NUL-carrying query is refused before anything is read', async () => { + const userId = await getActiveUserId() + expect(await searchConversationFiles({ conversationId: randomUUID(), userId, query: 'a\0b' })).toMatchObject({ + ok: false, + reason: 'invalid', + }) + expect(await searchConversationFiles({ conversationId: randomUUID(), userId, query: 'a'.repeat(201) })).toMatchObject({ + ok: false, + reason: 'invalid', + }) + }) +}) From 967a6868a81d9aec2f4a3feb7a5f34a20950a80c Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 16:36:39 -0600 Subject: [PATCH 3/9] feat(chat): `@` file mentions and a `/` command palette in the composer (#22) The two placeholder pills are live. Typing `@` at the start of a word, or pressing @ Context, opens the chat's files; typing `/` as the first character, or pressing / Commands, opens the palette. Both share one suggestion menu that never takes focus: the textarea keeps it and drives the menu with arrows, Enter or Tab to accept, and Escape to close (stopped from reaching the drawers' window listener). An open menu with nothing in it lets Enter send as typed, and Enter during IME composition neither sends nor picks, which also fixes Enter sending half-composed text. A command that needs a choice (model, agent, effort) opens a second list on the current value; /research takes the rest of the message; a message that starts with a known command runs it instead of being sent, and / Commands keeps an existing draft on the line below. Edits land on the element directly rather than after tick(): with async deriveds on the page tick() can wait on unrelated work, and a keystroke in between went to the old caret. On a phone the quick row above the box (Attach, @ Context, / Commands) had three buttons that did nothing; it moves into the composer, where they work, and a tap on a menu row picks it without dropping the keyboard. The chat page passes the file search and its own commands (compact, research, plan) and nothing else. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat-console/console.css | 141 ++++++ src/lib/chat/ChatComposer.svelte | 568 +++++++++++++++++++++++- src/lib/chat/ChatInput.svelte | 10 + src/lib/chat/ComposerSuggestMenu.svelte | 120 +++++ src/lib/chat/chat-page-commands.ts | 37 ++ src/routes/chat/[id]/+page.svelte | 19 +- tests/chat.composer-mentions.spec.ts | 391 ++++++++++++++++ 7 files changed, 1263 insertions(+), 23 deletions(-) create mode 100644 src/lib/chat/ComposerSuggestMenu.svelte create mode 100644 src/lib/chat/chat-page-commands.ts create mode 100644 tests/chat.composer-mentions.spec.ts diff --git a/src/lib/chat-console/console.css b/src/lib/chat-console/console.css index 6051311..d30ef6b 100644 --- a/src/lib/chat-console/console.css +++ b/src/lib/chat-console/console.css @@ -1762,6 +1762,147 @@ a.console-crumbs__seg:hover { box-shadow: inset 0 1px 0 color-mix(in oklab, var(--color-primary) 30%, transparent); } +/* ── Composer suggestions (#22): `@` files and the `/` palette ── + * Anchored to .console-composer and opened above it (below it when the composer sits near + * the top of the screen). Rows are two lines — name, then folder or description — so a long + * path or description truncates instead of squeezing the name to nothing on a phone. */ +.console-composer { position: relative; } + +.console-suggest { + position: absolute; + left: 8px; + right: 8px; + bottom: calc(100% + 6px); + z-index: 30; + display: flex; + flex-direction: column; + max-height: min(300px, 45vh); + background: var(--color-base-100); + border: 1px solid var(--color-base-300); + border-radius: 8px; + box-shadow: 0 10px 30px -12px color-mix(in oklab, var(--color-base-content) 35%, transparent); + font-family: Consolas, 'Cascadia Code', monospace; + font-size: 12px; + /* The new-chat page centres its column; a list reads from the left. */ + text-align: left; + overflow: hidden; +} + +.console-suggest.is-below { bottom: auto; top: calc(100% + 6px); } + +.console-suggest__head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--color-base-300); + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + font-size: 10.5px; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.console-suggest__list { + margin: 0; + padding: 4px; + list-style: none; + overflow-y: auto; + min-height: 0; +} + +.console-suggest__item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 34px; + padding: 5px 8px; + border-radius: 5px; + cursor: pointer; + color: color-mix(in oklab, var(--color-base-content) 80%, transparent); +} + +.console-suggest__item[aria-selected='true'] { + background: color-mix(in oklab, var(--color-primary) 14%, transparent); + color: var(--color-base-content); +} + +.console-suggest__item[aria-disabled='true'] { opacity: 0.55; cursor: not-allowed; } + +.console-suggest__icon { + flex: 0 0 auto; + display: inline-flex; + color: color-mix(in oklab, var(--color-base-content) 45%, transparent); +} + +.console-suggest__text { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; +} + +.console-suggest__label, +.console-suggest__detail { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-suggest__detail { + font-size: 10.5px; + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); +} + +.console-suggest__hint { color: color-mix(in oklab, var(--color-base-content) 40%, transparent); } + +.console-suggest mark { + background: transparent; + color: var(--color-primary); + font-weight: 600; +} + +.console-suggest__badge { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid var(--color-base-300); + font-size: 10px; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); +} + +.console-suggest__empty, +.console-suggest__foot { + flex: 0 0 auto; + margin: 0; + padding: 8px 10px; + font-size: 11px; + line-height: 1.5; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); +} + +.console-suggest__foot { border-top: 1px solid var(--color-base-300); padding: 5px 10px; font-size: 10.5px; } + +.console-composer__notice { + margin: 0 0 4px; + padding: 2px 4px; + font-family: Consolas, 'Cascadia Code', monospace; + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 65%, transparent); +} + +.console-composer__notice.is-error { color: var(--color-error); } + +/* The phone's quick row sits inside the composer's form, under the form's own top border. */ +.console-composer-wrap > .console-quick { border-top: 0; } +.console-quick button:disabled { opacity: 0.5; cursor: not-allowed; } + .console-composer__ta { width: 100%; border: 0; diff --git a/src/lib/chat/ChatComposer.svelte b/src/lib/chat/ChatComposer.svelte index aaae7c3..bd8feea 100644 --- a/src/lib/chat/ChatComposer.svelte +++ b/src/lib/chat/ChatComposer.svelte @@ -1,19 +1,40 @@
-
+ +
+ {#if onAddFiles} + + {/if} + {#if onMentionSearch} + + {/if} + +
+ +
+ {#if menu} + void accept(index)} + onHover={(index) => (activeIndex = index)} + /> + {/if} + + {#if notice} +

+ {notice.text} +

+ {/if} +
@@ -147,8 +665,26 @@ Attach {/if} - - + {#if onMentionSearch} + + {/if} +
diff --git a/src/lib/chat/ChatInput.svelte b/src/lib/chat/ChatInput.svelte index 9e388fb..a4dda20 100644 --- a/src/lib/chat/ChatInput.svelte +++ b/src/lib/chat/ChatInput.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import ChatComposer from '$lib/chat/ChatComposer.svelte'; import type { AgentChoice } from '$lib/chat/AgentSelector.svelte'; + import type { ComposerCommand } from '$lib/chat/composer-commands'; + import type { MentionSearchResult } from '$lib/chat/mention-match'; type ChatAttachment = { id: string; @@ -29,6 +31,8 @@ onCancelGeneration, estimatedRemaining = 128000, size = 'default', + onMentionSearch, + commands = [], } = $props<{ value?: string; placeholder?: string; @@ -48,6 +52,10 @@ estimatedRemaining?: number; /** 'large' starts the composer tall — used on the new-chat page. */ size?: 'default' | 'large'; + /** #22 — the conversation's `@` file search; see ChatComposer. */ + onMentionSearch?: ((query: string) => Promise) | undefined; + /** #22 — the page's own `/` commands; see ChatComposer. */ + commands?: ComposerCommand[]; }>(); let recording = $state(false); let transcribing = $state(false); @@ -321,6 +329,8 @@ onCancelGeneration={() => onCancelGeneration?.()} onAddFiles={() => openFilePicker()} onMicClick={() => toggleRecording()} + {onMentionSearch} + {commands} />
diff --git a/src/lib/chat/ComposerSuggestMenu.svelte b/src/lib/chat/ComposerSuggestMenu.svelte new file mode 100644 index 0000000..a7906b1 --- /dev/null +++ b/src/lib/chat/ComposerSuggestMenu.svelte @@ -0,0 +1,120 @@ + + + + +
+
+ {title} + {#if loading}{/if} +
+ {#if items.length > 0} +
    + {#each items as item, i (item.id)} + + +
  • { + if (i !== activeIndex) onHover?.(i) + }} + onclick={() => onPick(i)} + > + {#if item.icon} + + {/if} + + + {#each highlightSegments(item.label, item.labelIndices) as segment, s (s)}{#if segment.match}{segment.text}{:else}{segment.text}{/if}{/each}{#if item.hint} {item.hint}{/if} + + {#if item.detail} + + {#each highlightSegments(item.detail, item.detailIndices) as segment, s (s)}{#if segment.match}{segment.text}{:else}{segment.text}{/if}{/each} + + {/if} + + {#if item.badge}{item.badge}{/if} +
  • + {/each} +
+ {:else} +

{loading ? 'Searching…' : emptyText}

+ {/if} + {#if footer}

{footer}

{/if} +
diff --git a/src/lib/chat/chat-page-commands.ts b/src/lib/chat/chat-page-commands.ts new file mode 100644 index 0000000..0e7abc4 --- /dev/null +++ b/src/lib/chat/chat-page-commands.ts @@ -0,0 +1,37 @@ +import { setConversationPermissionMode } from '$lib/chat/chat.remote' +import type { ConversationPermissionMode } from '$lib/engine/permission-mode' +import { + compactCommand, + planModeCommand, + researchCommand, + type ComposerCommand, +} from '$lib/chat/composer-commands' + +/** + * #22 — the `/` commands only a conversation page can offer, wired to the handlers its own + * buttons use. Kept out of the page so the page passes one prop. + * + * - `/compact` is the context meter's Compact button (a summarise-this-conversation turn). + * - `/research ` is the page's Deep Research trigger. + * - `/plan` flips the permission-mode chip between Plan only and Ask, through the same + * command the chip calls. It never switches to Bypass, which needs the chip's confirm. + */ +export function buildChatPageCommands(input: { + conversationId: string + permissionMode: () => unknown + onPermissionModeChange: (mode: ConversationPermissionMode) => void + compact: () => Promise | void + research: (question: string) => Promise | void +}): ComposerCommand[] { + return [ + compactCommand(input.compact), + researchCommand(input.research), + planModeCommand({ + current: input.permissionMode, + async setMode(mode) { + await setConversationPermissionMode({ conversationId: input.conversationId, mode, confirmed: false }) + input.onPermissionModeChange(mode) + }, + }), + ] +} diff --git a/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index c665158..efb0a33 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -18,6 +18,8 @@ import { getAvailableModels } from '$lib/llm'; import { getSettings } from '$lib/settings'; import ChatInput from '$lib/chat/ChatInput.svelte'; + import { buildChatPageCommands } from '$lib/chat/chat-page-commands'; + import { searchWorkspaceFiles } from '$lib/chat-console/mentions.remote'; import ContextWindow from '$lib/chat/ContextWindow.svelte'; import { consoleState } from '$lib/chat-console/console-state.svelte'; import { openLeft, openRight } from '$lib/chat-console/mobile-drawer-state.svelte'; @@ -1715,13 +1717,6 @@ /> {/if} - -
- - - -
-