Files
lib-action-bus/README.md
jules f28038abec feat: initial commit — extracted from comfy-cloud
Anything-can-drive-the-UI command bus. Single dispatch point for LLM tool
calls, scripts, and (optional) WebSocket remote control. JSON canonical
form plus a plain-text DSL sugar layer. Includes a visible virtual cursor
that animates to elements before commands act on them.

Modules:
- bus.ts        — CommandBus class, dispatch, handlers, vars, history,
                  listActions, readState. Built-in handlers: navigate, click,
                  fill, submit, select, wait, wait_for, scroll, read, expect,
                  set
- parser.ts     — DSL ↔ JSON, with quoted values, # comments, # speed:
                  directive, $name = <cmd> assignment, $var interpolation,
                  expect assertions
- script.ts     — script runner; loads /scripts/<name>.script, supports
                  sub-scripts via `run` command
- cursor.ts     — virtual cursor + ripple, speed-controlled, aria-hidden
- provider.tsx  — React glue: registers `navigate` via react-router, mounts
                  cursor, exposes window.commandBus / runScript /
                  runScriptText for console use
- ws.ts         — optional WebSocket producer (connectCommandSocket)
- llm-bridge.ts — buildSystemPrompt (DSL ref + live action snapshot),
                  extractActionBlocks, runActionBlocks, estimateTokens,
                  trimMessages
- index.tsx     — barrel re-exports

Peer deps: react, react-router (consuming app provides).

Originally inline in `comfy-cloud/app/lib/`. Extracted as part of the
crema-app-aifirst-template scaffolding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:32:22 +10:00

4.9 KiB

@crema/action-bus

The "anything can drive the UI" command bus. One dispatch point for LLM tool calls, scripts, and (optional) remote control over WebSocket — all funnel through the same handlers, run against the same DOM contract.

Pairs with lib-aifirst-shell-ui which provides a [data-action]-tagged shell. Non-AI apps that want scriptable / e2e-testable UIs can use this lib alone.

Public API

import {
  commandBus,            // singleton
  CommandBusProvider,    // mounts cursor + registers `navigate`
  parseScript,           // DSL ↔ JSON
  runScript, runScriptText,
  buildSystemPrompt,     // LLM bridge
  extractActionBlocks, runActionBlocks,
  estimateTokens, trimMessages,
  connectCommandSocket,  // optional WebSocket producer
} from "@crema/action-bus"

Usage

Wrap your app:

// app/root.tsx
import { CommandBusProvider } from "@crema/action-bus"

export default function App() {
  return (
    <CommandBusProvider>
      <Outlet />
    </CommandBusProvider>
  )
}

Tag interactive elements:

<button data-action="save-button">Save</button>
<input data-action="search-input" />

Drive the UI:

commandBus.dispatch({ type: "click", target: "save-button" })
commandBus.dispatch({ type: "fill", target: "search-input", value: "acme" })

// Or as text DSL:
import { runScriptText } from "@crema/action-bus"
runScriptText(`
  navigate /resources
  wait_for resources-table
  fill search-input "acme"
`)

Built-in commands

Command Args Purpose
navigate path React Router navigation
click target Click [data-action=target]
fill target, value Set input value, fire input + change events
submit target Submit the form containing target
select target, value Set <select> value
wait ms Sleep
wait_for target, timeout? Poll until element exists
scroll target? Scroll element into view
read target? Return innerText
expect target, op, value? to_contain / to_be_visible / to_have_value
set name, value Set a variable

Register your own:

commandBus.register("highlight", async (cmd) => {
  document.querySelector(`[data-action="${cmd.target}"]`)?.classList.add("hot")
})

DSL syntax

Plain text, one command per line. Quote values with spaces. # starts a comment. # speed: <n> at the top adjusts cursor animation. $name = <cmd> captures a result; $name interpolates.

# speed: 0.7

click sidebar-toggle
wait 500
$id = read row-1
click $id
expect detail-panel to_contain "Acme"

Round-trips: parseScript(dsl){ options, commands }, stringifyScript(commands, options) → text.

LLM integration

import { buildSystemPrompt, runActionBlocks } from "@crema/action-bus"

// 1. Inject into the model's system prompt — includes a tight DSL ref +
//    every visible [data-action] on screen right now.
const system = buildSystemPrompt({ path: window.location.pathname })

// 2. Extract and run any ```action ... ``` blocks the model emits.
const { ran, errors } = await runActionBlocks(message.content)

Plus token-budgeting helpers (estimateTokens, trimMessages) for keeping chat history within a model's context window.

See comfy-cloud's docs/AI_FIRST.md for the full system tour.

Visible cursor

By default, CommandBusProvider hooks a virtual cursor into the bus — animates to the target element before each command, ripples on click. Makes LLM-driven sessions legible to a watching human and gives demo recordings for free. Speed is controlled by the # speed: directive in DSL scripts; the setCursorSpeed(n) API sets it programmatically.

To run silently (e.g. tests), pass { silent: true } to dispatch() / run().

Optional WebSocket producer

import { connectCommandSocket } from "@crema/action-bus"
const sock = connectCommandSocket("ws://localhost:9229/ui")
// sock.close() to disconnect

Server messages: { id?, command } | { id?, script } | { id?, dsl }. Replies with { id, ok: true } or { id, ok: false, error }.

Not auto-connected — call when ready. Add origin checks and an opt-in toggle in production; the lib provides the transport, not the policy.

Conventions

  • [data-action] is the contract. Every interactive element opts in by declaring an id. The bus introspects the DOM at dispatch time — no central registry to maintain.
  • No global state besides the singleton. The bus is a class; you can instantiate your own if you want isolation.
  • Theme-agnostic. Cursor and ripple read CSS variables (--primary), fall back to neutral colors.

Peer dependencies

  • react
  • react-router (for CommandBusProvider's navigate handler)

Both come from the consuming app.