zudo-text

検索したい単語を入力

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

Input Triggers

Input Triggers are typed prefixes the editor watches for while you write. @takazudo/cm-inline-triggers is the app-wide CodeMirror registry that detects them; it hosts both the inline AI command's @@ and the Snippets palette's ;; (epic #6780).

Grammars

A TriggerSpec names a grammar, which decides how its sequence is recognized on the current line:

GrammarWhere it matchesQuery
line-startOptional leading spaces/tabs, then the sequence and one space. The cursor may sit anywhere on the line.Everything after the sequence and its following space, to end of line.
after-boundaryThe sequence at the line start, or right after whitespace, anywhere on the line. The cursor must sit inside the query (or directly after the sequence).Characters after the sequence up to the next whitespace.

@@ (the inline AI command) uses line-start — it only fires at the start of a line, mirroring a chat-style command prompt. ;; (Snippets) uses after-boundary — it fires mid-sentence too, since a snippet trigger is meant to interrupt normal prose.

Two more per-trigger options:

  • skipInCode (default true) — ignores a hit inside fenced/indented code blocks and inline code spans.

  • fullWidthVariant — also accepts the sequence mapped onto the Fullwidth Forms Unicode block (toFullWidth()), e.g. ;; also matches ;;, the form a Japanese IME commits for the same two keys. Snippets sets this; the AI command does not.

The code-skip check forces a bounded parse up to the cursor line before deciding; if its 20 ms budget is exhausted, it deliberately falls back to the available tree and may allow a trigger in code.

Registering a trigger

import { registerTrigger, type TriggerSpec } from "@takazudo/cm-inline-triggers";

const mySpec: TriggerSpec = {
  id: "my-trigger",
  sequence: "::",
  grammar: "after-boundary",
  fullWidthVariant: true,
  onEnter: ({ trigger }) => myEffect.of({ query: trigger.query }),
  onUpdate: ({ trigger }) => myEffect.of({ query: trigger.query }),
  onExit: () => myEffect.of(null),
};
  • Ids are unique per editor: the first registration of an id wins if two specs share one.

  • An empty sequence is a no-op registration.

  • onEnter / onUpdate / onExit run inside a CodeMirror transactionExtender, so they must not dispatch — return the StateEffect(s) to append to the same transaction instead. This keeps a consumer's state atomic with the edit that caused it.

  • The active instance lives in activeTriggerField: { id, from, to, query, requestId }. requestId bumps on every fresh onEnter, so async work in flight for a stale instance can be filtered out.

  • dismissActiveTriggerEffect(id) clears the active instance without running onExit — a consumer dispatches it after ending its own session (submit/cancel) so the next recheck on a still-matching line enters afresh with a new requestId.

Mounting

There is exactly one reconfigurable trigger-set Compartment per editor, so an editor that hosts more than one trigger must build the whole set in a single call:

  • inlineTriggersCore() — the shared detection machinery (composition tracking, the detector, activeTriggerField) with no trigger set. Safe to include from several extensions; CodeMirror deduplicates it.

  • inlineTriggers(specs) — the core plus the reconfigurable set, mounted once per editor.

  • reconfigureTriggers(view, specs) — swaps the whole set in place (keeping undo history, selection, and scroll), mounting it first if the editor doesn't have one yet. Every call supplies the complete set currently wanted, not just the entry that changed.

packages/cm-inline-command's inlineCommandExtension() (the @@ surface) takes an ownTriggerSet option: true (the default) has it call inlineTriggers([inlineCommandTrigger(sequence)]) itself; false has it mount only inlineTriggersCore() and expect the host to register inlineCommandTrigger(sequence) in its own combined inlineTriggers([...]) call. The writing app's editor uses ownTriggerSet: false for exactly this reason — it combines the AI command trigger and the snippets trigger in one call (tauri-app/renderer/components/editor-pane/extensions.ts):

inlineTriggers([
  ...(inlineCommand
    ? [inlineCommandTrigger(inlineCommandSequence, { typed: inlineCommandTyped })]
    : []),
  ...(snippetsTriggerEnabled ? [snippetTrigger(snippetsTriggerSequence)] : []),
]),

A later sequence or enabled-flag change reconfigures that same set in place via reconfigureTriggers (tauri-app/renderer/lib/codemirror-view-factory.ts) instead of rebuilding the whole EditorView — every call there also supplies both specs, whichever are currently enabled. Toggling the AI command itself (inlineAiCommand.enabled) is still a full view rebuild, because inlineCommandExtension also owns capture state and a keymap; the snippets trigger owns neither, so its enable/disable is a plain reconfigure like a sequence edit.

inlineTriggers.aiCommand.enabled gates only the typed sequence. With it off, the AI command trigger stays registered as typed: false: typing @@ does nothing, but Mod-j and the command palette still start capture, because insertInlineCommandTrigger dispatches its insert with explicitTriggerAnnotation naming the trigger id — the only way a typed: false spec enters. Flipping it is an in-place reconfigure like a sequence edit.

@@ — the inline AI command

INLINE_COMMAND_TRIGGER_ID = "inline-ai-command". Its hooks drive inlineCommandStateField (capture-mode state: active line, live command text, decorations, the status pill) so capture state lands atomically with the edit that triggered it. insertInlineCommandTrigger(view) — bound to the configurable shortcut (default Mod-j) — reads the live sequence off the registered trigger spec rather than hardcoding @@, and handles cursor placement so neither leading nor trailing text on the line is silently swallowed into the command. On submit or cancel, the keymap dispatches both exitCaptureEffect and dismissActiveTriggerEffect(INLINE_COMMAND_TRIGGER_ID) so the next recheck starts clean.

;; — Snippets

SNIPPET_TRIGGER_ID = "snippets" (tauri-app/renderer/lib/snippet-trigger.ts). Unlike the AI command it owns no capture state of its own: its hooks just dispatch snippetTriggerEffect with { range, query, requestId } (or null on exit), consumed by the React hook use-snippet-trigger-driver.ts, which dispatches OPEN_SNIPPET_PALETTE_EVENT. The CodeMirror-only pieces (snippet-trigger.ts) are deliberately free of React/app-events imports so the low-level editor factory and its narrowly-mocked unit tests don't pull in that dependency graph; the React driver lives under hooks/ instead.

Settings

AppSettings.inlineTriggers (@takazudo/app-defaults) replaced the old inlineAiCommand.triggerSequence enum ("@@" | "/ai" | "off") — pre-release, no migration:

inlineTriggers: {
  aiCommand: { sequence: string; enabled: boolean };
  snippets: { sequence: string; enabled: boolean };
};

Defaults: { aiCommand: { sequence: "@@", enabled: true }, snippets: { sequence: ";;", enabled: true } }.

Validation (validateSettings()):

  • A sequence needs at least 2 characters and no whitespace (^\S{2,}$). An invalid-but-present sequence warns and falls back to the default for that one entry.

  • Enabled sequences may not collide — equal, or one a prefix of the other (the shorter would always win the grammar match, so the longer could never fire). triggerSequencesConflict() is exported so the settings UI can show the same warning the validator would otherwise silently resolve. A collision resets the whole inlineTriggers block to defaults, not just the offending entry, since either trigger could be the one the user actually meant to change.

inlineAiCommand.insertOnLineStartOnly is a separate, narrower toggle: it only gates the Mod-j / command-palette insert shortcut, not the typed-grammar detection above (already line-start-only by construction for aiCommand, and independent of it for snippets, which is after-boundary).

Settings UI

Settings → Input Triggers (tauri-app/renderer/components/settings/sections/input-triggers-section.tsx) is the one surface for every registered sequence — it replaced a temporary free-text "Trigger sequence" input that used to live inline inside the Inline AI Command section. Each row edits a sequence and its enabled flag, and shows the same conflict warning triggerSequencesConflict() computes ("Conflicts with Other Trigger — equal sequence, or one is a prefix of the other. It is not saved until the conflict is resolved."). The AI Command row is hidden entirely when the app instance is not AI-capable; the Snippets row is always shown. A hidden AI Command row still takes part in the conflict check: the validator resets both triggers on any collision, so the Snippets row shows "Conflicts with the AI Command trigger "@@" …" (naming the hidden sequence) and refuses to commit. A conflicting sequence edit stays a local draft, and ticking Enabled on a trigger that would collide is refused with the same warning; the reset buttons go through the same guarded paths.

Known gap

The registry is mounted at every main editor site, but the External File Editor does not mount the snippets trigger driver — typing ;; there does not open the Snippets palette. The same gap already exists for @@. ⌘K → "Snippets…" and the configurable shortcut (default ⌘⇧I) both work everywhere regardless.