372 lines
14 KiB
Markdown
372 lines
14 KiB
Markdown
# 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<Array<{ id: string; summary: string }>>([]),
|
|
notes: signal<Array<{ id: string; content: string }>>([]),
|
|
nextSteps: signal<Array<{ id: string; description: string; completed: boolean }>>([]),
|
|
activeFiles: signal<Array<{ path: string; status: string }>>([]),
|
|
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. |