Part VI: Three Modes, One Machine
The trigger for the entire refactoring was wanting to add two more interaction modes. The old code could barely handle one. The new architecture handles three — and adding a fourth would take an afternoon.
Why Three Modes?
Each mode solves a different problem:
Panels (default) — For discovery. When you don't remember what commands exist. Press a group key (s, b, t), see all options, pick one:
[s] Serve [b] Build [t] Test [a] Audit [g] Git [o] Other
> t
┌─ test ────────────────────────────────────────────────┐
│ [u] Unit [e] E2E [v] Visual │
│ [a] A11y [p] Perf [*] ALL │
│ lowercase = STATIC UPPERCASE = DEV [q] Back │
└───────────────────────────────────────────────────────┘ [s] Serve [b] Build [t] Test [a] Audit [g] Git [o] Other
> t
┌─ test ────────────────────────────────────────────────┐
│ [u] Unit [e] E2E [v] Visual │
│ [a] A11y [p] Perf [*] ALL │
│ lowercase = STATIC UPPERCASE = DEV [q] Back │
└───────────────────────────────────────────────────────┘Keys (vim-style) — For speed. Everything visible, 2 keystrokes max. No navigation needed. You already know the shortcuts:
SERVE BUILD GIT
sd Start DEV bt Build TOC gc Commit + push
ss Start STATIC bs Build static gC Commit only
sb Start both bj Build JS gp Push
... SERVE BUILD GIT
sd Start DEV bt Build TOC gc Commit + push
ss Start STATIC bs Build static gC Commit only
sb Start both bj Build JS gp Push
...Command (power user) — For complex invocations. Free-form input. Type full commands with flags, or use 2-letter shortcuts:
> test e2e --target=dev --grep="navigation"
> te (shortcut: test e2e static)
> build static --no-mermaid --no-images > test e2e --target=dev --grep="navigation"
> te (shortcut: test e2e static)
> build static --no-mermaid --no-imagesOne Machine Drives All Three
The tui-machine manages phases, not modes. The mode is stored in the state but the transitions don't depend on it:
type TuiState =
| { phase: 'idle'; tuiMode: TuiMode }
| { phase: 'panel'; tuiMode: TuiMode; panel: string }
| { phase: 'running'; cmd: string }
| { phase: 'postRun'; cmd: string; exitCode: number; durationMs: number }
| { phase: 'input'; prompt: string; then: (value: string) => void };type TuiState =
| { phase: 'idle'; tuiMode: TuiMode }
| { phase: 'panel'; tuiMode: TuiMode; panel: string }
| { phase: 'running'; cmd: string }
| { phase: 'postRun'; cmd: string; exitCode: number; durationMs: number }
| { phase: 'input'; prompt: string; then: (value: string) => void };When the machine transitions to idle, it calls callbacks.renderMenu(tuiMode). The callback chooses the right renderer based on the mode:
renderMenu: (mode) => {
switch (mode) {
case 'panels': renderPanelsMenu(io); break;
case 'keys': renderKeysMenu(io); break;
case 'command': renderCommandMenu(io); break;
}
},renderMenu: (mode) => {
switch (mode) {
case 'panels': renderPanelsMenu(io); break;
case 'keys': renderKeysMenu(io); break;
case 'command': renderCommandMenu(io); break;
}
},The machine doesn't know which renderer runs. It just says "render the menu for this mode." The renderers are pure functions of (ConsoleIO) → terminal output.
Renderers Are Pure Functions
No state. No side effects beyond writing to ConsoleIO. No classes:
// renderers/mode-panels.ts
export function renderPanelsMenu(io: ConsoleIO): void {
const groups = Object.entries(PANEL_KEYS)
.map(([key, label]) => keyLabel(io, key, label))
.join(' ');
io.writeLine(` ${groups}`);
io.writeLine(` ${keyLabel(io, 'm', 'Switch mode')} ${keyLabel(io, 'q', 'Exit')}`);
io.write(' > ');
}// renderers/mode-panels.ts
export function renderPanelsMenu(io: ConsoleIO): void {
const groups = Object.entries(PANEL_KEYS)
.map(([key, label]) => keyLabel(io, key, label))
.join(' ');
io.writeLine(` ${groups}`);
io.writeLine(` ${keyLabel(io, 'm', 'Switch mode')} ${keyLabel(io, 'q', 'Exit')}`);
io.write(' > ');
}Want a fourth mode? Write a renderFooMenu(io: ConsoleIO) function and add a case to the switch. The machine doesn't change. The other renderers don't change. The tests for those renderers don't change.
Mode Switching
Pressing m sends a switchMode event to the TUI machine:
function switchMode(): void {
if (state.phase !== 'idle' && state.phase !== 'panel') return; // guard
const nextMode = MODE_ORDER[(idx + 1) % MODE_ORDER.length];
callbacks.saveMode(nextMode); // persist to .workrc.json
transition({ phase: 'idle', tuiMode: nextMode });
callbacks.renderDashboard();
callbacks.renderMenu(nextMode);
}function switchMode(): void {
if (state.phase !== 'idle' && state.phase !== 'panel') return; // guard
const nextMode = MODE_ORDER[(idx + 1) % MODE_ORDER.length];
callbacks.saveMode(nextMode); // persist to .workrc.json
transition({ phase: 'idle', tuiMode: nextMode });
callbacks.renderDashboard();
callbacks.renderMenu(nextMode);
}Guard: mode switching is blocked during running and postRun. You can't switch modes while a command is executing. The machine enforces this — the readline handler doesn't need to check.
Serve Commands Run In-Process
Commands like build toc or test unit spawn a subprocess (npx tsx scripts/workflow.ts build toc). But serve commands need to stay in the TUI process — the server machines must be alive for the dashboard to show their status.
The menu loop checks for serve commands before falling back to subprocess:
executeCommand: async (cmd) => {
const inProc = await executeInProcess(io, cmd, config);
const result = inProc ?? executeCommandSync(cmd);
tui.cmdComplete(result.exitCode, result.durationMs);
},executeCommand: async (cmd) => {
const inProc = await executeInProcess(io, cmd, config);
const result = inProc ?? executeCommandSync(cmd);
tui.cmdComplete(result.exitCode, result.durationMs);
},executeInProcess handles serve dev, serve static, serve both, serve stop, and serve status by directly calling getOrCreateMachine() — the same function the CLI mode uses. The server machines are shared between TUI and CLI.
The Dashboard
The dashboard header is rendered by a pure function that reads server machine state:
export function renderDashboard(io: ConsoleIO, servers: Map<string, ServerMachine>): void {
function fmtServer(label: string, url: string | undefined): string {
if (url) return `${label} ${io.green(url)} ${io.dot(true)}`;
return `${label} ${io.dim('───────────────')} ${io.dot(false)}`;
}
const lines = [
`${io.bold('work')} serard.dev`,
`${fmtServer('DEV', devUrl)} ${fmtServer('STATIC', statUrl)}`,
];
io.writeLine(box(io, '', lines));
}export function renderDashboard(io: ConsoleIO, servers: Map<string, ServerMachine>): void {
function fmtServer(label: string, url: string | undefined): string {
if (url) return `${label} ${io.green(url)} ${io.dot(true)}`;
return `${label} ${io.dim('───────────────')} ${io.dot(false)}`;
}
const lines = [
`${io.bold('work')} serard.dev`,
`${fmtServer('DEV', devUrl)} ${fmtServer('STATIC', statUrl)}`,
];
io.writeLine(box(io, '', lines));
}After serve dev, the dashboard updates:
┌────────────────────────────────────────────────────────────────┐
│ work serard.dev │
│ DEV http://localhost:4001 ● STATIC ─────────────── ○ │
└────────────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────────────────────┐
│ work serard.dev │
│ DEV http://localhost:4001 ● STATIC ─────────────── ○ │
└────────────────────────────────────────────────────────────────┘Real state. Not parsed from subprocess output. The machine's getUrl() returns the URL or null. The renderer formats it.
Post-Run Footer
After a command completes, the machine transitions to postRun and renders the result:
✓ build static 14.2s
[Enter] Back to menu ✓ build static 14.2s
[Enter] Back to menuPressing Enter sends dismiss() to the machine, which transitions back to idle and re-renders the menu.
The Pattern
The entire TUI is:
- State machines manage transitions and enforce guards
- Renderers are pure functions of
(state, ConsoleIO) → output - The menu loop reads input, maps it to machine events, and that's it
No renderer knows about the machine's internals. No machine knows about ANSI codes. No readline handler manages state. Each layer does one thing.
This is the same separation the frontend uses: state machines in src/lib/ manage logic, DOM adapters in src/app-*.ts handle rendering. Same pattern. Different domain.
What We Built
| Component | Files | Lines (each) | Testable? |
|---|---|---|---|
| State machines | 4 | 60-120 | Yes — mock callbacks |
| ConsoleIO | 1 | 130 | Yes — test implementation |
| Renderers | 4 | 30-90 | Yes — test ConsoleIO captures output |
| Commands | 11 | 20-120 | Yes — pure functions + mock ConsoleIO |
| Menu loop | 1 | 200 | Integration (readline) |
| Entry point | 1 | 30 | N/A |
24 source files. 15 test files. 81 tests. 600ms. Zero globals.
From a 1270-line monolith with 6 mutable variables and no tests, to a state machine-driven architecture where every layer is independently testable and the entire TUI is driven by one machine that doesn't know what mode it's rendering.
The same pattern. The 29th state machine in the project.
Back to series index.