diff --git a/README.md b/README.md index dd3c32c8..4d3726b1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Self-hosted autonomous AI agent platform for a single owner, with a sandboxed wo AgentStudio provides a streaming chat interface where the assistant can call tools such as web search and sandboxed code execution. The filesystem toolset supports ranged file reads, full writes, unified-diff patch apply, deterministic string replace, recursive directory listing, search, move/rename, delete, and file metadata lookups. Chat supports editing and branching, interleaved tool and thinking blocks, per-message performance and cost metrics, model selection, and per-prompt reasoning effort selection. -Chats run on Claude models through the Claude subscription, at no per-token cost. The model pickers — the composer, the default model and each agent's model — offer only models that can actually run here: the current Claude models Claude Code knows, plus, when the operator configures an Anthropic-compatible gateway such as OpenRouter's, the gateway's models, labelled **Gateway · paid** and priced per token in the usage ledger. The gateway is off by default. See [docs/llm/llm.md](docs/llm/llm.md). +Chats run on Claude models through the Claude subscription, at no per-token cost. The model pickers — the composer (its model pill and the `/model` command), the default model and each agent's model — offer only models that can actually run here: the current Claude models Claude Code knows, plus, when the operator configures an Anthropic-compatible gateway such as OpenRouter's, the gateway's models, labelled **Gateway · paid** and priced per token in the usage ledger. The gateway is off by default. See [docs/llm/llm.md](docs/llm/llm.md). + +The message box has two shortcuts. Typing `@` lists the files in the chat's workspace (a bound project's folder, or an agent's persistent workspace) and inserts the chosen file's relative path. Typing `/` at the start opens a command palette over the app's own actions: `/compact`, `/model`, `/agent`, `/research`, `/plan`, `/effort`, `/attach` and `/voice`. Both work from the keyboard and by tapping on a phone. See "Composer shortcuts" in [docs/chat/spec.md](docs/chat/spec.md). Replies can be read aloud: a speaker button on each reply plays it through an OpenRouter text-to-speech model (code blocks are skipped), and an opt-in, per-device **Auto-read** switch above the message box reads each new reply when its turn finishes, for hands-free use. The model and voice are set in Settings → Model & AI; spend is recorded in the usage ledger under "Read Aloud" and counts toward budget limits. See [docs/speech/speech.md](docs/speech/speech.md). @@ -210,6 +212,7 @@ Notes: - Runtime spec: `docs/runtime/spec.md` - Tools (what agents can call, approvals, code execution): `docs/tools/tools.md` - Chat plan: `docs/chat/plan.md` +- Chat spec (composer shortcuts, attachments, safe rendering): `docs/chat/spec.md` - Memory spec: `docs/memory/spec.md` - Automations: `docs/automations/automations.md` - Monitors: `docs/monitors/monitors.md` diff --git a/docs/chat-console/chat-console.md b/docs/chat-console/chat-console.md index 524d9b95..b39db617 100644 --- a/docs/chat-console/chat-console.md +++ b/docs/chat-console/chat-console.md @@ -191,6 +191,13 @@ These are the constraints that matter, and why: - **The URL is always visible** above the frame, so it is never ambiguous what is loaded. - **Framed pages are sandboxed.** Scripts and forms are allowed; a page on the app's own origin additionally loses `allow-same-origin` so it cannot script the app. +- **The composer's `@` file list follows the same rules.** It lists the workspace the chat's + next turn will run in (a bound project's folder, or an agent's persistent workspace; a chat + whose turns each start in a fresh folder has nothing to list), re-checks ownership on every + search, never follows or lists a symbolic link, skips `.git`, `node_modules` and build + output, and returns relative names only. It never runs `git` or any other program over the + workspace: the agent can write a repository's `.git/config`, and some of its settings make + git run a program on the server. See "Composer shortcuts" in the chat spec. - **Previewed markdown is sanitized.** Raw HTML in the file is escaped rather than executed, and links and images that are not `http(s)` are dropped. That includes text that follows an inline ``, `` or `
` tag, which the markdown library
@@ -201,6 +208,7 @@ These are the constraints that matter, and why:
 ## Integrations
 
 - `src/lib/workspace/workspace.server.ts` — workspace resolution and path containment.
+- `src/lib/chat-console/workspace-files.server.ts` and `mentions.server.ts` — the `@` file list.
 - `src/lib/tools/sandbox.server.ts` — the same workspace the agent's file tools use.
 - `/api/preview/raw` — image and PDF bytes for the rail.
 - `src/lib/engine/tool-result-details.ts` — the `file_edit` details (path, change type,
diff --git a/docs/chat/spec.md b/docs/chat/spec.md
index 15ad9a1b..10ebc4fb 100644
--- a/docs/chat/spec.md
+++ b/docs/chat/spec.md
@@ -112,6 +112,72 @@ Rules that keep this honest:
 3. The same rules apply whether the conversation is on a Claude model or a third-party model routed through the gateway. A gateway model that cannot see images will simply not use them; nothing about the delivery path changes.
 4. Files attached on the new-chat page (`/`) go with the first message, exactly as if they had been attached inside the conversation. The new-chat page used to upload them, show them, and then send only the text.
 
+### Composer shortcuts: `@` file mentions and `/` commands
+
+The message box has two shortcuts. Both work from the keyboard, and both work on a phone by tapping.
+
+#### Mentioning a file with `@`
+
+Typing `@` at the start of a word opens a list of the files and folders in the chat's workspace. As you keep typing, the list narrows to the closest matches. The letters only have to appear in order, so `pnt` finds `PinnedTodoPanel.svelte`, and a match in a file's name ranks above the same letters spread across folder names. Picking an entry writes its path into the message as inline code (for example `src/lib/app.ts`) followed by a space. Nothing is sent. The **@ Context** button does the same as typing `@`.
+
+Which files are listed depends on where the chat's next turn will run:
+
+| The chat is… | The list shows |
+| --- | --- |
+| Bound to a project | The project's working folder |
+| Talking to an agent that keeps a persistent workspace | That workspace |
+| Neither | Nothing. Each turn in such a chat starts in a fresh, empty folder, so a file the last turn wrote is not where the next turn will look. The list says so and suggests binding the chat to a project. |
+| Not created yet (the new-chat page) | No `@` list and no **@ Context** button |
+
+Rules:
+
+1. A path is relative to the folder the next turn starts in, so the agent can open it exactly as written.
+2. Folders that are never worth mentioning and are often huge are skipped: `.git`, `node_modules`, build output (`build`, `dist`, `.next`, `.svelte-kit`, `.turbo`, `target`), caches, coverage reports and Python virtual environments. Other dot-folders stay, so project knowledge (`.agentstudio/knowledge`), `.github` and `.claude` can be mentioned.
+3. Symbolic links are never followed or listed. A link the agent made, or one a cloned repository contains, cannot be used to see files elsewhere on the server.
+4. Only names reach the browser. File contents and full server paths never do.
+5. A very large workspace is searched only up to 20,000 entries, 16 folders deep or 1.5 seconds of scanning, whichever comes first, and the list says when that happened. The list of files is remembered for 15 seconds, so a brand-new file can take that long to appear.
+6. Only the conversation's owner can search its files. Asking about someone else's conversation gets the same answer as asking about one that does not exist.
+7. The path goes in as inline code, not as `@path`, so what the model receives does not depend on how the Agent SDK happens to treat an `@`.
+
+#### Running a command with `/`
+
+Typing `/` as the very first character of the message opens the command palette. The **/ Commands** button does the same. Every command is an action the app already has a button for; the palette is a quicker way to reach it, and it calls exactly what that button calls. When that button is switched off, the command is too, and it says why instead of running.
+
+| Command | What it does | Available |
+| --- | --- | --- |
+| `/compact` | Asks the agent to summarise the conversation so far, to free up context. The same as Compact on the context meter | In a chat, when no reply is running |
+| `/model ` | Switches the model for the next message. It lists the same models as the model picker: only models that can run here, with gateway models marked "Gateway · paid" (see [docs/llm/llm.md](../llm/llm.md)) | Everywhere |
+| `/agent ` | Hands the conversation to another agent | Everywhere, when there are agents to pick |
+| `/research ` | Starts a deep research run on the question and opens its page | In a chat |
+| `/plan` | Turns plan mode on or off. Plan mode is read-only: the agent plans instead of making changes. Turning it off goes back to Ask. It never switches to Bypass, which still needs the confirmation on the mode chip | In a chat, when no reply is running |
+| `/effort ` (also `/reasoning`, `/think`) | Sets how hard the model thinks before answering | Everywhere, except on a gateway model, which always runs with reasoning off |
+| `/attach` | Opens the file picker | Everywhere |
+| `/voice` | Starts or stops dictation | Where the browser supports it, and not while the last recording is still being turned into text |
+
+How it behaves:
+
+1. A command that needs a choice (model, agent, effort) opens a second list. The value in effect now is marked "current" and starts highlighted, so Enter keeps it. Typing after the command narrows the list (`/model sonnet`), and the best match is then highlighted instead.
+2. A list shows 50 entries at a time; a deployment with a large gateway can offer more models than that. When the current model is not among those 50, it is shown first, so it is never missing from the list. A conversation whose model is stored in OpenRouter's spelling (`anthropic/claude-sonnet-4.5`) still finds its model marked current. A model that can no longer run here is not in the list at all; the model pill marks it "Unavailable". The first time the list opens on a page it may still be loading; the highlight lands on the current model when the list arrives, unless you have already moved it with the arrow keys or the pointer.
+3. `/research` takes free text: type the question on the same line, after the command, and press Enter. Anything on the lines below is left in the message box.
+4. A message that starts with a known command runs the command instead of being sent, whether it was picked from the list or typed out in full (`/effort high` works). Any other message that starts with a slash, such as `/usr/bin is missing`, is sent as typed.
+5. The rest of the message box is kept. The **/ Commands** button puts the slash on its own line above an existing draft, and the draft is still there after the command runs.
+6. A short confirmation, or the reason a command could not run, shows above the message box for a few seconds. The message box can be open while a reply is still running, when the agent has paused to ask you a question. `/compact` and `/plan` then say that a reply is running rather than acting: the Compact button does nothing until the reply ends, and the mode chip is switched off because a running reply keeps the mode it started with.
+7. The palette is built so that commands the Agent SDK reports for a trusted project (its own `.claude/commands` and skills) can be listed beside these later without changing how it works.
+
+#### Keyboard and touch
+
+| Key | With a list open |
+| --- | --- |
+| Up / Down | Move through the list (it wraps around) |
+| Enter or Tab | Take the highlighted entry |
+| Escape | Close the list. It stays closed until the cursor leaves that word |
+| Enter or Tab, while the file list is still searching | Nothing yet. Pick from the list once it arrives, or press Escape to close it and send the message as typed |
+| Enter, when the list has finished and is empty | Sends the message as typed |
+
+While a Japanese, Chinese or Korean input method is composing, Enter confirms the composition. It never sends the message or picks from a list; before this, Enter could send half-composed text.
+
+On a phone the **Attach**, **@ Context** and **/ Commands** buttons sit in a row above the message box (before this they did nothing). Tapping an entry in a list picks it without closing the keyboard. The list opens above the message box, or below it when the box is near the top of the screen, as on the new-chat page.
+
 ### Starting a conversation from the new-chat page
 
 The new-chat page (`/`) greets the owner by the display name they gave during first-run setup ("Good morning, Alex"). If no name was given, the greeting is just "Good morning".
@@ -296,6 +362,8 @@ The renderer checks itself when the app starts by running a set of known attack
 - Messages keep their order when two writers add to a conversation at the same moment (a Stop saving what was written so far while the turn saves its final reply, or a background run). The one that loses the race takes the next position and is still saved.
 - A staged attachment always lands in the same sandbox workspace the run's own tools resolve, so the path quoted to the agent is a path the agent can open.
 - Nothing the model writes can run script in the app, and displaying a reply never loads any image except an uploaded attachment.
+- A path offered by `@` is one the next turn can open as written: it is relative to the folder that turn starts in, and it never reaches through a symbolic link.
+- A palette command calls the same handler as the button it stands in for. The palette has no copy of its own of any action.
 
 ## Roles & Permissions
 
@@ -303,6 +371,7 @@ The renderer checks itself when the app starts by running a set of known attack
 | -------------------------------- | ------------------ |
 | View own workbench sessions      | Authenticated user |
 | Approve own plan or tool request | Owner user, admin  |
+| Search a conversation's files with `@` | The conversation's owner |
 | Resolve another user's item      | Admin only         |
 | View admin observability panes   | Admin only         |
 
diff --git a/docs/llm/llm.md b/docs/llm/llm.md
index 604b8988..98b89830 100644
--- a/docs/llm/llm.md
+++ b/docs/llm/llm.md
@@ -36,7 +36,7 @@ The app's smaller features — conversation titles, memory, monitors' yes/no che
 
 Three pickers choose the model a chat runs on. All three list only engine models:
 
-1. **The chat composer**, for the conversation in front of you.
+1. **The chat composer**, for the conversation in front of you: its model pill, and the `/model` command in its `/` palette, which lists the same models (a gateway row there says "Gateway · paid" beside its id).
 2. **Settings → Model & AI → Default Model**, for new conversations.
 3. **An agent's configuration** (`/agents/[id]`), for conversations that agent starts — from a monitor, a pull-request fix, and so on.
 
@@ -102,7 +102,7 @@ Nothing else from the server's environment reaches it, the same as for any turn
 - **Off by default.** No gateway settings means Claude only. Both `LLM_GATEWAY_URL` and `LLM_GATEWAY_TOKEN` are needed; an empty value counts as unset.
 - **Only models the gateway serves are offered.** If the gateway's model list cannot be fetched, no gateway models are offered (the app retries a minute later) rather than guessing.
 - **Only gateway models that can use tools are offered.** Claude Code sends its tools with every request and expects a text answer. A model OpenRouter's catalogue lists without tool support, or that answers only in images or audio, would fail on the first message, so it is left out. A model the catalogue does not list at all (a local model behind LiteLLM) is offered, since nothing says it cannot run.
-- **Reasoning is off on gateway models.** Claude's adaptive thinking and effort levels are Anthropic features; whether a gateway passes them on to another model is unverified, so a gateway turn runs with thinking off and the composer's reasoning control is disabled.
+- **Reasoning is off on gateway models.** Claude's adaptive thinking and effort levels are Anthropic features; whether a gateway passes them on to another model is unverified, so a gateway turn runs with thinking off and the composer's reasoning control is disabled, as is its `/effort` command, which says why instead of opening its list.
 - **Cost.** A Claude turn records its tokens and $0. A gateway turn is priced from OpenRouter's catalogue over that turn's own tokens — input, output, and cached prompt tokens at the catalogue's cache prices where it lists them. The ledger row notes `backend: gateway` and where the price came from (`costBasis`): `catalogue`, or `cli-estimate` for a model the catalogue does not price (Claude Code's own estimate), or `unpriced` when there is neither. An unpriced turn is recorded like any other call the ledger cannot price: at $0, marked unpriced with the reason, and with a warning in the server log. Claude Code's own estimate is not used when the catalogue has a price, because for a model it does not know it guesses at a Claude rate.
 - **Budgets apply.** Gateway turns count toward budget limits like any other metered spend.
 - **Tool use is weaker off Claude.** Claude Code is built for Claude models; OpenRouter says other models may not work correctly through it, and multi-step tool use is where they fall short. The gateway is a deliberate, labelled choice, never a default.
diff --git a/docs/structure/claude-desktop-parity.md b/docs/structure/claude-desktop-parity.md
index 85d339c5..a802104f 100644
--- a/docs/structure/claude-desktop-parity.md
+++ b/docs/structure/claude-desktop-parity.md
@@ -71,9 +71,10 @@ This is the Cowork comparison, and it is the one I got wrong in the first draft:
 | Web search + fetch | **even** | `web_search`, `web_fetch`, `pdf_read` | same |
 | Code execution | **n/a** — removed (#69) | `run_code` never ran on the engine path and was deleted rather than rebuilt. The agent writes a script into the run's workspace and runs it with the SDK's `Bash`, sandboxed by bubblewrap in production and approval-gated where bubblewrap is missing. No in-script tool calls, no chart output — a chart is a file shown in the Preview tab | analysis tool / sandboxed Python, renders charts |
 | File attachments | **even** (#36 fixed) | images inline as base64 content blocks on the SDK's streaming-input prompt; PDFs and other files are staged into the run's sandbox workspace and read with `pdf_read` / `file_read`; anything undeliverable (video, oversized or unsupported images) warns on the message instead of being dropped; files attached on the new-chat page go with the first message (they were silently dropped until 2026-09-23) | images, PDFs, office docs, with extraction |
+| Slash commands and `@` file mentions | **behind** (#22) | `@` lists the files in the folder the chat's next turn runs in (a bound project or a persistent agent workspace), fuzzy-ranked, never through a symlink, and inserts the relative path; `/` is a palette over the app's own actions (`/compact`, `/model`, `/agent`, `/research`, `/plan`, `/effort`, `/attach`, `/voice`). A trusted project's own `.claude/commands` and skills are not listed yet, and an unbound chat has nothing to mention because every turn starts in a fresh folder | `/` lists built-in, project and skill commands; `@` for files and connectors |
 | Voice dictation | **even** | record → `/api/transcribe`; no live transcript while speaking | same |
 | Text-to-speech | **even** (#27 fixed) | speaker button on every reply and an opt-in, per-device auto-read for hands-free use, through an OpenRouter speech model and voice chosen in Settings; code is skipped, long replies are chunked, spend is on the ledger and under budget limits | shipped |
-| Deep research | **even** | in a chat, the Research agent writes a plan the user must approve, then hands it to Chat, which does the research as ordinary turns with the web tools — steerable, but no cited-report page or notification. The background pipeline (planner, fetch, reflection, cited report, notification) runs only from research-mode automations, and it is fixed: a run cannot be steered once started. The composer's Research button that would start one is not switched on. (Until 2026-09-23 every background run failed at its first model call — the bare SDK model id was sent to OpenRouter — and would have failed again at its last, marking the cited sources; a canceled run was retried to completion, or saved its report over a Cancel pressed during the write-up.) | agentic, steerable |
+| Deep research | **even** | in a chat, the Research agent writes a plan the user must approve, then hands it to Chat, which does the research as ordinary turns with the web tools — steerable, but no cited-report page or notification. The background pipeline (planner, fetch, reflection, cited report, notification) runs from research-mode automations and, from a chat, from `/research ` in the composer (#22); it is fixed: a run cannot be steered once started. (Until 2026-09-23 every background run failed at its first model call — the bare SDK model id was sent to OpenRouter — and would have failed again at its last, marking the cited sources; a canceled run was retried to completion, or saved its report over a Cancel pressed during the write-up.) | agentic, steerable |
 | Memory | **win** | Memory Palace: wing/room/closet/drawer, hybrid vector + tsvector + temporal recall, mining pipeline | categorized entries, Topics editor, sensitive-topics exclusion — thinner retrieval |
 | Memory management UI | **win** (#37 fixed) | per-drawer edit with mandatory re-embed, delete, pin / never-recall, per-conversation forget, exclusion rules applied *before* embedding — to mined turns, recall queries and the chat turn's skill ranking, with a time limit and a capped pool of workers so a user's regex cannot stall or exhaust the server — with built-in credential patterns and a tester, plus a per-drawer "why was this recalled?" score breakdown | Topics editor, per-item delete, sensitive-topic exclusion — no recall explainability |
 | Skills | **even** | full CRUD, skill hooks, agent identity skills, and a copy-paste `SKILL.md` package (resource files included) that round-trips cleanly. No marketplace or sharing, which only matters with a second user | same, plus a marketplace and `claude plugin eval` |
@@ -139,6 +140,6 @@ Ordered for how this box is used — NAS host, repeating jobs, files rarely open
 
 **Wave 4 — the long tail.**
 
-[#17](https://github.com/derekhearst/AgentStudio/issues/17) external MCP · [#18](https://github.com/derekhearst/AgentStudio/issues/18) conversation search, pin, archive, export · [#37](https://github.com/derekhearst/AgentStudio/issues/37) memory management · [#38](https://github.com/derekhearst/AgentStudio/issues/38) usage digest · [#22](https://github.com/derekhearst/AgentStudio/issues/22) slash commands and `@`-mentions · [#23](https://github.com/derekhearst/AgentStudio/issues/23) project instructions · ~~[#27](https://github.com/derekhearst/AgentStudio/issues/27) the dead TTS endpoint~~ (finished instead: read-aloud)
+[#17](https://github.com/derekhearst/AgentStudio/issues/17) external MCP · [#18](https://github.com/derekhearst/AgentStudio/issues/18) conversation search, pin, archive, export · [#37](https://github.com/derekhearst/AgentStudio/issues/37) memory management · [#38](https://github.com/derekhearst/AgentStudio/issues/38) usage digest · [#22](https://github.com/derekhearst/AgentStudio/issues/22) slash commands and `@`-mentions (`@` and an app-actions `/` palette landed; SDK commands to follow) · [#23](https://github.com/derekhearst/AgentStudio/issues/23) project instructions · ~~[#27](https://github.com/derekhearst/AgentStudio/issues/27) the dead TTS endpoint~~ (finished instead: read-aloud)
 
 **Deferred on purpose:** artifacts (#13, removed), incognito chat (delete covers it), remote control (the web UI is reachable anywhere), plugin marketplace and connector governance (no second user), computer use (large, and the browser tools cover the real cases), inline charts and Claude Design (worth revisiting only if the reports get visual).
diff --git a/docs/structure/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md
index ffcdb928..4cbe4099 100644
--- a/docs/structure/parity-reaudit-2026-09-22.md
+++ b/docs/structure/parity-reaudit-2026-09-22.md
@@ -255,7 +255,7 @@ plan), **fold** (belongs inside another issue), **delete** (close it).
 | #5 | Port subagents to SDK subagents | **as filed** — shipped | keystone; `Options.agents` + `Task`, `run_subagent` retired |
 | #4 | Native AskUserQuestion | **as filed** | `toolConfig.askUserQuestion.previewFormat` confirmed present |
 | #18 | Conversation pin/archive/search/export | **as filed**, trimmed | all four are cheap; make archive the default action, not delete |
-| #22 | Slash commands and `@`-mentions | **split** | build `@` now; `/` should wait for `settingSources` |
+| #22 | Slash commands and `@`-mentions | **split** — `@` shipped, `/` shipped over app actions | `@` lists the next turn's workspace; the `/` palette covers the app's own buttons, with SDK commands still to add |
 | #38 | Usage digest | **rebuild** | fix the ledger first, then ship the header strip; the digest agent is the last 20% |
 | #14 | Rethink the right sidebar | **rebuild** → **shipped** | #29 already fixed the "blank by default" complaint; what is left is deleting two tabs. Shipped as Preview + a real Files tab, collapsed by default; see below |
 | #27 | Wire up or delete the TTS endpoint | **delete** → **finished** | confirmed dead: no UI reference, and the setting the issue mentions does not exist. The owner chose to finish it; see below |
@@ -500,6 +500,25 @@ lands (#23), `query.supportedCommands()` returns the real command list, includin
 own `.claude/commands/` and every skill, pushed live on change via `commands_changed`. A
 palette over *that* is worth having; a palette over three buttons is not.
 
+**Status (2026-09-23).** The owner chose to ship both halves now, the `/` half as a palette
+over the app's existing actions (option B of the triage).
+
+- `@` lists files and folders in the folder the chat's next turn starts in: the bound
+  project's checkout, or an agent's persistent workspace. A chat whose every turn gets a fresh
+  `runs/` directory (or a fresh worktree) has nothing to list, and the menu says so rather
+  than offering paths the next turn will not find. The walk never follows a symlink, skips
+  `.git`, `node_modules` and build output, is capped by entries, depth and time, and never
+  shells out to `git` or `rg`. The path is inserted as inline code, not as `@path`.
+- `/` offers `/compact`, `/model`, `/agent`, `/research`, `/plan`, `/effort`, `/attach` and
+  `/voice`, each calling the handler its button calls. Commands are plain data with a
+  `source`, so the SDK list (`supportedCommands()`, cached from `init` / `commands_changed`)
+  can be merged in later without changing the palette. With #9, `/model` lists the same
+  runnable engine models as the model pill, and `/effort` is switched off on a gateway model,
+  as the reasoning pill is.
+- Still open for the SDK half: caching the command list on the conversation (control
+  requests only work mid-turn), routing `/compact` to the SDK's real compaction, and handling
+  `local_command_output` and `conversation_reset` in the stream loop.
+
 ### #38 — usage digest
 
 Right instinct, wrong order. The issue's own trap — "a digest nobody reads" — is the likely
diff --git a/src/lib/chat-console/console.css b/src/lib/chat-console/console.css
index 10d61015..71789480 100644
--- a/src/lib/chat-console/console.css
+++ b/src/lib/chat-console/console.css
@@ -1583,6 +1583,154 @@ 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;
+}
+/* The title is a flex child: it needs its own ellipsis, or a long one on a phone runs off the
+   edge and pushes the loading dots out of view. */
+.console-suggest__head > span:first-child {
+	min-width: 0;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.console-suggest__head > .loading { flex: 0 0 auto; }
+
+.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-console/mentions.remote.ts b/src/lib/chat-console/mentions.remote.ts
new file mode 100644
index 00000000..13a4ccf3
--- /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 00000000..ade9f68b
--- /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 cc155c18..5d0cc0cd 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 00000000..4c50ec84
--- /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/chat/ChatComposer.svelte b/src/lib/chat/ChatComposer.svelte
index b1801f9f..0d589484 100644
--- a/src/lib/chat/ChatComposer.svelte
+++ b/src/lib/chat/ChatComposer.svelte
@@ -1,20 +1,41 @@
 
 
 
-
+ +
+ {#if onAddFiles} + + {/if} + {#if onMentionSearch} + + {/if} + +
+ +
+ {#if menu} + void accept(index)} + onHover={(index) => (chosenIndex = index)} + /> + {/if} + + {#if notice} +

+ {notice.text} +

+ {/if} +
@@ -150,8 +726,26 @@ Attach {/if} - - + {#if onMentionSearch} + + {/if} +
@@ -173,7 +767,7 @@
diff --git a/src/lib/chat/ComposerSuggestMenu.svelte b/src/lib/chat/ComposerSuggestMenu.svelte new file mode 100644 index 00000000..a7906b15 --- /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 00000000..9118f9b6 --- /dev/null +++ b/src/lib/chat/chat-page-commands.ts @@ -0,0 +1,44 @@ +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. + * + * The message box can be open while a reply is still running: an ask_user question pauses the + * turn and hands the box back. `/compact` and `/plan` then say why they cannot run, as their + * buttons are disabled (the chip) or do nothing (Compact) until the reply ends. + */ +export function buildChatPageCommands(input: { + conversationId: string + /** A reply is running on this page, paused on a question or not. */ + streaming: () => boolean + permissionMode: () => unknown + onPermissionModeChange: (mode: ConversationPermissionMode) => void + compact: () => Promise | void + research: (question: string) => Promise | void +}): ComposerCommand[] { + return [ + compactCommand(input.compact, { replyRunning: input.streaming }), + researchCommand(input.research), + planModeCommand({ + current: input.permissionMode, + replyRunning: input.streaming, + async setMode(mode) { + await setConversationPermissionMode({ conversationId: input.conversationId, mode, confirmed: false }) + input.onPermissionModeChange(mode) + }, + }), + ] +} diff --git a/src/lib/chat/composer-commands.ts b/src/lib/chat/composer-commands.ts new file mode 100644 index 00000000..0b9c2d9b --- /dev/null +++ b/src/lib/chat/composer-commands.ts @@ -0,0 +1,324 @@ +/** + * #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, type RankedItem } 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 ?? [])]) +} + +/** How many choices a list shows at once. The model catalogue runs to hundreds. */ +export const CHOICE_LIMIT = 50 + +export function rankChoices(query: string, choices: readonly ComposerChoice[], limit = CHOICE_LIMIT) { + return rankItems(query, choices, (choice) => [choice.label, choice.id], limit) +} + +/** + * A choice list as the menu shows it, and the row it starts on. The two come from the same + * array, so the starting row is always a row that is on screen. + * + * With nothing typed, the list keeps its given order and starts on the value in effect now, so + * Enter keeps it. A long list is cut to `limit`, and the current value can sort past the cut + * (most of the model catalogue does): it then leads the list, so it is still shown, marked and + * highlighted, rather than the highlight landing on whichever row happens to be at its index. + * With something typed, the best match leads and starts highlighted. + */ +export function choiceMenu( + query: string, + choices: readonly ComposerChoice[], + limit = CHOICE_LIMIT, +): { matches: RankedItem[]; initial: number } { + const matches = rankChoices(query, choices, limit) + if (query.trim()) return { matches, initial: 0 } + const shown = matches.findIndex((match) => match.item.current) + if (shown >= 0) return { matches, initial: shown } + const current = choices.find((choice) => choice.current) + if (!current || limit < 1) return { matches, initial: 0 } + return { matches: [{ item: current, score: 0, indices: [], key: 0 }, ...matches.slice(0, limit - 1)], initial: 0 } +} + +/** + * 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 + +/** + * What a model row says beside its name: its id, and for a model the paid gateway runs (#9) + * that it is billed per token, as the model picker's "Gateway · paid" badge says. + */ +export function modelChoiceDetail(model: { id: string; backend?: 'subscription' | 'gateway' }): string { + return model.backend === 'gateway' ? `Gateway · paid · ${model.id}` : model.id +} + +export function modelCommand(input: { + current: () => string + /** The models that can run here (the engine list), or null while it loads. */ + models: () => ReadonlyArray<{ id: string; name: string; backend?: 'subscription' | 'gateway' }> | 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: modelChoiceDetail(model), + 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 + /** Why the reasoning control is disabled (a gateway model runs with thinking off), or null. */ + unavailable?: () => string | null +}): ComposerCommand { + return { + name: 'effort', + aliases: ['reasoning', 'think'], + description: 'Set how hard the model thinks before answering', + source: 'app', + unavailable: input.unavailable, + 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 + /** The last recording is still being turned into text; the mic button is disabled meanwhile. */ + transcribing?: () => 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), + unavailable: () => (input.transcribing?.() ? 'Still turning the last recording into text.' : null), + async run() { + await input.toggle() + }, + } +} + +/** + * Why a command that acts on the conversation cannot run now. A reply can still be running while + * the message box is open: an ask_user question pauses it and hands the box back to the user. + */ +export const REPLY_RUNNING = 'A reply is still running. Wait for it to finish, or stop it, first.' + +export function compactCommand( + compact: () => unknown, + options: { replyRunning?: () => boolean } = {}, +): ComposerCommand { + return { + name: 'compact', + description: 'Summarise the conversation so far to free up context', + source: 'app', + // The Compact button's handler does nothing while a reply runs; say so instead. + unavailable: () => (options.replyRunning?.() ? REPLY_RUNNING : null), + 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', + // The question is the rest of the command's line; lines below it stay in the box as the draft. + argument: { kind: 'text', placeholder: '', hint: 'Type the question on this line, 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 + /** + * A reply is running. The mode chip is disabled then: a running turn keeps the mode it + * started with, so switching now would claim a change that does not apply to it. + */ + replyRunning?: () => boolean +}): 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), + unavailable: () => (input.replyRunning?.() ? REPLY_RUNNING : 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 00000000..a6193bc0 --- /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 + /** + * What follows the word on its own line, trimmed: a choice's name, or a free-text argument + * such as a research question. Later lines are the draft, never part of the argument. + */ + 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(), + 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 00000000..0a7f4ea9 --- /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/src/lib/workspace/workspace.server.ts b/src/lib/workspace/workspace.server.ts index 946ebed3..95b0ab1b 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/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index 6b3f923f..fdd168e3 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -19,6 +19,8 @@ import { engineContextLimit } from '$lib/llm/engine-models'; 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, resetConsoleState, setChangedFiles } from '$lib/chat-console/console-state.svelte'; import { changedFilesInThread } from '$lib/chat-console/changed-files'; @@ -1680,13 +1682,6 @@ /> {/if} - -
- - - -
-