Llevo años usando Taskwarrior. Me encanta — pero por sí solo nunca terminó de ser mi herramienta de cabecera. Llevaba el control de tareas; no llevaba el control de mi trabajo.
El punto de quiebre llegó en un proyecto de alto riesgo donde lideraba un equipo de 10 a 14 devs. Demasiados frentes de entrada. Dailies, design syncs, pings sueltos de “¿tenés un segundo?”, y una cola de revisión que nunca se vaciaba. Una lista de tareas plana ayudaba, pero el costo real no era tener la lista — era cambiar de contexto entre una reunión y un code review y de vuelta, todo el día, sin que se me cayera nada.
Empecé a que se me cayeran cosas. Una tarea se colaba entre dos reuniones y no me daba cuenta hasta que alguien llevaba horas bloqueado esperando una respuesta que yo creía haber mandado. Ese es el modo de falla que de verdad duele como líder: no tu propio rendimiento, sino la gente estancada detrás de vos.
Así que construí tawtui — el modelo de Taskwarrior, envuelto alrededor de la forma específica de ser tech lead. Lleva el control de mis reuniones y mis tareas pendientes, y me deja pasarle un PR a un agente para que lo revise mientras yo digiero el resto de la cola, y después volver y marcarlo. Una sola superficie, sin soltar nunca el teclado.
No fue Ink esta vez (y otras correcciones de rumbo)
Si leés sobre interfaces de terminal en TypeScript, todos los artículos te mandan a Ink — y ya he trabajado con él antes, en lumentui. Pero para tawtui agarré otro camino. Está construido sobre OpenTUI, y empecé con React, para después migrar todo el frontend a Solid. La reactividad de grano fino de Solid encaja mucho mejor con una terminal — cuando cambia una celda de estado, quiero que se vuelva a renderizar exactamente el <text> que depende de ella, no un reconciler recorriendo un árbol entero.
Lo otro que moldeó la UX: soy un usuario intensivo de sidecar, y lo que me enamoró ahí fueron sus modales. Así que me robé la idea — tawtui se apoya en modales superpuestos para los asistentes de configuración y para crear tareas con sus detalles. Hizo que todo se sintiera como una app en vez de un formulario. Esa decisión se quedó.
Una terminal dentro de una terminal (lol)
La función de la que estoy más orgulloso es la que suena más absurda: una terminal dentro de la terminal.
La idea, otra vez, vino de lo fluido que se sentía sidecar. Quería embeber un panel de terminal en vivo para digerir PRs y feedback de revisión sin salir de la app. Cuando elijo un PR, tawtui:
- Precarga el contexto — la descripción del PR y los comentarios existentes se traen automáticamente.
- Corre un prompt de dos pasos — ese contexto se le pasa al agente, que trabaja sobre el cambio en un git worktree para no tocar nunca mi checkout real.
El agente hace su pasada en aislamiento; yo veo su salida de terminal en vivo en el panel derecho, y tomo la decisión. Es una revisión con humano en el bucle donde la parte aburrida — branch, contexto, prompt — ya está hecha para cuando me pongo a mirar.
Cómo se renderiza en realidad
Por debajo es un pipeline de tres capas: Solid.js (reactividad) → OpenTUI (renderables + layout) → la terminal (códigos de escape ANSI). El JSX no es HTML — <box> y <text> compilan a través de @opentui/solid hacia objetos renderables de OpenTUI, los acomoda Yoga (el mismo motor de flexbox que usa React Native) y se pintan en la terminal.
Arranca desde una sola llamada a render:
// src/modules/tui.service.ts
await render(App, {
useAlternateScreen: true,
useMouse: true,
exitOnCtrlC: false,
});
El cableado del JSX vive en tsconfig.json — "jsx": "preserve" más "jsxImportSource": "@opentui/solid" — para que <box>/<text> resuelvan a los BoxRenderable/TextRenderable de OpenTUI en vez de nodos del DOM.
La pantalla de agentes es un split de dos paneles con flexbox:
// 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 ... /> {/* panel izquierdo */}
<TerminalOutput ... /> {/* panel derecho */}
</box>
</box>
flexDirection, flexGrow, width, padding alimentan a Yoga; fg/bg/attributes son el estilo de la terminal.
El panel izquierdo (AgentList) es una caja con borde, un header con degradado — cada carácter coloreado interpolando con un pequeño lerpHex() — y un <scrollbox> de agentes renderizado con <For>. Un detalle que me costó tiempo de verdad: tengo que envolver la lista en <Show when={agents.length > 0}> para que el scrollbox se desmonte por completo cuando está vacío. Sin eso, me topaba una y otra vez con un bug de nodos viejos de OpenTUI, donde el scrollbox vaciado se aferraba a nodos muertos. Desmontalo del todo y el bug desaparece — no es elegante, pero es honesto.
El panel derecho (TerminalOutput) es el divertido. Muestra la salida cruda de un panel de tmux, que está llena de códigos de escape ANSI, así que es básicamente un mini emulador de terminal dentro de la terminal. Un createMemo corre parseAnsiTextCached() para convertir la cadena cruda en ParsedLine[] — segmentos de { text, fg, bg, attrs } — y luego los renderiza:
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>
Parsear ANSI → re-estilizarlo como nodos de OpenTUI → dejar que OpenTUI lo pinte de vuelta como ANSI. Una terminal, renderizando una terminal, renderizando una terminal.
Reactividad, y un hack del que no estoy orgulloso
El estado son apenas señales de Solid — agents, agentIndex, capture, interactive. Los datos entran por un loop de polling que adapta su intervalo: 80ms mientras estoy manejando un agente activamente, relajándose a 2000ms cuando todo está ocioso. Sin cola, sin Redis — para una herramienta local, un loop de polling que podés razonar le gana a infraestructura que tenés que andar cuidando.
// 80ms interactivo → 2000ms ocioso
TerminalService.captureOutput(id) // → CaptureResult
// → setCapture(result)
// → createMemo re-parsea → <For> difea las líneas → <text> se actualiza
Unos createEffect siguen a agents() / agentIndex() / dimensions() para refrescar la captura y redimensionar el panel de tmux cada vez que cambia la selección o el tamaño de la terminal. Como Solid es de grano fino, solo se vuelven a correr las ramas que de verdad dependen de la señal que cambió — el resto del árbol se queda quieto. En una terminal, donde cada repintado se ve, eso importa.
Ahora el hack. La TUI necesita llegar a servicios de backend — tmux, GitHub y compañía — que viven en una capa de NestJS. El frontend de Solid y el backend de Nest corren en el mismo proceso pero no están conectados por DI, así que los puenteo con un global:
// bridge.ts
globalThis.__tawtui = { /* tmux, github, ...servicios de NestJS */ };
¿Un puente con globalThis entre un frontend y un contenedor de Nest es algo que pondría en una API de producción? Para nada. ¿Es la decisión correcta para una herramienta local de un solo proceso, donde cablear DI real a través de la frontera no me daría nada? Sí. La defiendo.
Dónde está hoy
tawtui es real y está en uso — está corriendo en las máquinas de un par de compañeros, lo compartí con el equipo y se engancharon. Todavía está temprano y le queda mucho camino, pero es lo que abro cada mañana.
Lo que estoy construyendo ahora mismo: diffs a nivel de hunk en el flujo de revisión, para que el paso con humano en el bucle revise diffs de verdad en vez de texto plano. De eso va el próximo post.