Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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    │
  └───────────────────────────────────────────────────────┘

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
   ...

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

One 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 };

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;
  }
},

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('  > ');
}

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);
}

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);
},

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));
}

After serve dev, the dashboard updates:

  ┌────────────────────────────────────────────────────────────────┐
  │  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

Pressing Enter sends dismiss() to the machine, which transitions back to idle and re-renders the menu.

The Pattern

The entire TUI is:

  1. State machines manage transitions and enforce guards
  2. Renderers are pure functions of (state, ConsoleIO) → output
  3. 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.