Skip to content
victor·stein
0%
booting ~/
./blog / terminal / building-tawtui
terminalopentuisolidjs

I kept dropping tasks as a tech lead, so I built a TUI

VS Victor Stein · Jun 02, 2026 · 5 min read

I’ve used Taskwarrior for years. I love it — but on its own it was never quite a daily driver for me. It tracked tasks; it didn’t track my job.

The breaking point came on a high-stakes project where I was leading a team of 10–14 devs. Too many input lanes. Standups, design syncs, one-off “got a sec?” pings, and a review queue that never emptied. A flat todo list helped, but the real cost wasn’t having the list — it was context-switching between a meeting and a code review and back, all day, without dropping anything on the floor.

I started dropping things on the floor. A task would slip between two meetings and I wouldn’t notice until someone had been blocked for hours waiting on a reply I thought I’d already sent. That’s the failure mode that actually hurts as a lead: not your own throughput, but the people stalled behind you.

So I built tawtui — Taskwarrior’s model, wrapped around the specific shape of being a tech lead. It tracks my meetings and my pending tasks, and it lets me hand a PR to an agent to review while I digest the rest of the queue, then come back and mark it off. One surface, never leaves the keyboard.

Not Ink this time (and other course-corrections)

If you read about terminal UIs in TypeScript, every article points you at Ink — and I’ve shipped with it before, on lumentui. But for tawtui I went a different way. It’s built on OpenTUI, and I started on React, then migrated the whole frontend to Solid. Solid’s fine-grained reactivity is a much better fit for a terminal — when one cell of state changes, I want exactly the one <text> that depends on it to re-render, not a reconciler walking a tree.

The other thing that shaped the UX: I’m a heavy sidecar user, and the thing I fell in love with there was its modals. So I stole the idea — tawtui leans on modal overlays for setup wizards and for creating tasks and their details. It made the whole thing feel like an app instead of a form. That decision stuck.

A terminal inside a terminal (lol)

The feature I’m proudest of is the one that sounds the most absurd: a terminal inside the terminal.

The idea, again, came from how smooth sidecar felt. I wanted to embed a live terminal pane to digest PRs and review feedback without leaving the app. When I pick a PR, tawtui:

  1. Prefills context — the PR description and existing comments get pulled in automatically.
  2. Runs a two-step prompt — that context is fed to the agent, which works on the change in a git worktree so it never disturbs my real checkout.

The agent does its pass in isolation; I watch its terminal output live in the right pane, and I make the call. It’s a human-in-the-loop review where the boring setup — branch, context, prompt — is already done by the time I’m looking at it.

How it actually renders

Under the hood it’s a three-layer pipeline: Solid.js (reactivity) → OpenTUI (renderables + layout) → the terminal (ANSI escape codes). The JSX isn’t HTML — <box> and <text> compile through @opentui/solid into OpenTUI renderable objects, get laid out by Yoga (the same flexbox engine React Native uses), and get painted to the terminal.

It boots from a single render call:

// src/modules/tui.service.ts
await render(App, {
  useAlternateScreen: true,
  useMouse: true,
  exitOnCtrlC: false,
});

The JSX wiring lives in tsconfig.json"jsx": "preserve" plus "jsxImportSource": "@opentui/solid" — so <box>/<text> resolve to OpenTUI’s BoxRenderable/TextRenderable instead of DOM nodes.

The agents screen is a two-pane flexbox split:

// views/agents-view.tsx
<box flexDirection="column" flexGrow={1}>
  <Show when={error()}><text fg={COLOR_ERROR}>{error()}</text></Show>
  <box flexDirection="row" flexGrow={1}>
    <AgentList ... />       {/* left pane */}
    <TerminalOutput ... />  {/* right pane */}
  </box>
</box>

flexDirection, flexGrow, width, padding all feed Yoga; fg/bg/attributes are terminal styling.

The left pane (AgentList) is a bordered box with a gradient header — each character is colored by interpolating with a little lerpHex() — and a <scrollbox> of agents rendered through <For>. One detail that cost me real time: I have to wrap the list in <Show when={agents.length > 0}> so the scrollbox fully unmounts when it’s empty. Without that, I kept hitting an OpenTUI stale-node bug where the emptied scrollbox held onto dead nodes. Unmount it entirely and the bug goes away — not elegant, but honest.

The right pane (TerminalOutput) is the fun one. It displays raw tmux pane output, which is full of ANSI escape codes, so it’s effectively a mini terminal-emulator inside the terminal. A createMemo runs parseAnsiTextCached() to turn the raw string into ParsedLine[] — segments of { text, fg, bg, attrs } — and then renders them:

const parsedLines = createMemo(() => parseAnsiTextCached(capture().text));

<For each={parsedLines()}>{(line) =>
  <box flexDirection="row">
    <For each={line}>{(seg) =>
      <text fg={seg.fg} bg={seg.bg} attributes={seg.attrs}>{seg.text}</text>
    }</For>
  </box>
}</For>

Parse ANSI → re-style it as OpenTUI nodes → let OpenTUI paint it back out as ANSI. A terminal, rendering a terminal, rendering a terminal.

Reactivity, and one hack I’m not proud of

State is just Solid signals — agents, agentIndex, capture, interactive. The data flows in through a polling loop that adapts its interval: 80ms while I’m actively driving an agent, backing off to 2000ms when things are idle. No queue, no Redis — for a local tool, a polling loop you can reason about beats infrastructure you have to babysit.

// 80ms interactive → 2000ms idle
TerminalService.captureOutput(id)   // → CaptureResult
  // → setCapture(result)
  // → createMemo re-parses → <For> diffs lines → <text> updates

createEffects track agents() / agentIndex() / dimensions() to refresh the capture and resize the tmux pane whenever the selection or the terminal size changes. Because Solid is fine-grained, only the branches that actually depend on the changed signal re-run — the rest of the tree sits still. In a terminal, where every repaint is visible, that matters.

Now the hack. The TUI needs to reach backend services — tmux, GitHub, and friends — which live in a NestJS layer. The Solid frontend and the Nest backend run in the same process but aren’t DI-connected, so I bridge them through a global:

// bridge.ts
globalThis.__tawtui = { /* tmux, github, ...NestJS services */ };

Is a globalThis bridge between a frontend and a Nest container a thing I’d put in a production API? Absolutely not. Is it the right call for a single-process local tool where wiring up real DI across the boundary would buy me nothing? Yeah. I’ll defend it.

Where it is today

tawtui is real and in use — it’s running on a couple of my teammates’ machines, I shared it with the team and they took to it. It’s still early and there’s a lot of road ahead, but it’s the thing I open every morning.

What I’m building right now: hunk-level diffs in the review flow, so the human-in-the-loop step is reviewing actual diffs instead of plain text. That’s the next post.

terminalopentuisolidjstypescripttuinestjs ← all posts
NEXT UP →
I'd become a single point of failure