@llui/vite-plugin

Vite adapter for LLui. Wires the @llui/compiler signal transform into Vite — lowering signal expressions in component views to runtime helpers, emitting introspection metadata, and surfacing the signal lint rules as non-bypassable build errors. There is no virtual DOM.

pnpm add -D @llui/vite-plugin

Setup

// vite.config.ts
import { defineConfig } from 'vite'
import llui from '@llui/vite-plugin'

export default defineConfig({
  plugins: [llui()],
})

Options

llui({
  mcpPort: 5200, // MCP debug server port (default: 5200, false to disable)
})

What It Does

The plugin runs a single signal transform (@llui/compiler) over every .ts/.tsx file using the TypeScript Compiler API:

Step What it does
View lowering Lowers signal expressions in a component's DIRECT view to runtime helpers (signalText / el / react / signalEach / signalShow / signalBranch / …). An optimization — anything it can't lower runs via the runtime authoring helpers, so both forms coexist.
Introspection metadata Emits component / msg / state metadata (and, via opt-in compiler modules, agent schemas and devtools __componentMeta).
Lint rules Runs the signal lint set as non-bypassable build errors (surfaced via this.error()): peek-in-slot, operator-on-signal, pure-derive-body, no-node-construction-in-body, plus shared cross-file / agent / convention checks.

Diagnostics

The compiler emits warnings for common issues:

Diagnostic Description
Missing alt attribute Accessibility: img without alt
Non-exhaustive update update() switch missing msg type cases
Empty props Element helper called with empty props object
Namespace imports import * as prevents tree-shaking
Spread children Spread in children array defeats static analysis

Types

AgentPluginConfig

Reserved for future agent-server config. Empty today — opaque tokens (post-0.0.35) need no signing key, and the dev server hard-codes the identity resolver to 'dev-user'. The shape is kept so callers can pass agent: { ... } and we can grow options without churning the public type.

export type AgentPluginConfig = Record<string, never>

Annotation

export type Annotation = ({
    type: 'rect';
} & NoteRect & {
    label?: string;
}) | {
    type: 'element';
    selector: string;
    bbox: NoteRect;
    label?: string;
};

Author

export type Author = 'human' | 'llm';

CaptureLevel

export type CaptureLevel = 'standard' | 'verbose';

LogLevel

export type LogLevel = 'log' | 'warn' | 'error' | 'info' | 'debug';

NoteIntent

export type NoteIntent = 'task' | 'note';

NoteKind

export type NoteKind = 'rect' | 'element' | 'text' | 'capture' | 'reply';

NoteStatus

export type NoteStatus = 'open' | 'claimed' | 'in-progress' | 'proposed' | 'accepted' | 'applied' | 'rejected' | 'wontfix' | 'failed';

ServerEvent

export type ServerEvent = {
    type: 'note-created';
    id: string;
    filename: string;
    author: Author;
} | {
    type: 'note-updated';
    id: string;
    sessionId: string;
} | {
    type: 'note-deleted';
    id: string;
    sessionId: string;
} | {
    type: 'task-progress';
    noteId: string;
    elapsedMs: number;
    /** Token counters from the LLM stream.
     *   - `in`: latest cumulative input_tokens (context size). Grows
     *           monotonically across the conversation.
     *   - `out`: sum of all output_tokens generated so far.
     *   - `cacheRead`: prompt-cache hits, if the model reports them
     *           (claude's `cache_read_input_tokens`). Shows how much
     *           of the context was served from cache vs. reprocessed. */
    tokens?: {
        in: number;
        out: number;
        cacheRead?: number;
    };
    toolSummary?: string;
} | {
    type: 'capture-request';
    requestId: string;
    payload: CaptureRequestPayload;
} | {
    type: 'capture-request-cancelled';
    requestId: string;
} | {
    type: 'session-rotated';
    sessionId: string;
} | {
    type: 'status-changed';
    noteId: string;
    from: NoteStatus | null;
    to: NoteStatus;
    /** Optional human-readable context — e.g. the LLM's proposed-fix
     *  summary, a failure message, or a git-apply conflict. The HUD
     *  surfaces this verbatim in its status line. */
    reason?: string;
};

SseRole

export type SseRole = 'hud' | 'mcp' | 'viewer';

Interfaces

AgentSchemaSummary

export interface AgentSchemaSummary {
    msg: string;
    fields: Record<string, string>;
}

CaptureRequestPayload

export interface CaptureRequestPayload {
    route?: string;
    url?: string;
    selector?: string;
    annotate?: Annotation[];
    prose?: string;
    waitForMessage?: string;
    captureLevel?: CaptureLevel;
    timeoutMs?: number;
}

CaptureRequestResponse

export interface CaptureRequestResponse {
    requestId: string;
    status: 'fulfilled' | 'timeout' | 'no-client';
    note?: CreateNoteResponse;
}

ComponentMetaRef

export interface ComponentMetaRef {
    file: string;
    line: number;
    name: string;
}

ConsoleLogEntry

export interface ConsoleLogEntry {
    ts: string;
    level: LogLevel;
    text: string;
}

CreateNoteRequest

export interface CreateNoteRequest {
    body: string;
    frontmatter: Omit<NoteFrontmatter, 'id' | 'ts'>;
    noteBody: NoteBody;
    screenshot?: string;
}

CreateNoteResponse

export interface CreateNoteResponse {
    id: string;
    filename: string;
    path: string;
    sessionId: string;
}

CurrentSessionResponse

export interface CurrentSessionResponse {
    sessionId: string;
    startedAt: string;
    notesDir: string;
}

DevmodeAnnotateConfig

export interface DevmodeAnnotateConfig {
  /** Override the on-disk notes root. Relative paths resolve against
   *  the Vite project root. Default: `.llui/notes`. The
   *  `LLUI_NOTES_DIR` env var takes precedence if set. */
  notesDir?: string
  /**
   * Override session-folder naming and/or slug derivation. The
   * id+author+kind prefix of each filename stays fixed so id ordering
   * and filename parsing keep working — only the trailing slug and
   * the session folder name are customizable.
   *
   * ```ts
   * format: {
   *   formatSessionFolder: (d) => `session-${d.toISOString().slice(0, 10)}`,
   *   deriveSlug: (prose) =>
   *     prose.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/^-|-$/g, '') || 'capture',
   * }
   * ```
   *
   * Note: when the MCP server writes notes directly (out-of-process),
   * it uses defaults — only writes that go through the dev-server
   * middleware (the HUD path) honor these overrides.
   */
  format?: NoteFormatConfig
  /** Override the default capture-request long-poll timeout in
   *  milliseconds. The `LLUI_CAPTURE_TIMEOUT_MS` env var takes
   *  precedence if set. Default: 30000. */
  captureTimeoutMs?: number
  /**
   * The attention router auto-picks up task-mode notes (the developer
   * clicks "Solve" in the HUD) and spawns the configured LLM CLI to
   * propose a fix. Accepts:
   *
   *  - `false` — disable. The HUD hides its "Solve" button; notes
   *              still save to disk so MCP-side consumers can act on
   *              them.
   *  - `'claude' | 'codex' | 'gemini'` — preset; everything defaults.
   *  - `LlmRouterConfig` — preset + overrides (model, timeoutMs,
   *              concurrency, env, extraArgs), or a fully custom
   *              invocation `{ command, args, promptVia }` (omit
   *              `preset` to opt out of preset defaults entirely).
   *
   * When the chosen CLI isn't on PATH the router degrades silently
   * to save-only and the HUD hides the Solve button — the user gets
   * a one-line install hint in the console.
   *
   * Default: `'claude'`.
   */
  router?: false | LlmPreset | LlmRouterConfig
  /** Override the per-task timeout for the router's spawn. Default
   *  5 minutes. Deprecated alias for `router.timeoutMs`. */
  routerTimeoutMs?: number
  /**
   * Controls the in-app HUD (`@llui/devmode-annotate`) auto-injection.
   *
   *  - `true` / omitted — inject in dev mode (default).
   *  - `false`          — skip injection. The notes API stays live so
   *                       MCP can still consume the notebook; only the
   *                       floating button + modal are skipped.
   *  - `HudInjectionConfig` — inject with forwarded options. Currently
   *                       supports `{ hidden: true }` to mount the HUD
   *                       programmatically (no floating button).
   *
   * Injection silently no-ops when `@llui/devmode-annotate` isn't
   * resolvable from the project root.
   */
  hud?: boolean | HudInjectionConfig
}

DirtyTraceEntry

export interface DirtyTraceEntry {
    component: string;
    pathsTracked: string[];
    mask: number;
    maskHi?: number;
    lastFlippedBits: string[];
}

HudInjectionConfig

export interface HudInjectionConfig {
  /** Mount the HUD without rendering the floating button. The
   *  keyboard shortcut + programmatic API still work. */
  hidden?: boolean
  /** When `true` (default), the HUD installs `window.onerror` +
   *  `unhandledrejection` listeners. On an uncaught error it opens
   *  the modal pre-populated with the stack + a screenshot — turns
   *  "I saw something weird but can't reproduce it" into a
   *  one-click solve. Set `false` to opt out of the listeners
   *  entirely. */
  autoCaptureOnError?: boolean
  /** When `true` (default), the HUD shows a "● Record" toggle that
   *  captures clicks/inputs/route-changes/messages between toggle-on
   *  and submit, attaching them to the note for the LLM to replay.
   *  Set `false` to hide the toggle and skip the listener setup. */
  repro?: boolean
  /** When `true` (default), the HUD exposes the "⌖ Pick element"
   *  annotation mode alongside "⌖ Add region". Set `false` to hide
   *  the picker affordance. */
  elementPick?: boolean
}

ListNotesQuery

export interface ListNotesQuery {
    sessionId?: string;
    author?: Author;
    kind?: NoteKind | NoteKind[];
    since?: string;
    limit?: number;
}

ListNotesResponse

export interface ListNotesResponse {
    sessionId: string;
    notes: NoteSummary[];
    total: number;
}

LluiPluginOptions

export interface LluiPluginOptions {
  /**
   * Port for the MCP debug bridge. In dev mode, the runtime relay connects
   * to `ws://127.0.0.1:<port>` so an external `llui-mcp` server can forward
   * tool calls into the running app.
   *
   * When omitted, the plugin checks whether `@llui/mcp` is resolvable from
   * the Vite project root. If yes → defaults to `5200`. If no → stays
   * disabled. This means installing `@llui/mcp` (+ starting its server)
   * Just Works without an explicit config entry. Pass an explicit `false`
   * to opt out even when `@llui/mcp` is installed; pass a number to use
   * a non-default port. When enabled but the MCP server isn't running,
   * the plugin returns 404 from its discovery endpoint and the browser
   * silently skips the connection — no retry noise.
   */
  mcpPort?: number | false

  /**
   * Enables two things together when set:
   *
   *   1. Emits schemas + binding descriptors in prod builds so the
   *      @llui/agent runtime has metadata to advertise over its WS hello
   *      frame.
   *   2. Auto-mounts `@llui/agent/server`'s router at `/agent/*` and its
   *      WS upgrade handler at `/agent/ws` on the Vite dev server — so
   *      plain `vite dev` has working agent endpoints with no extra
   *      server.ts wiring. Requires `@llui/agent` installed; if it isn't,
   *      the plugin warns and skips dev mounting (prod emission still
   *      works from Plan 3b).
   *
   * Pass `true` for defaults (random signing key per dev session;
   * `identityResolver` returns `'dev-user'`). Pass an object to customize.
   * Default `false` — metadata is dev-only, no agent endpoints.
   */
  agent?: boolean | AgentPluginConfig

  /**
   * Whether any component in the app uses `each()`'s `enter` / `leave`
   * / `onTransition` options. When `false` (the default), the
   * vite-plugin substitutes `__LLUI_TRANSITIONS__ = false` into the
   * runtime bundle; Vite's dead-code eliminator then drops the
   * per-entry enter/leave helpers, the `leaving` queue plumbing, and
   * the `report` allocation in `each()`'s reconcile path. Saves
   * ~0.3 kB gz on jfb-shape bundles that don't animate.
   *
   * Apps using `@llui/transitions` or any custom `each({ enter, leave,
   * onTransition })` MUST pass `transitions: true` — otherwise the
   * options will be silently ignored at runtime.
   */
  transitions?: boolean

  /**
   * Surface compiler `perf` diagnostics as Vite warnings. Currently one
   * diagnostic exists: `llui/each-verbatim` — an `each` whose rows did not
   * compile to the cloneNode RowFactory (nor the render-callback lowering)
   * and render via the runtime authoring path instead, paying per-row
   * construction overhead. The message names the bail reason(s) with an
   * actionable hint (e.g. a row delegating to an imported helper, spread
   * connect-part props, an imperative render body).
   *
   * Advisory only — never blocks the build (a verbatim `each` is fully
   * correct, just slower per row). **Default: on in dev mode, off in
   * build.** Pass `false` to silence, `true` to also warn during builds.
   */
  perfDiagnostics?: boolean

  /**
   * Controls the devmode-annotate notebook surface — a single Connect
   * middleware mounted at `/_llui/*` that lets the HUD
   * (`@llui/devmode-annotate`) and the MCP server (`@llui/mcp`) read
   * and write a shared on-disk notebook under `.llui/notes/`. The HUD
   * developer drops notes from the running app; the LLM consumes them
   * via MCP subscriptions; both can initiate captures.
   *
   * **Default: on in dev mode.** Omitting the option (or passing `true`)
   * registers the middleware automatically — there's nothing to do.
   * Pass `false` to opt out (no routes registered, middleware tree-
   * shakes). Pass an object to keep it on while customizing the notes
   * directory or default timeout.
   *
   * The HUD is **auto-injected** in dev mode: the plugin emits a
   * `<script type="module">` into the served HTML that imports
   * `@llui/devmode-annotate` and mounts the floating button. Production
   * builds never run `configureServer` or `transformIndexHtml(dev)`, so
   * this is dev-only by construction. Disable just the HUD (keeping the
   * notes API on) with `devmodeAnnotate: { hud: false }`; disable
   * everything with `devmodeAnnotate: false`. The HUD package must be
   * resolvable from the project root — install
   * `@llui/devmode-annotate` alongside `@llui/vite-plugin`.
   *
   * Environment overrides (honored when not opted out):
   *   - `LLUI_NOTES_DIR` — override the notes root path
   *   - `LLUI_CAPTURE_TIMEOUT_MS` — override the default capture-request timeout
   *
   * The proposal (`docs/proposals/devmode-annotate/`) details what
   * lands on disk and what the LLM gets.
   */
  devmodeAnnotate?: boolean | DevmodeAnnotateConfig
}

MessageLogEntry

export interface MessageLogEntry {
    ts: string;
    component: string;
    msg: unknown;
}

NoteBody

export interface NoteBody {
    stateSnapshot?: unknown;
    messageLog?: MessageLogEntry[];
    consoleLog?: ConsoleLogEntry[];
    pendingMessages?: PendingMessage[];
    effects?: {
        pending: PendingEffectEntry[];
        recent: RecentEffectEntry[];
    };
    dirtyTrace?: DirtyTraceEntry[];
    structuralAt?: StructuralSnapshot;
    sourceMap?: SourceMapEntry[];
    errors?: RuntimeErrorEntry[];
    /** Captured user interactions from the HUD's repro recorder. The
     *  LLM uses this to understand what the developer did before the
     *  bug appeared. Times are milliseconds from the start of the
     *  recording, not absolute. */
    repro?: ReproEvent[];
    verbose?: VerboseNoteBody;
}

NoteFrontmatter

export interface NoteFrontmatter {
    id: string;
    ts: string;
    author: Author;
    kind: NoteKind;
    captureLevel: CaptureLevel;
    url: string;
    route: string | null;
    routeParams: Record<string, string>;
    viewport: {
        w: number;
        h: number;
        dpr: number;
    };
    componentPath: string[] | null;
    componentMeta: ComponentMetaRef | null;
    annotations: Annotation[];
    screenshot: string | null;
    agentSchemas: AgentSchemaSummary[];
    llui: {
        runtime: string;
        compiler: string;
    };
    fulfillsRequestId?: string;
    intent?: NoteIntent;
    replyTo?: string;
    proposedDiff?: ProposedDiff;
    /** When true (default true for HUD-originated tasks), the router
     *  spawns the LLM with its resume-previous-conversation flag (e.g.
     *  `claude --continue`) so the LLM keeps prior context. Presets
     *  without a resume flag treat this as a no-op. */
    resume?: boolean;
    /** Name of the resume chain this task participates in. The router
     *  keeps a map of chain name → last session id and passes the
     *  corresponding id via `--resume` when `resume: true`. Lets the
     *  user maintain independent conversation threads (e.g. "refactor",
     *  "ui-polish") without them stomping on each other. Default
     *  `'default'`. */
    chainName?: string;
}

NoteRect

export interface NoteRect {
    x: number;
    y: number;
    w: number;
    h: number;
}

NoteSummary

export interface NoteSummary {
    id: string;
    sessionId: string;
    filename: string;
    ts: string;
    author: Author;
    kind: NoteKind;
    url: string;
    componentPath: string[] | null;
    preview: string;
    hasScreenshot: boolean;
    /** Frontmatter shortcuts surfaced in the list so the HUD can
     *  rehydrate trackedTasks + chainHistories on reload without
     *  fetching each note individually. Optional for back-compat with
     *  servers that don't populate them. */
    intent?: NoteIntent;
    chainName?: string;
    /** For reply notes only — the original task this reply addresses. */
    replyTo?: string;
    /** For reply notes only — the LLM's one-line summary of the
     *  proposed change (extracted from proposedDiff). */
    proposedSummary?: string;
}

PendingEffectEntry

export interface PendingEffectEntry {
    id: string;
    component: string;
    effect: unknown;
    sinceMs: number;
}

PendingMessage

export interface PendingMessage {
    component: string;
    msg: unknown;
}

ProposedDiff

export interface ProposedDiff {
    files: Array<{
        path: string;
        patch: string;
    }>;
    summary: string;
    confidence: 'high' | 'medium' | 'low';
}

RecentEffectEntry

export interface RecentEffectEntry {
    ts: string;
    component: string;
    effect: unknown;
    outcome: 'ok' | 'error' | 'cancelled';
    error?: string;
}

RuntimeErrorEntry

export interface RuntimeErrorEntry {
    ts: string;
    kind: 'runtime' | 'compiler';
    file?: string;
    line?: number;
    message: string;
    stack?: string;
}

SourceMapEntry

export interface SourceMapEntry {
    selector: string;
    file: string;
    line: number;
    componentPath: string[];
}

StatusTransition

export interface StatusTransition {
    ts: string;
    noteId: string;
    from: NoteStatus | null;
    to: NoteStatus;
    by: Author | 'system';
    reason?: string;
}

StructuralSnapshot

export interface StructuralSnapshot {
    branches: Array<{
        at: string;
        activeArm: string;
    }>;
    shows: Array<{
        at: string;
        visible: boolean;
    }>;
    eachKeys: Array<{
        at: string;
        keys: string[];
    }>;
}

VerboseNoteBody

export interface VerboseNoteBody {
    scopeTree?: Array<{
        id: string;
        parent: string | null;
        component: string;
        key?: string;
    }>;
    bindings?: {
        total: number;
        hottest: Array<{
            component: string;
            path: string;
            firesPerSec: number;
        }>;
        lastCycleMs: number;
    };
    agentBridge?: {
        connectedAgents: string[];
        pendingToolCalls: number;
        recentMsgs: Array<{
            ts: string;
            direction: 'in' | 'out';
            payload: unknown;
        }>;
    };
    transitionsInFlight?: Array<{
        component: string;
        name: string;
        progress: number;
    }>;
    foreignInstances?: Array<{
        component: string;
        library: string;
    }>;
}