zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Inline AI Command — Spec & UX Decisions

Historical record — some details predate the current backend

This spec is preserved as a locked historical record of the design decisions behind the Inline AI Command epic (#1058) — it is not kept in sync with later implementation changes and is not moved from this path (see the note below). In particular, the "local LLM" framing in §1 and the provider-routing language in §10 predate the move to a cloud-hosted model on Cloudflare Workers AI, served by the agent-server Worker's /inline route rather than a local process. For the current behavior, see Inline AI command, Skill authoring, and Flue Agent Platform. The trigger grammar, capture-mode UX, submit gestures, cancel/undo semantics, and skill file format below remain accurate — those did not change with the backend swap.

Status: Locked design spec for the Inline AI Command epic (#1058). Every other sub-task in the epic refers to this document for ambiguous decisions. Do not move this file from doc/src/content/docs/internal/inline-ai-command-spec.mdx.

This document fixes the design of the Inline AI Command surface — the @@ <command> writer-flow that lets the user invoke the local LLM from inside the CodeMirror editor without opening the heavyweight AiAssistantPanel. It is intentionally short on rationale (see the epic) and long on the rules implementers must hit byte-for-byte.

1. Design philosophy

"We can do many things with local LLM. But do we really need to show the panel for it? Inline text editing is much more powerful for the code/text writer."

The editor itself is the primary AI surface. The user types @@ <command> or presses Cmd+J to enter capture mode, then chooses per invocation whether the answer lands inline (ghost-text bake) or in a slim bottom response panel.

Two entry points → one capture mode → user-selectable output

  Entry A: type  @@ <command>     (writer-flow; line-start or after whitespace)
  Entry B: press Cmd+J            (inserts "@@ " and enters capture)

Submit:
  Tab        → inline mode  → ghost-text below the @@ line; Tab again bakes
  Cmd+Enter  → panel mode   → slim ~120px bottom panel streams there
  Esc        → cancel       → line returns to plain text, no LLM call

2. Trigger grammar

The capture-mode trigger fires only when all of the following are true at the cursor position:

  1. The character sequence immediately to the left of the cursor matches the regex (^|\s)@@ — i.e. @@ (two at-signs followed by a single ASCII space) at line start, or preceded by at least one whitespace character.

  2. The cursor is not inside a word. The character to the left of the @@ token must be either start-of-line or whitespace.

  3. The cursor is not inside a fenced code block. Implementations must consult the markdown language tree (lezer-markdown FencedCode / CodeBlock nodes) and refuse to enter capture mode when the cursor is anywhere inside one of those nodes.

  4. The editor is not in IME composition. Implementations mirror composingField from @takazudo/cm-ghost-text (see §6 IME interaction).

Additional rules:

  • Exactly two @ followed by exactly one space. @@<command> (no space), @@@ command, and @@ command (double space) do not trigger.

  • Any text already present after the trigger on the same line (the "command tail") becomes the initial command text on entry.

  • The trigger is a prefix only. The command tail extends from the trigger to the end of the current line; line breaks end the command.

Examples

| Input (cursor |) | Triggers? | Reason | | --- | --- | --- | | @@ summarize| | yes | Line start, space after @@ | | @@ /translate-en こんにちは| | yes | After whitespace, space after @@ | | foo @@ bar| | yes | Preceded by whitespace | | foo@@ bar| | no | @ is preceded by a word char | | @@bar| | no | No space after @@ | | @@@ bar| | no | Three @ does not match @@ | | ```\n@@ foo\n``` | no | Inside fenced code block | | @@ typed during IME comp. | no | Suspended; re-evaluated on commit |

3. Capture-mode visual

When capture mode is active the affected line is decorated as follows:

  • Line tint. The current line gets a subtle background tint (token to be defined by the cm-extension implementer; align with the design system --accent-translucent or equivalent). The tint covers the full line gutter to right edge, not just the typed range.

  • Right-edge status pill. A non-interactive pill is rendered at the right edge of the active line. It shows the available submit gestures in English, per the English-UI-labels policy (epic #5789):

    Tab to insert · ⌘↵ panel · Esc to cancel

    The exact string is locked. The middle dots are full-width "・"-like separators rendered as the ASCII " · " sequence above (U+0020 U+00B7 U+0020). When IME composition is active the pill is greyed (reduced opacity, e.g. 0.4) but remains visible (see §6).

The pill is purely informational — it is not a clickable button. Capture is exited only via the gestures listed in §4, or by an auto-cancel event (§8).

4. Submit gestures

These three gestures end capture mode. The user's gesture always wins. A skill's mode frontmatter (§9) is a hint shown in autocomplete metadata, not a binding override of the user's gesture.

GestureModeEffect
TabinlineStream the LLM response as ghost text on the line below the @@ line; the user presses Tab a second time to bake it into the document. The whole bake (delete of the @@ line + insert of the answer text) is wrapped in a single EditorView.dispatch so Cmd+Z reverts atomically.
Cmd+EnterpanelOpen the slim ~120 px bottom mini response panel and stream the response there. The @@ line is left intact in the editor until the user explicitly inserts the answer (Insert at cursor) or cancels.
EsccancelCancel without an LLM call. Restores the line per §5.

Notes for implementers:

  • Cmd here is the platform meta key — on macOS Cmd, on Linux/Windows Ctrl. Bindings register with the shortcut engine using the project's existing meta-mapping convention.

  • A skill resolved to a specific mode (e.g. mode: panel) renders that mode's icon in the autocomplete list, but the user is free to press Tab instead and get inline anyway.

  • While streaming is in flight the user can press Esc to abort the stream; the line falls back to its pre-capture state per §5.

5. Cancel and undo semantics

Cancel during capture (Esc)

  • If the user typed @@ themselves (Entry A), Esc removes the capture-mode decoration but leaves the literal @@ and any tail text in place. The user may decide to keep typing as plain text.

  • If @@ was inserted by Cmd+J (Entry B), Esc removes the inserted @@ along with any tail text the user typed during capture, restoring the line to exactly its pre-Cmd+J state. Implementations remember the entry mode and the pre-Cmd+J selection range to support this.

  • No LLM request is made. If a request was already streaming (only possible after Tab / Cmd+Enter), it is aborted.

Cancel during panel-mode streaming (Esc)

  • Aborts the stream and closes the mini panel. The @@ line remains as plain text (the user can decide whether to delete it manually).

Undo after inline bake (Cmd+Z)

The inline bake must be a single atomic transaction. The bake replaces the @@ <command> line and any streamed ghost-text region with the final answer text in one EditorView.dispatch({ changes: [...] }) call. As a result:

  • A single Cmd+Z reverts the entire bake — both the inserted answer and the deletion of the @@ line — restoring the document to exactly what it was before the bake.

  • A second Cmd+Z then walks back the user's pre-bake edits (typing the command tail, etc.) following CodeMirror's normal history.

Implementers must avoid splitting the bake across multiple dispatches. Two dispatches produce two history entries and two undo steps, which violates this contract.

6. IME interaction

Capture mode coexists with IME (Japanese kana-kanji conversion etc.) by mirroring the composing-state pattern already used in @takazudo/cm-ghost-text:

  • Maintain a StateField analogous to composingField that flips true on compositionstart and false on compositionend.

  • While composing:

    • The trigger grammar (§2) does not fire. Typing @@ mid-composition is treated as ordinary text until composition ends.

    • If capture mode was already active when composition started, the status pill is rendered at reduced opacity (e.g. 0.4) and submit gestures are suspended — Tab, Cmd+Enter, and Esc fall through to the IME / the editor as they normally would.

  • On compositionend the trigger is re-evaluated at the new cursor position. If the post-commit text matches the §2 grammar, capture mode starts (or resumes) on the relevant line. If it does not, the editor remains in plain-text mode.

This mirrors the late-response gating in cm-ghost-text where ghost suggestions are suppressed if the same transaction flips composingField to true (see tr.startState.field(composingField) || composingStartInTx in that package).

7. Selection semantics

When capture mode is entered, the implementer captures the current selection range and decides:

  • With selection → "transform" mode. The selected text is the input document; the user's command is the transformation instruction. Suitable examples: @@ make this more formal, @@ /translate-en (over a Japanese selection).

  • Without selection (cursor only) → "generate" mode. There is no input document; the command stands alone. Suitable examples: @@ summarize the section above, @@ write a one-line stand-up update.

System-prompt difference

The two modes ship distinct system-prompt prefixes to the LLM. Sub-task #1061 implements the exact strings; the contract this spec locks is:

ModeSystem prompt shape (in spirit)
transform"You are an editing assistant. The user has selected a passage of text. Apply the following instruction to the passage and return only the rewritten passage. Do not add commentary or surrounding text." Followed by the selection inserted as a <selection>…</selection> block in the user message.
generate"You are a writing assistant embedded in a markdown editor. Produce a concise, well-formatted response to the following request. Reply in the same language as the request." No selection block; the command is the user message.

In both modes the user message ends with the literal command text the user typed (after stripping the @@ prefix and resolving any /skill-name per §9). Free-form commands are sent verbatim — no keyword extraction, no tool routing in MVP (see §10).

8. Edge cases

The implementation must handle each of these explicitly:

  1. Typing more @@ mid-line. While capture mode is active on a line, typing additional @@ characters in the command tail does not start a nested capture. The literal characters are appended to the command text.

  2. Click elsewhere mid-capture. If the user clicks (or arrow-keys) the selection out of the capture line, capture mode auto-cancels with the same restore semantics as Esc (§5). Returning to the line does not re-enter capture; the user must re-type the trigger or press Cmd+J.

  3. Vim insert mode. Capture mode must coexist with the existing vim integration — the trigger fires while in vim insert mode, and Esc is intercepted by capture (cancel) before vim sees it as "leave insert". Full vim integration (e.g. :ai ex-command parity) is deferred to Phase 2; this spec only requires that vim insert-mode capture does not break.

  4. Empty command. Submitting @@ with no command text on Tab or Cmd+Enter is a no-op: the gesture falls through to the editor's default Tab / Cmd+Enter handler and capture mode exits as if Esc had been pressed.

  5. Long lines / soft wrap. The status pill is anchored to the right edge of the logical line, not the visual wrapped row. If the command tail soft-wraps, the pill renders on the last visual row.

  6. Multiple cursors. Capture mode is single-cursor only. If multiple selections are present when the trigger fires, only the primary selection's line enters capture; the other cursors are left untouched.

9. Skill format

A "skill" is a user-authored markdown file that turns @@ /skill-name <args> into a deterministic prompt template. Skills are resolved by the loader defined in #1062.

Locations

Skills are loaded from these directories, in priority order:

  1. Workspace-local: <workspace>/.zudotext/skills/ — relative to the active workspace root.

  2. User-global: <userConfigDir>/skills/ — the per-app config directory, ~/.config/zudotext/<appname>/skills/ on both macOS and Linux. The loader receives this path from the Tauri backend (get_user_skills_dir command); see tauri-app/src/lib.rs resolve_app_config_dir for the resolution logic.

File format

Each skill is a single .md file whose stem is the skill's invocation name. <workspace>/.zudotext/skills/translate-en.md is invoked as @@ /translate-en ….

---
name: translate-en
description: Translate the input to natural English.
mode: inline       # one of: inline | panel
model: llama3.2:3b # optional; parsed but not forwarded — no-op in current implementation
---

You are a translation assistant. Translate the user's text into natural,
idiomatic English. Return only the translation, no commentary.

The body of the markdown file (everything after the frontmatter) is the system prompt. The user's command tail (everything after /skill-name ) is the user message. Selection semantics from §7 still apply — a skill invoked with a selection runs in transform mode and gets the selection appended to the user message; without a selection, it runs in generate mode.

Frontmatter fields

FieldRequiredTypeNotes
nameyesstringMust match ^[a-z][a-z0-9-]*$. Invalid name → file is skipped (no stem fallback).
descriptionyesstringShown in autocomplete next to /name.
modeno"inline" | "panel"Default "inline". Hint only — surfaced in autocomplete metadata; the user's gesture (§4) wins.
modelnostringParsed and stored but not forwarded to the backend — a no-op in the current implementation. Reserved for future provider routing.

Unknown frontmatter fields are ignored (forward-compatible). Extra body text is the system prompt verbatim — no preprocessing.

Validation rules

A skill is valid when:

  • The file parses as YAML frontmatter + body.

  • name is present, non-empty, matches /^[a-z][a-z0-9-]*$/ (must start with a lowercase letter — leading digits are not allowed), and equals the file stem.

  • description is present and non-empty.

  • mode, if present, is one of the enum values.

  • model, if present, is a non-empty string.

  • The body (system prompt) is non-empty after trimming.

Override precedence

When the same name exists in both the workspace and user-global directories, the workspace copy wins. The user-global copy is silently shadowed (the loader still parses it for validation but does not register it under the colliding name).

Failure mode

Invalid skills must never crash the editor. The loader:

  1. Logs a structured warning (skill path + first error) to the console.

  2. Skips the offending file.

  3. Continues loading the rest of the directory.

The autocomplete UI must tolerate the resulting partial registry without showing an error toast on every keystroke. A "skills failed to load" indicator MAY surface in settings (out of scope for this spec).

10. MVP scope clarification

Free-form commands are sent verbatim to the LLM. The MVP does no keyword extraction, no tool routing, and no automatic skill selection. The three example queries below MUST all work in the MVP exactly as described:

QueryResolutionWhat gets sent to the LLM
@@ summarize the section aboveFree-form generationUser message: summarize the section above. Generate-mode system prompt (§7).
@@ find similar text file that includes "foobar"Free-form questionUser message: find similar text file that includes "foobar". Generate-mode system prompt. No routing to bridge.suggestions.query; the LLM answers in prose.
@@ /translate-en こんにちはSkill-resolvedSystem prompt = body of translate-en.md. User message: こんにちは. Mode hint from frontmatter; user gesture wins.

Explicitly out of scope (Phase 2)

  • Tool routing@@ find similar … calling bridge.suggestions.query is Phase 2.

  • Auto-routed skills — LLM- or embedding-based skill selection without the explicit /name prefix is Phase 2.

  • Multi-turn in mini panel — only a single quick follow-up is in scope (#1064); a full chat thread that promotes losslessly to AiAssistantPanel is Phase 2.

  • Vim ex-command integration:ai parity is Phase 2.

  • Cloud-provider parity — The model frontmatter field is parsed but not forwarded to the backend. Provider routing is Phase 2.

  • Selection-transform presets / slash sub-commands — built-in @@ /shorten, @@ /translate-en shortcuts shipped with the app are Phase 2.

  • Skill editor UI — MVP authors copy-and-edit a .md template; a graphical skill editor is Phase 2.

11. Cross-references