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

166 lines
4.9 KiB
Markdown

# @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`](https://git.sky-ai.com/CremaUIStudio/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
```ts
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:
```tsx
// app/root.tsx
import { CommandBusProvider } from "@crema/action-bus"
export default function App() {
return (
<CommandBusProvider>
<Outlet />
</CommandBusProvider>
)
}
```
Tag interactive elements:
```tsx
<button data-action="save-button">Save</button>
<input data-action="search-input" />
```
Drive the UI:
```ts
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:
```ts
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
```ts
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`](https://git.sky-ai.com/CremaUIStudio/crema-app-aifirst-template/raw/branch/main/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
```ts
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.