zudo-text

検索したい単語を入力

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

Snippets

Snippets are reusable reply templates — a shop reply, a support macro, a standard sign-off — that start as plain text and grow in place into fields, choices, branches and optional sections without ever losing that plain-text starting point. The Snippet Palette is the one surface for them: it opens instantly, narrows as you type, and does the whole authoring/filling/inserting job (epic #6780).

Three ways to open it: the typed trigger ;; (configurable, see Input Triggers), the ⌘⇧I shortcut (openSnippetPalette), and ⌘K → "Snippets…" / "Snippets: New from selection" / "Snippets: Manage…" (tauri-app/renderer/data/app-commands.tsx). All three dispatch the same OPEN_SNIPPET_PALETTE_EVENT (tauri-app/renderer/data/snippet-palette-events.ts) with a mode (insert | new | manage) and, for the typed trigger, the document range to replace and the captured query/selection.

This page covers the file format, the template syntax, the three storage layers and the SnippetIndex, the palette's grow operations, insertion, and the AI services. For account-level sync ("My snippets") and the trigger registry itself, see Account Store / Account API and Input Triggers.

File format

@takazudo/snippet-store's parseSnippetFile(path, text) reads a snippet as Markdown-with-frontmatter — the same shape workspace documents use, via @takazudo/file-utils/frontmatter. It never throws: a malformed file returns { error } so the loader can log a warning and skip just that one file.

Frontmatter fieldRequiredNotes
namenoAny non-empty text. When absent or blank, autoName(body) derives it from the first non-empty body line, stripping a leading #/-/* marker. A body with no usable text at all (and no name) is a parse error.
triggernoDirect-open trigger characters, e.g. ;refund. Must match ^[\p{L}\p{N}_-]{1,32}$ (Unicode letters/digits, so non-Latin triggers work) — an invalid value is a parse error, not a silent drop.
descriptionnoAny text.
aino"off" (default) | "offer" | "always" — when the inline AI polish step runs against the filled text. "always" opens the AI column automatically but never auto-sends (epic #6780 delegated decision).
aiInstructionnoFree text passed to the polish system prompt on its first turn, shown to the user before sending — there is no trust gate on it.

Everything after the closing frontmatter delimiter is the body — the template source consumed by @takazudo/snippet-template.

serializeSnippet(snippet) round-trips back through parseSnippetFile, built on updateFrontmatterField (which appends a field to the end of the block on each call, so calling it in the desired field order produces that order). Fields left at their default (ai: "off", absent trigger/description/aiInstruction) are omitted rather than written out explicitly — a snippet saved with no AI use round-trips as a plain two-field frontmatter block.

Template syntax

@takazudo/snippet-template's parse(body) builds an AST from {{…}} tags. It never throws either — a malformed template returns { ast, errors } with source offsets (ParseError.from/to), and parsing recovers as far as it can so the editor keeps working on a body that doesn't fully parse yet.

SyntaxMeaning
{{name}}Text field. Every {{name}} with the same name is one input, filled everywhere it appears.
{{name = default}}Text field with a default value.
{{name | A | B | C}}Choice field — the user picks one option; the first option is the default.
{{@today}}Built-in: today's date, ISO YYYY-MM-DD via @takazudo/date-utils's toIsoDate, injected via RenderContext.today (defaults to new Date()).
{{@clipboard}}Built-in: the system clipboard text, read only when the template actually uses this tag.
{{@selection}}Built-in: the editor selection captured when the palette opened.
{{@cursor}}Built-in: no text — marks where the caret lands after insertion.
{{#switch name}}{{case A}}…{{case B}}…{{/switch}}The case labels become the options of a choice field named name. Case labels in one switch must be unique — a duplicate is a parse error, not a silent overwrite.
{{#if name = A}}…{{#elif name != B}}…{{#else}}…{{/if}}=/!= compare a field to a value; a bare name ({{#if name}}) is truthy when the field is a non-empty string or true. #elif/#else are optional.
{{#opt name}}…{{/opt}}Optional section, toggled by a boolean field.

An unknown built-in ({{@nope}}) renders as its own literal tag rather than vanishing, so a typo stays visible instead of silently disappearing from the output. Every {{#switch}}/{{#if}}/{{#opt}} must be closed by its matching tag — blocks may nest but must not overlap — and a newline directly against an opening/closing block tag is swallowed, so authors can put block tags on their own line without stray blank lines in the rendered output.

Rendering and field introspection

  • render(ast, values, ctx?) → Segment[] — each segment carries field/builtin tags so the UI can highlight filled vs. still-empty parts; an empty text field renders as [name] with empty: true.

  • toInsertion(ast, values, ctx?) → { text, cursorOffset } — the final insertable string plus the {{@cursor}} offset (null if the template never uses it).

  • allFields(ast) — every field in the template across every branch, first-seen order.

  • activeFields(ast, values) — only the fields reachable under the current values; this list shrinks/grows live as a switch, #if or #opt choice changes, which is what drives the fill accordion's visible rows.

  • defaults(ast) — a Values object seeded from each field's declared default.

Layers and the SnippetIndex

A snippet lives in one of three layers, resolved into a single in-memory SnippetIndex (@takazudo/snippet-store):

LayerBacked byDirectory
workspacebridge.workspaceFiles.zudotext/snippets/ (WORKSPACE_SNIPPETS_DIR)
account ("My snippets")bridge.accountFiles — see Account Storesnippets/ (ACCOUNT_SNIPPETS_DIR)
external (other workspaces)a lazy, read-write peek — see belownot loader-backed

createSnippetLoader({ workspace?, account? }) is FS-agnostic (SnippetFs: readDir/readFile/watch, injected per layer) and scans only direct .md children of the configured directory — no sub-directory recursion. Unlike @takazudo/inline-command-skills's loader, it does not merge same-name entries across layers: same-trigger snippets in both layers are both kept (see collisions() below), so per-file events carry their own layer + path instead of a single resolved name. A missing directory is an empty layer, not an error.

createSnippetIndex() is pure and in-memory:

  • upsert({ layer, path, snippet, origin? }) — parses the body once, storing ast, fields (allFields) and lowercased body-line tokens on the entry (id is "<layer>:<path>").

  • query(q) — ranked: exact trigger match, then name substring, then description substring, then body-token substring, each group in insertion order. An empty query returns every entry unranked.

  • byTrigger(trigger) — resolves collisions workspace > account > external (LAYER_PRIORITY).

  • collisions() — every trigger claimed by more than one entry, sorted by the same layer priority, so the palette can surface "this trigger also matches…" instead of silently shadowing one.

useSnippetIndex — eager, at workspace-ready

tauri-app/renderer/hooks/use-snippet-index.ts builds the one SnippetIndex instance eagerly (mounted via SnippetIndexProvider, tauri-app/renderer/contexts/snippet-index-context.tsx, in app.tsx's WorkspaceKeyedProviders) — not lazily when the palette first opens. Two independent effects feed it:

  • Workspace layer — keyed on workspaceId. A workspace-identity change drops every existing "workspace" entry before scanning the new directory, so a stale previous workspace's snippets never leak into the next one.

  • Account layer — keyed on the account engine's generation (getAccountEngineGeneration/subscribeAccountEngine from account-lifecycle.ts), deliberately not on workspaceId. A real workspace switch unmounts <App>, and this provider with it, so the account loader, its subscription and the loaded entries live one level up in tauri-app/renderer/services/account-snippet-layer.ts: the remounted index is reseeded from memory with no accountFiles reads, and only the workspace layer is rescanned. The account layer is rebuilt when the account engine is armed, re-keyed, or torn down (sign-out included).

ready flips true once both initial loads have settled, at which point list()/query()/byTrigger() are pure lookups needing zero further backend reads. The hook also exposes saveSnippet/deleteSnippet/moveSnippet: writes go through the bridge (workspaceFiles/accountFiles) and the index optimistically, in that order for a move (write the target, then delete the source) — a source-delete failure after a successful target write is reported (MoveSnippetResult.error) rather than losing either copy.

Other workspaces (external layer)

tauri-app/renderer/services/other-workspace-snippets.ts's createOtherWorkspaceSnippets gives the palette a lazy, read-write peek into every other workspace on the signed-in account. The renderer only arms one workspace's bridge singletons at a time, and the server can't filter by path (paths are encrypted end to end), so each peeked workspace gets its own CloudSyncClient and its own derived keys, held entirely inside this service — never handed to the shared bridge. Nothing runs until expand() (the "Other workspaces" group is opened) or peekOnLocalMiss(query) (the local layers had no hit).

  • Index paths are <workspaceId>/<path>, tagged with SnippetIndexEntry.origin = { workspaceId, workspaceName, path }, so two workspaces' files never collide as one entry.

  • A workspace is locked when this device holds no stored key for it ("no-key") or when the peeked workspace's own sync.alwaysRequireWorkspacePassword is on ("password-required") — never the active workspace's. The policy is read from the peeked workspace's encrypted .zudotext.settings.json before any snippet is decrypted; no document means the defaults. When the document cannot be read, the policy is treated as "always" (fail closed). Under "always", a stored key is not reused and a password unlock stays in session memory only. unlock(workspaceId, password) verifies the password against the workspace's stored hash before deriving keys, and persists the key only when the peeked workspace remembers its password.

  • saveSnippet/deleteSnippet detect a remote change since the last load by comparing the path's newest version (listVersions) and throw OtherWorkspaceSnippetError with code "conflict" rather than overwriting silently.

  • Results are cached 5 minutes (OTHER_WORKSPACE_CACHE_TTL_MS); dispose() (on sign-out or an account change) drops every peek client, key and external index entry.

  • createBridgeOtherWorkspaceSnippets lists workspaces through the mock-aware lib/workspace-source.ts. In pnpm dev:mock, main-mock.tsx installs services/mock-peer-workspace-snippets.ts via setOtherWorkspaceSnippetsBackend: an in-memory, genuinely encrypted peer server. "Second workspace" (mock-workspace-2) has a stored key and one snippet. mock-workspace-locked — and any other account workspace without a fixture — starts locked and unlocks with mock-peer-password. Add the locked peer with __mockControls.setAccountWorkspaces, and read write-backs with window.__mockPeerSnippets.readFiles(workspaceId).

The Snippet Palette

tauri-app/renderer/components/snippet-palette/ is the one dialog every invocation path opens. Its layout is a small state machine (palette-model.ts), smallest to largest, with Esc stepping back one level:

list ─Enter─▶ fill (accordion under the row) ─⌘E─▶ editor (list → slim rail)
                └──────────── ⌘J ────────────┴─▶ + AI column (right)

A snippet with no active fields (needsFill(entry) is false) skips fill and inserts straight from the list. compose is the compact "Save selection as snippet" accordion offered first when the palette opens over a non-empty selection; mode: "new" without a selection opens the roomy editor directly. mode: "manage" opens the editor on the first local snippet with "Other workspaces" pre-expanded. Viewport (mobile ≤640px, narrow under 900px, wide ≥900px) decides column layout (resolvePaletteLayout) — at wide, list + editor + AI column can all show at once; below 900px each state takes the whole body in turn.

Extension points

palette-slots.tsx defines the three growing states as swappable slots — Fill, Editor, AiColumn — each receiving a PaletteSlotContext (register the ⌘⏎ primary action, insertText, back/close, the shared editorBody the shell owns so it survives the editor unmounting under the AI column on narrow viewports, …). This is how the palette shell landed before fill/insert, the grow editor and the AI column were built on top of it: DEFAULT_SNIPPET_PALETTE_SLOTS names the concrete components (palette-fill.tsx, palette-editor.tsx, palette-ai-column.tsx).

Growing a template in place

The contextual grow bar (tauri-app/renderer/components/snippet-palette/palette-grow.ts) never shows a permanent wall of buttons — it reads what the caret/selection is sitting in (growContextAt) and offers only the operations that make sense there:

ContextOffered
A selectionMake variable, Optional, Branch
Caret inside a field tagMake choices
Caret inside a choice tagVary text per choice (→ switch), Edit choices
Caret inside a {{#switch}}Add case

Each step is body → { body, caret } (GrowOutcome), built on @takazudo/snippet-template's grow primitives (wrapAsField, wrapAsOptional, wrapAsBranch, fieldToChoice, choiceToSwitch, addCase) and returning the caret position where the next relevant control should show (e.g. right after growField wraps a selection, the caret lands inside the new tag, where "Make choices" now applies).

  • growField — selection → {{name = selection}}. wrapAsField only embeds the selection as the default when it's safe to (no {{, }}, | or = inside it); otherwise it falls back to a bare {{name}}.

  • growOptional — selection → {{#opt name}}selection{{/opt}}.

  • growBranch — selection → branches over 2+ labels. If name is a field already asked elsewhere, it becomes an {{#if name = A}}…{{#elif name = B}}…{{/if}} chain (the selection is the first branch's body, one empty #elif per remaining label, and deliberately no #else). If name is new, the selection becomes a {{#switch name}} instead, since an #if chain only makes sense branching on a field asked somewhere else.

  • growChoices — {{name}} → {{name | A | B}} at every occurrence of that field.

  • growSwitch — {{name | A | B}} → {{#switch name}}{{case A}}{{case B}}{{/switch}}, when different choices need different surrounding text rather than just a different value.

  • growCase — one more {{case label}} before a switch's closing tag.

Every step is refused — returning { ok: false, problem } instead of mutating the body — when it would leave the template with more parse errors than it started with (finish()'s errorCount check). This is the one invariant that keeps every grow path parseable: e.g. selecting text that straddles an existing tag boundary and asking to wrap it as a field is rejected with "That selection cuts across a template tag — select plain text only." rather than producing a broken template.

Inserting a snippet

tauri-app/renderer/extensions/insert-snippet.ts:

  • renderSnippetInsertion(ast, values, { selection, readClipboard }) calls toInsertion after reading the clipboard only when the AST actually uses {{@clipboard}} (usesBuiltin) — a template that never references it never triggers a clipboard permission prompt.

  • insertSnippet(view, insertion, range?) replaces range (the typed-trigger span, ;;query) — or, without one, the current selection — in one CodeMirror transaction (userEvent: "input.snippet", one undo step), then places the caret at cursorOffset (from {{@cursor}}) or at the end of the inserted text.

AI services and mock modes

tauri-app/renderer/services/snippet-ai.ts covers two distinct jobs, both over the workspace-less POST /inline transport (bridge.inlineCommand.stream) with client-kept multi-turn messages — /inline itself keeps no server-side state.

Authoring: describe, templatize, refine

authorFromDescription(description), templatizeText(text) and refineTemplate(instruction, body) each send a task-specific system prompt (built from a shared SYNTAX_SPEC block describing the template grammar to the model) and require the reply as one JSON object: {"name", "body", "variables": [{"value", "name"}], "note"}. validateTemplateReply parses body with @takazudo/snippet-template's own parse() and rejects unknown {{@builtin}} names — the same parser the palette uses, so an AI reply that fails here would also fail to edit correctly in the grow editor. On a validation failure, the problems are sent back to the model as the next user turn (retryFeedback) — at most SNIPPET_AI_MAX_RETRIES (2) retries — before throwing SnippetAiError with code: "invalid_output" and the last raw output attached (lastOutput), so the caller can offer it for manual repair instead of just failing silently.

Insert-time polish

polishDraft({ history, instruction, draft, editorContext?, snippetInstruction? }) runs a short, explicit conversation over the filled-but-not-yet-inserted text: each user turn wraps the current draft and the instruction in <draft>/<instruction> tags, and editorContext/snippetInstruction (the snippet's own aiInstruction frontmatter field) are sent only on the first turn, tagged <context>/<snippet-instruction> and explicitly called out in the system prompt as reference material, not instructions that override the polish rules. The model always returns the complete new draft (no diff format) so re-editing by hand between turns is safe — the next call always sends whatever is currently on screen, not what the model last produced.

Error codes

SnippetAiError.code is one of quota_exceeded (HTTP 429), request_too_large (over INLINE_REQUEST_MAX_BYTES, 64 KiB — checked client-side before sending), invalid_output, aborted, or failed. Each (except failed) carries a fixed user-facing message (FRIENDLY_MESSAGES).

Mock modes

@takazudo/backend-bridge's mock inline-command streamer answers deterministically when the system prompt carries one of two markers the service always embeds:

  • [[mock:snippet-author]] (MOCK_SNIPPET_AUTHOR_MARKER) — replies with a canned valid template, unless the accumulated user text contains !badtemplate (invalid only on the first call of that authoring session) or !alwaysbadtemplate (invalid on every call) — the two knobs a test uses to exercise the parse-validate-retry loop and its terminal invalid_output failure without a real model.

  • [[mock:snippet-polish]] (MOCK_SNIPPET_POLISH_MARKER) — echoes the draft back with (polish #N: <instruction>) appended, N counting the assistant turns so far — enough to assert that history accumulates correctly across a multi-turn polish conversation.

A real model treats both markers as an inert label; they only change behavior inside createMockInlineCommandStreamer.

See also

  • Account Store and Account API — the account-level store behind "My snippets": key material, wrapping, enrollment, offline cache, realtime sync.

  • Input Triggers — the app-wide typed-sequence registry hosting both ;; (Snippets) and @@ (the inline AI command), including grammars, mounting, and the AppSettings.inlineTriggers schema.