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 existingChatLoopwith its IO mapped onto JSON Lines (ChatProtocol);LineRouterdemultiplexes the front end’s messages and approval answers;JSONApproverasks through the protocol under the same timeout as MCP elicitation. Tested without the model.tools/wisp-tui: a ratatui program that spawnswisp chat --json, inserts finished lines into scrollback withinsert_before, and draws a four-row band: reply in progress, approval dialog, input, status. Keys: type and Enter to send,y s p a nto answer a dialog, Ctrl-C or Ctrl-D to cancel a dialog or quit. Tested with ratatui’sTestBackend.
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 --jsonneeds 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
scriptor 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
- Does the inline viewport with
insert_beforekeep scrollback intact in Terminal.app, iTerm, and tmux, including copy and selection of earlier lines? - Does streaming feel right: the partial line in the band, whole lines moving up as they complete?
- Does a resize mid-turn leave the band consistent?
- Does the approval dialog in the band round-trip inside wisp’s timeout, and is the dialog readable with the reasons committed above it?
- 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.