# Signals + UJSX: Reactive Transform Pipeline ## The Core Idea Preact Signals' `computed` provides exactly what UJSX needs for efficient rendering: a component whose output is automatically cached and only recomputed when its input signals change. In the context of an agent HUD: - HUD **state** lives in `signal()` values (task description, decisions, notes, context %) - HUD **components** are `computed()` signals that read their input signals and produce UElements - The **root render** is a `computed()` that traverses the component tree and produces markdown - Changing one signal (e.g., context percentage from 70% to 85%) automatically marks only the downstream components as outdated — not the entire tree This is fundamentally different from the current approach where the entire HUD is re-rendered from scratch every turn. ## How Signals Work (from source) Key behaviors from `/workspace/signals/packages/core/src/index.ts`: ### `signal(value)` — The Reactive Primitive ```typescript const count = signal(0) count.value // read (tracks deps if in computed/effect) count.value = 1 // write (notifies dependents, batches updates) count.peek() // read WITHOUT tracking (untracked) ``` ### `computed(fn)` — Derived, cached computation ```typescript const doubled = computed(() => count.value * 2) ``` - Lazily evaluated: fn only runs when `.value` is read AND deps have changed - Cached: if deps haven't changed, returns cached value without running fn - Version tracking: uses `_version` numbers on signals instead of storing values - Dependency graph: automatically tracks which signals were read during evaluation ### `effect(fn)` — Side effects ```typescript effect(() => { console.log(doubled.value) // re-runs when count changes }) ``` - Returns a dispose function - fn can return a cleanup function - Batches: multiple signal writes within `batch()` only trigger one effect run ### `batch(fn)` — Batching ```typescript batch(() => { a.value = 1 // doesn't trigger effects yet b.value = 2 // still batched }) // effects run once after batch completes ``` ### `createModel(factory)` — Model pattern ```typescript const Counter = createModel((initial: number) => ({ count: signal(initial), increment: action(() => { Counter.count.value++ }), doubled: computed(() => Counter.count.value * 2), })) const c = new Counter(0) c.count.value // 0 c.increment() // batched, untracked write c.doubled.value // 2 c[Symbol.dispose]() // cleans up all effects ``` ### Key Implementation Details 1. **Version numbers, not value storage**: Computed signals store `_version` per dependency node, not values. When checking if recomputation is needed, it compares version numbers. This is memory-efficient. 2. **Global version counter**: `globalVersion` increments when ANY signal changes. Computed signals track `_globalVersion` — if global hasn't changed since last evaluation, skip entirely (fast path). 3. **Lazy evaluation**: Computed values are NOT computed until someone reads `.value`. No eager re-computation. 4. **Batch snapshots**: When writing multiple signals in a batch, `_batchSnapshotVersion` tracks which signals were written. On batch end, `reconcileBatchSnapshots()` restores version numbers for signals that were written but ended up with the same value (avoiding spurious notifications). 5. **Auto-dispose**: Effects created within `createModel` are captured and disposed when the model's `[Symbol.dispose]()` is called. 6. **Untracked reads**: `signal.peek()` reads without tracking. `untracked(fn)` runs fn without tracking. `action(fn)` wraps fn in batch+untracked. ## Application to UJSX Transform Pipeline ### The Problem In the HUD, every agent turn re-renders the entire HUD from the root. If the task description hasn't changed, the task section still re-renders. If context went from 67% to 68%, the entire tree is walked even though only `ContextBar` changed. With signals: - Each component whose input hasn't changed **skips re-evaluation entirely** - Only the `computed` signals that depend on changed `signal` values re-compute - The render pipeline walks the tree, but `computed.value` returns cached results for unchanged components ### Proposed Architecture ```typescript import { signal, computed, batch, effect } from "@preact/signals-core" // --- State Layer (signals) --- const hudState = createModel(() => ({ task: signal<{ description: string; updatedAt: number } | null>(null), contextPercent: signal(0), contextStatus: computed(() => { const p = hudState.contextPercent.value if (p < 70) return 'green' as const if (p < 85) return 'yellow' as const return 'red' as const }), decisions: signal>([]), notes: signal>([]), nextSteps: signal>([]), activeFiles: signal>([]), density: computed(() => { const p = hudState.contextPercent.value if (p < 70) return 'full' as const if (p < 85) return 'compact' as const return 'minimal' as const }), })) // --- Component Layer (computed signals producing UElements) --- function TaskSection() { return computed(() => { const task = hudState.task.value const density = hudState.density.value if (!task) return null if (density === 'minimal') { return h('p', null, task.description) } return h('div', null, h('h2', null, 'Task'), h('p', null, task.description), ) }) } function ContextBar() { return computed(() => { const percent = hudState.contextPercent.value const status = hudState.contextStatus.value const density = hudState.density.value if (density === 'minimal') { return h('p', null, `${Math.round(percent)}% ${status === 'red' ? '⚠' : ''}`) } return h('div', null, h('h2', null, 'Context'), h('p', null, `${Math.round(percent)}% used (${status})`), ) }) } function DecisionsList() { return computed(() => { const density = hudState.density.value const decisions = hudState.decisions.value if (density === 'minimal') return null if (density === 'compact') { return h('p', null, `Decisions (${decisions.length})`) } return h('div', null, h('h2', null, 'Decisions'), h('ul', null, ...decisions.map(d => h('li', null, d.summary))), ) }) } // --- Root Render (computed signal) --- const hudRender = computed(() => { const task = TaskSection() const ctx = ContextBar() const decisions = DecisionsList() const children = [task, ctx, decisions].filter(Boolean) return createRoot('hud', ...children) }) // --- Markdown Output (effect, or manual) --- // Option A: Reactive — auto-renders when any signal changes const dispose = effect(() => { const tree = hudRender.value // only recomputes what changed const markdown = renderMarkdown(tree) sendToSystemPrompt(markdown) }) // Option B: Manual — called when we know we need a new render function renderHUD() { return hudRender.value // triggers only necessary computed() evaluations } // --- Updates are batched --- function updateHUD(event: HudEvent) { batch(() => { switch (event.type) { case 'task.set': hudState.task.value = { description: event.description, updatedAt: Date.now() } break case 'context.snapshot': hudState.contextPercent.value = event.percent break case 'decision.record': hudState.decisions.value = [...hudState.decisions.value, event.decision] break } }) // Only one effect run after the batch, even if multiple signals changed } ``` ### What This Buys Us 1. **Skip unchanged components**: If `task` signal hasn't changed, `TaskSection()` returns the cached UElement without calling the component function. 2. **Density changes propagate efficiently**: Changing `contextPercent` from 67% to 68% (green→green) doesn't trigger `ContextBar` re-render because the computed `contextStatus` still returns 'green'. From 69%→71% (green→yellow) DOES trigger `ContextBar` and anything that reads `density`. 3. **Batched updates**: A multi-field update (task + decisions + context) in one `batch()` only triggers one render pass. 4. **No global dirty tracking needed**: The existing POC's `dirty` bitmask on graphology nodes is a manual approximation. Signals do this automatically and correctly. 5. **Separation of state and rendering**: State is in `signal()` values. Rendering is `computed()` derivations. Effects wire rendering to side effects (injecting into system prompt). 6. **`peek()` for untracked reads**: During transformation (walking the tree to produce markdown), we can `peek()` at values without subscribing — useful when the transform doesn't need to be reactive. 7. **Disposal**: `hudState[Symbol.dispose]()` cleans up all effects created within the model. Clean teardown when a session ends. ### What This Does NOT Buy Us (Yet) 1. **Structural changes still need full walk**: Adding/removing a list item still requires re-walking that part of the tree. Signals help with value changes, not structural changes. For structural changes, we'd need a keyed list diff (like React's reconciliation). 2. **The transform (UJSX → mdast → markdown) is always full**: Even if only one component re-computes, the entire tree still gets walked to produce markdown. To skip unchanged subtrees in the transform, we'd need to memoize transform results per node (using `computed` at the transform level too). 3. **No framework dependency yet**: We're using signals-core standalone, not via Preact/React integration. The Preact adapter (`@preact/signals`) adds `useSignal()` hook, React adapter (`@preact/signals-react`) adds `useSignalValue()`. We don't need either — we use `signal`/`computed` directly. ### Computed Transforms (Memoization at Every Level) The real power: make the transform pipeline itself reactive: ```typescript // State signal const taskText = signal("Implement auth module") // Component produces a UElement (computed — cached) const taskElement = computed(() => h('h2', null, taskText.value) ) // Transform produces mdast (computed — cached) const taskMdast = computed(() => transformToMdast(taskElement.value, { direction: 'ujsx->mdast' }) ) // Serialize to markdown (computed — cached) const taskMarkdown = computed(() => toMarkdown(taskMdast.value) ) // Only runs when taskText changes: taskElement → taskMdast → taskMarkdown // If taskText hasn't changed, reading taskMarkdown.value returns cached string ``` This means the ENTIRE pipeline is memoized. If you change `contextPercent` from 67% to 68% (green→green, no density change), `taskMarkdown.value` returns the cached string instantly. Only `contextBarMarkdown.value` recomputes. ### The Final Composition ```typescript // Each section is its own computed pipeline const taskMd = computed(() => renderSection(TaskSection(), 'ujsx->mdast')) const ctxMd = computed(() => renderSection(ContextBar(), 'ujsx->mdast')) const decisionsMd = computed(() => renderSection(DecisionsList(), 'ujsx->mdast')) // Root markdown is computed from sections const fullMarkdown = computed(() => [taskMd.value, ctxMd.value, decisionsMd.value] .filter(Boolean) .join('\n\n') ) // Only the sections whose signals changed recompute their markdown // fullMarkdown recomputes its string join (cheap) but doesn't re-walk unchanged sections ``` ### Integration with opencode Plugin The `effect()` that injects into the system prompt would be: ```typescript const dispose = effect(() => { const markdown = fullMarkdown.value // This runs whenever any upstream signal changes // But only the changed computed() evaluations actually ran systemPromptInjector.push(markdown) }) ``` Or more pragmatically, since opencode calls the plugin hook on every LLM turn: ```typescript // In the plugin's system.transform hook: "experimental.chat.system.transform": async (input, output) => { // Just read .value — it's cached, no work if unchanged const markdown = fullMarkdown.value output.system.push(markdown) } ``` This is the simplest integration: no effect, no subscription. Just read `.value` when asked. If nothing changed, it's O(1). If something changed, only the minimal recomputation happens. ### Signals + TypeBox Module Interaction The signals hold runtime state. The TypeBox Module defines the schema for that state. ```typescript import { Type, Static } from "@alkdev/typebox" const HudStateModule = Type.Module({ Task: Type.Object({ description: Type.String(), updatedAt: Type.Number(), }), Decision: Type.Object({ id: Type.String(), summary: Type.String(), details: Type.Optional(Type.String()), }), ContextInfo: Type.Object({ percent: Type.Number(), status: Type.Union([Type.Literal('green'), Type.Literal('yellow'), Type.Literal('red')]), model: Type.String(), }), }) // Import for validation const Task = HudStateModule.Import("Task") const Decision = HudStateModule.Import("Decision") // Validate before setting signal values function updateHUD(event: HudEvent) { batch(() => { if (event.type === 'task.set') { if (Value.Check(Task, event.task)) { hudState.task.value = event.task } } // ... }) } ``` The TypeBox Module validates incoming events. The signals hold the validated state. The `computed` components derive UElements from the state. The transform pipeline serializes to markdown. ### File Structure Addition ``` @alkdev/ujsx/ core/ schema.ts # TypeBox Module for UNode/UElement/URoot h.ts # h(), createRoot(), Fragment factory context.ts # Signal-based context (density, target) signals.ts # createHudModel() — signals for HUD state host/ config.ts # HostConfig interface (preserved) markdown.ts # Markdown host (new) transform/ registry.ts # Enhanced TransformRegistry with direction + schema rule.ts # Rule type with TypeBox schema ... ``` The signals integration is an **addition** to the TypeBox-driven rewrite, not a replacement. The TypeBox Module defines the shape. The signals hold the runtime values. The computed signals produce the rendered output.