Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spike: a ratatui front end for wisp chat

Date: 2026-09-22. Status: accepted and merged the same day; the decision is ADR 0029. Kept as the record of what was tried.

Why

wisp chat prints a status line above every prompt because it lives inside the terminal’s line discipline. Claude Code and Codex pin input and status at the bottom and let the conversation scroll above, inline, keeping scrollback. Five ways to get that were compared (ANSI scroll region, libedit, SwiftTUI, ncurses, ratatui); ratatui by process split was chosen to spike because its inline viewport does exactly this, it has a test backend, and the split yields a headless protocol other front ends can use.

What was built

  • wisp chat --json: the existing ChatLoop with its IO mapped onto JSON Lines (ChatProtocol); LineRouter demultiplexes the front end’s messages and approval answers; JSONApprover asks through the protocol under the same timeout as MCP elicitation. Tested without the model.
  • tools/wisp-tui: a ratatui program that spawns wisp chat --json, inserts finished lines into scrollback with insert_before, and draws a four-row band: reply in progress, approval dialog, input, status. Keys: type and Enter to send, y s p a n to answer a dialog, Ctrl-C or Ctrl-D to cancel a dialog or quit. Tested with ratatui’s TestBackend.

Answered on this Mac

  • The protocol round-trips with the real model over a pipe: streamed deltas, every audit event, the status with context use, and an approval answered by id. wisp chat --json needs no terminal.
  • Both halves pass the gate: strict Swift lint and tests, pedantic clippy with warnings as errors, Rust tests including a rendered band.
  • The TUI cannot be driven under script or any fake pty: ratatui’s inline viewport asks the terminal for the cursor position at start and a fake never answers, so the remaining questions need a real terminal.

Styling

wisp’s palette, defined on 2026-09-22 and used by both faces: one ghostly pale blue in four tones (glow #CFF1FF for the prompt, wisp #8FD3F4 for status facts and ok states, mist #86AEC8 for tool lines, notes, and separators, deep #253B4E for the input box’s background; a first, greener and darker set was rejected on sight the same day), amber #F2B950 for attention (approvals, moderate, context past 80%), ember #FF6B6B for danger and errors, and white #E6EDF0 for the conversation, bold for the user’s own words. The band and every committed line keep a one-cell margin on each side; the input is a row tinted edge to edge with its text inset like the rest, and a half-height strip of the tint above and below it (rows of ▄ and ▀ glyphs, since a terminal cannot tint less than a row), and a placeholder, “Ask wisp to do anything”, when empty (a two-cell margin and a three-row box were judged too much on sight).

Open, for a real terminal

  1. Does the inline viewport with insert_before keep scrollback intact in Terminal.app, iTerm, and tmux, including copy and selection of earlier lines?
  2. Does streaming feel right: the partial line in the band, whole lines moving up as they complete?
  3. Does a resize mid-turn leave the band consistent?
  4. Does the approval dialog in the band round-trip inside wisp’s timeout, and is the dialog readable with the reasons committed above it?
  5. Is the delta latency over the pipe noticeable against the plain chat?

How to try it

cd harness && swift build -c release
cd ../tools && cargo build
WISP_BIN=../harness/.build/release/wisp target/debug/wisp-tui --model ollama:qwen3-coder

If it stays

Decide the protocol’s shape (a turn event instead of relying on status; whether event should be pre-rendered lines or raw events, currently raw), package two binaries in the formula with wisp chat handing off to the TUI when it is installed, add line editing and history, and record the decision as an ADR that amends the chat’s place in docs/design.md.