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:
- Prefills context — the PR description and existing comments get pulled in automatically.
- 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.