3 min read tuitypescriptagents

A terminal UI with SolidJS signals and no React

Streaming agent output is a fine-grained reactivity problem. Why efferent's TUI is OpenTUI + Solid signals instead of Ink, and what the cost model looks like.

On this page

An agent TUI has an unusual rendering profile: long stretches of nothing, then a burst of hundreds of tiny updates per second — token counters ticking, a spinner, new conversation lines, a context gauge — each touching a few terminal cells. The natural way to say “this number changed” should cost about as much as changing the number.

That profile is why efferent’s TUI is OpenTUI + SolidJS signals, and why it isn’t Ink.

The cost model is the argument

Ink is React for the terminal, and it inherits React’s update semantics: state changes schedule a re-render, components re-execute, the reconciler diffs element trees, and the renderer works out what changed. For a settings form, fine. For a stream pushing thirty token-count updates a second into a UI that also has a conversation pane, a file list, and a status bar, you’re paying tree-diff prices for cell-sized changes — at streaming frequency, with the GC participating.

Solid has no virtual anything. createSignal wires a dependency from a value to the exact JSX expressions that read it. When the value changes, those expressions re-run. Nothing else does:

const [tokensOut, setTokensOut] = createSignal(0)
const [contextUsed, setContextUsed] = createSignal(0)

export const Activity = () => (
  <box border title="activity">
    <text>context  {fmtTokens(contextUsed())} / 1M</text>
    <text>tok out  {fmtTokens(tokensOut())}</text>
  </box>
)

When setTokensOut fires, the one text node showing the count updates. OpenTUI’s native renderer (loaded over FFI) keeps a retained scene graph and repaints damaged regions — Solid’s fine-grained graph tells it precisely which region that is. The “diff” step doesn’t get cheaper; it gets deleted.

Effect on one side, signals on the other

The agent loop is Effect all the way down, so the seam between the two worlds is one adapter: fibers push, signals receive.

const wireTokens = (events: Stream.Stream<ModelEvent, ModelError>) =>
  events.pipe(
    Stream.filter((e) => e._tag === 'TokenDelta'),
    Stream.runForEach((e) =>
      Effect.sync(() => setTokensOut((n) => n + e.count)),
    ),
  )

Streams stay typed and interruptible on the Effect side (Esc cancels the fiber, not the UI); rendering stays synchronous and surgical on the Solid side. Neither framework leaks into the other’s half — core has no idea a terminal exists.

It also composes upward: assistant prose renders as markdown and code blocks come back syntax-highlighted through tree-sitter, all as OpenTUI components sitting in the same signal graph — not a hand-rolled ANSI escape pass over strings.

”No React” is a cost model, not a mood

The README says no Electron, no React, no Ink, and it’s worth being precise about why, because it isn’t aesthetics. React’s reconciliation is a brilliant amortization strategy for DOM trees mutated by unpredictable handlers. A terminal streaming pipeline is the opposite shape: updates are predictable, tiny, and constant. Paying reconciliation there is buying insurance against a risk you don’t have.

Choose the rendering model whose cost curve matches your update pattern. For an agent’s terminal — thousands of small, known mutations — that’s signals into a retained native renderer. If that conclusion generalizes past terminals, well. The blog you’re reading ships zero framework JavaScript.