kolu-tui — a CLI terminal client for kolu-server
A terminal-side client of kolu-server's in-process pty-host — list / attach / spawn from the shell over a unix socket, no browser and no daemon. The CLI face of kolu and the seed of a tmux replacement, shipped in beta phases.
A terminal-side client of kolu-server’s pty-host. kolu-server owns its PTYs in-process today; kolu-tui connects to that same in-process pty-host over a local unix socket (speaking ptyHostSurface) and gives you list / attach / spawn from the terminal — no browser, no daemon. It is the CLI face of kolu and the seed of a tmux/zmx replacement (
#671 ), shipped in phases as a beta.
Scoped to kolu-tui-on-the-in-process-server. The parent plan’s Phase B — moving the pty-host into a surviving daemon — is a separate plan this later dovetails with (see Later). Ancestry: child of the pty-daemon plan, sibling of the chrome-bar rail; cf. Ghostex vs. kolu remote-terminals.
Architecture — everything stays in-process
No new process. kolu-server keeps owning the PTYs exactly as it does now; it just exposes the same in-process router over an additional local socket so a second client — the CLI — can reach it. The web path is byte-identical.
The split by richness. The browser is the rich client — it speaks kolu-server’s public contract and gets the whole world (PTY plus the provider DAG: git context, agent state, PR status, session grouping). kolu-tui is the raw client — it speaks ptyHostSurface directly and gets the bare multiplexer (PTY plus the VT taps), exactly the tmux altitude. Both attach to the one in-process pty-host.
Why a second client at all
Two reasons, both standing on their own without any reference to daemons.
- It’s a feature. A persistent-while-the-server-runs terminal you drive from the shell — the CLI face of kolu, the start of the
tmuxreplacement #671 has always pointed at. - It proves the contract is consumer-agnostic. A1 (
#1055 ) asserts
ptyHostSurfaceis a clean seam — kolu-server talks to the pty-host through a contract, not throughnode-ptyinternals. The only real proof of that is a second, independent consumer. If writing a terminal client against the surface is clean, the seam is at the right altitude (the “framework needs an end-to-end demo, not just unit tests” lesson — parent plan, lesson #3). If it’s awkward, that’s a finding worth having early.
The surface — ~850 LOC in packages/pty-tui
A small node CLI in its own package, packages/pty-tui, that dials the unix socket through unixSocketLink (the local-IPC member of @kolu/surface’s link family — same base64+newline framing the daemon’s ssh stdio path uses later) and gets a typed ptyHostSurface client. No new wire, no new framing — each accepted connection is pumped through the already-tested serveOverStdio.
| Subcommand | Surface call | Behaviour |
|---|---|---|
kolu-tui list | terminal.list() | One-shot — print each live PTY’s id · pid · idle · cmd · cwd (cmd = the OSC title or the foreground command), with the full entry under --json. The pty-host’s inventory. Shipped: the entry was enriched with title + foregroundProcess (contract 2.1) so this is one round-trip, not per-row tap fetches. |
kolu-tui attach <id> | terminalAttach.get({id}) (stream) + terminal.write / terminal.resize + exit.get({id}) | Put the local tty in raw mode. First yield is the scrollback snapshot → paint to stdout; deltas stream after. Forward stdin via write; SIGWINCH → resize({id, cols, rows}). A line-start ~. escape detaches — the CLI exits, kolu-server keeps the PTY. When the deltas end, the exit stream discriminates “PTY died” (report the real code, quit with it) from “stream dropped” (re-attach; the fresh snapshot repaints). The full client-side loop — reply filter, deterministic restore, sizing — shipped with Phase 2 below. |
kolu-tui spawn [-- cmd] | terminal.spawn({id: uuid, …}) then attach | Mint a UUID client-side, spawn, immediately attach. --print-id prints the id and does not attach (for scripting). |
kolu-tui snapshot <id> | terminal.getScreenText({id}) | One-shot, non-interactive: dump the current scrollback as plain rendered text (not the terminalAttach first frame — that’s serialized VT screen state for late attach, which would replay control sequences and defeat grep) + a trailer line to stderr, then exit. The scriptable primitive the headless test asserts on. |
kolu-tui kill <id> | terminal.kill({id}) | Out-of-band termination — exercises the order-safe kill path (abort the exit tap then kill) from a non-browser client. |
Of the taps, attach consumes exit — stream-end discrimination plus the real exit code, which tombstones server-side so it’s retrievable even after the deltas end. The metadata taps (cwd / title / foreground) feed no persistent status line: that early sketch contradicted the raw-passthrough decision and was dropped (see the Phase 2 decisions below); they stay on the contract for a later richer UX. --json makes list machine-readable; --pty-host-socket <path> points at a non-default server.
Decisions recorded here (to be echoed in code comments)
| Decision | Choice | Why |
|---|---|---|
| Home | a tiny separate packages/pty-tui | An independent CLI package, not a bin folded into @kolu/pty-host — keeps the pty-host package focused on the contract + primitive, and keeps the CLI’s own deps (raw-tty handling, arg parsing) out of the closure the A2 staleKey hashes. |
| Render fidelity | raw VT passthrough | Write the pty-host’s bytes straight to stdout; do not re-render through a second @xterm/headless mirror. Simplest and truest, and it avoids a second rendering path drifting from the server’s. Recorded as a design-decision comment at the passthrough site. |
| Detach / escape | ssh-style line-start ~ escape — never Ctrl+B | A passthrough multiplexer must not steal any control char the inner tools need — Ctrl+B and Ctrl+J are reserved by Claude Code (input/prohibitedKeybinds.ts). The escape is the unambiguous ssh model: ~ recognised only immediately after a newline. ~. detach · ~~ literal tilde · ~? help; configurable via --escape (a single character). ~k kill ships with Phase 3’s kill, not Phase 2. A literal ~ mid-line passes through untouched, so every inner-app chord reaches the program unmodified. |
Phasing — three beta increments, all in-process
Phases 1–3 build kolu-tui on the in-process server — each leaves Kolu working, ships on its own, and changes nothing in the web path. They’re preceded by a Phase 0 that lives outside kolu: a surface example that rehearses the whole “interactive TUI over oRPC stdio” pattern.
| Phase | Ships | Server change | User-visible |
|---|---|---|---|
| 0 · surface example shipped #1073 | A minimal CI-runner TUI over oRPC stdio in @kolu/surface’s examples — the 3rd example, after the worker demo + the remote-process-monitor (→ drishti). | None — separate from kolu-server. | A standalone example; proves the pattern and becomes kolu-tui’s reference skeleton. |
1 · list shipped
#1084 | kolu-server serves its in-process pty-host router over a unix socket (@kolu/surface’s serveOverUnixSocket); new @kolu/pty-tui with list + read-only snapshot. list carries full metadata from the enriched terminal.list (contract 2.1). | +1 local socket listener on the existing router (servePtyHostOverUnixSocket). Web path byte-identical. | A beta CLI that lists and snapshots your live terminals — nix run …#kolu-tui. |
2 · attach shipped
#1255 | Raw-tty passthrough, snapshot-then-delta, stdin→write, SIGWINCH→resize, the ~-escape detach — plus the device-query reply filter and the deterministic terminal restore the loop needs (specced below). | None beyond Phase 1, except one nicety, shipped: terminalAttach on a bad id is a clean NOT_FOUND (one composed requirePty guard; error shape only, no contract bump). | Drive a terminal from the CLI; detach and re-attach while the server runs. |
3 · create / --host / kill | create (a plain $SHELL or a given command, --json) shipped
#1370 ; remote --host (reach + provision over ssh) shipped
#1373 ; kill later. | None for create; --host adds a kaval --stdio front that relays the ssh link to the durable daemon’s socket. | Create PTYs from the CLI, reach a remote kaval over ssh (a created terminal survives the link — create on prod, attach later), and — later — kill them: a usable raw multiplexer (beta). The remote phasing lives in kaval-sessions. |
Phase 1 alone is the smallest honest first ship of kolu-tui — a one-shot RPC, no raw-tty mode, the “hello world” that proves serveOverUnixSocket + unixSocketLink + the contract round-trip, at near-zero risk.
Phase 0 — a minimal CI-runner TUI (the 3rd surface example) shipped #1073
Before touching kolu, prove the pattern in @kolu/surface’s examples — the falsifiability test of lesson #3 applied to “interactive TUI over oRPC stdio,” the way the worker demo and the remote-process-monitor (which became drishti) validated the earlier patterns. The candidate: a minimal CI runner — justci-flavoured but self-contained, deliberately not the real justci. Just a small DAG of shell commands, runnable locally or on a single remote host.
It’s a clean structural twin of kolu-tui: a long-lived runner owns the DAG + each node’s process + its log buffer, and streams to ephemeral TUI clients over stdio. The plan’s nodes.list() / node.log(id) / node.rerun(id) map onto the framework-idiomatic spelling — a nodes cell (surface.nodes.get({})) ↔ kolu-tui’s list; a nodeLog stream (snapshot-then-delta) ↔ attach; a node.rerun procedure ↔ input. That this was clean to write against the surface primitives is the finding: the seam is at the right altitude for kolu-tui to inherit. The example is now kolu-tui’s copy-paste skeleton and a permanent regression test for the pattern.
Remote mode ships the runner the drishti way: a prebuilt mini-ci-runner nix closure is nix copy’d to the host, realised, then run as ssh host mini-ci-runner --stdio with the TUI attached over stdio-over-ssh via @kolu/surface-nix-host’s getHostSession({ host, binary, resolveDrvPath }) (which owns ref-count, reconnect, watchdog, and a copying → connecting → connected state cell). localhost skips the nix copy and runs the realised binary directly — the same HostSession, only the transport differs.
Phase 2 — the attach loop shipped #1255
Recon verdict before implementation, borne out by the ship: no contract bump, no server change — terminalAttach / write / resize / exit all exist at contract 2.1 (and the web path already consumes the same host.attach as just another subscriber on the per-PTY broadcast channel, so a CLI attach violates no exclusivity). Attach is a pure consumer; no drishti-mirror PR. The whole phase is ~300–400 LOC in packages/pty-tui plus tests. One delivery note: the reply filter first shipped as a move of the browser’s terminalResponseFilter into kolu-common; a blocking structural review then promoted the whole protocol policy — grammars + stripper, the headless forward/drop rule, the answered/silent device-query matrix, paste delimiters, and the snapshot-reciprocal TTY reset — into a dedicated zero-dep leaf, @kolu/terminal-protocol, that the browser, the pty-host, and kolu-tui all import. The pty-host’s staleKey hashes it (a protocol change is observable daemon behaviour), and its device-query matrix is executed as contract tests against a real headless.
The sequence: connect → version gate (both shipped in Phase 1) → resize-then-attach → raw mode → one-shot ↻ snapshot restored… notice → paint snapshot → pump until detach or stream end. Five decisions carry the design weight (echoed as code comments at their sites, per this note’s convention):
| Decision | Choice | Why |
|---|---|---|
| Device-query replies | filter the tty’s auto-answers out of the stdin→write path | The snapshot/deltas carry queries (DA1 · DSR/CPR · XTVERSION …) that the user’s real terminal auto-answers on stdin — but the headless mirror already answered them server-side, so forwarding the duplicate reply corrupts the inner program’s stdin (the yazi-class bug). Mirror the browser path’s suppression predicates, preserving the client-suppressed ⇒ server-answered invariant. |
| Terminal restore | one deterministic reset on every exit path | The snapshot replays modes onto the local terminal — alt-buffer (?1049h), mouse tracking, bracketed paste (?2004h), app cursor keys. Detach, PTY exit, SIGTERM/SIGHUP, and crash all run setRawMode(false) + a fixed reset string (alt-buffer off, mouse off, paste-wrap off, cursor visible). Restore is much more than un-raw-ing stdin. |
| Sizing | resize-then-attach; last-resize-wins across clients | The snapshot serializes at the server-side grid (the browser’s last size, or the 80×24 default); resizing first renders it at the local dimensions. The contract has no size-change tap, so a concurrently-attached browser tile may show wrap artifacts until its own next resize — accepted and documented; a size tap would be contract 2.2, out of Phase 2 scope. |
| Status lines | one-shot notices only — no persistent footer | A live taps-fed footer needs scroll-region ownership, which violates the raw-passthrough render-fidelity decision above (the earlier sketch self-contradicted). One line before the paint (↻ snapshot restored…), one after restore on detach; the CLI owns zero pixels while attached. |
| Stream end & retry | discriminate via the exit stream; never auto-retry | The deltas iterator ends identically on PTY exit, server-side abort, and the silent slow-consumer drop (bounded 10k queue). On end, ask exit: code present → report it, quit with it; PTY still live → re-attach, and the fresh snapshot repaints — exactly the right recovery for the drop case. Stream auto-retry would replay the snapshot mid-session, so attach opts out; if the server itself goes away the CLI restores the tty and prints an honest one-liner — manual re-dial is the only reconnect. |
The escape machine’s fine print: it runs on bytes, decoding via string_decoder only at the write boundary so multibyte characters split across stdin chunks survive; session start counts as line-start (ssh behaviour); recognition is suspended inside bracketed-paste brackets so a pasted \n~. cannot detach; in raw mode Ctrl+C arrives as byte 0x03 and is forwarded like everything else — only external SIGTERM/SIGHUP trigger the restore-and-exit path.
Testing. The escape machine, reply filter, and reset emission are pure functions — unit-tested with no tty (the render.test.ts pattern). The loop itself is factored over read/write streams plus a tty-ish interface and integration-tested against the real-socket harness (the serveOverSocket.test.ts pattern: in-process pty-host + unixSocketLink on a temp socket). The home-manager VM test keeps its list smoke — attach is infeasible headless.
The user flow
The interactive loop a user walks, then the same loop scripted as a headless test. The running example: a PTY 3f9a…c21 in ~/code/kolu that becomes a Claude Code session.
Interactive session
Is the server’s pty-host reachable? — an unreachable pty-host is a clear, immediate error, never a silent empty hang:
$ kolu-tui list
kolu-tui: no pty-host socket at /run/user/1000/kolu/pty-host.sock (ECONNREFUSED)
is kolu-server running? the socket appears once it boots.
The inventory — list from another shell, one row per live PTY (the status line reports the in-process pty-host):
Birth → work → detach → re-attach (Phase 2). spawn mints a PTY and attaches; everything passes through raw — the CLI owns no pixels while attached; a line-start ~. detaches (the server keeps the PTY); re-attach repaints the full scrollback snapshot instantly because the first yield of the attach stream is the snapshot, serialized at the local tty’s dimensions thanks to resize-then-attach.
This is client-side detach — distinct from server survival. If kolu-server itself restarts, the in-process PTY dies; that persistence is the separate daemon plan.
Headless test — client-death detach/reattach, no browser
The detach/reattach loop scripted: it asserts that the CLI client can die and a fresh one re-joins the same PTY (the server runs throughout) — fast, no browser.
#!/usr/bin/env bash
set -euo pipefail
# 1 · spawn a PTY with a unique marker in its scrollback (no attach)
id=$(kolu-tui spawn --print-id -- bash -c 'echo "MARK-$$"; exec sleep 1d')
pid_before=$(kolu-tui list --json | jq -r ".[] | select(.id==\"$id\").pid")
# 2 · (no kolu-tui process is attached — simulating client death)
# 3 · a fresh client re-attaches by id and asserts the state is intact
kolu-tui snapshot "$id" | grep -q "MARK-" # scrollback preserved
pid_after=$(kolu-tui list --json | jq -r ".[] | select(.id==\"$id\").pid")
[ "$pid_before" = "$pid_after" ] # same PTY, not a respawn
echo "✓ client came and went; server held the PTY (pid $pid_after unchanged)"
This is the kolu-tui-scoped reattach test — the client can come and go while the server holds the PTY. (Surviving a server restart is a different assertion that belongs to the daemon plan, not here.)
Later — how this dovetails with the daemon plan (out of scope)
Stated only so we don’t trip over it: when the parent plan’s Phase B eventually moves the pty-host out of kolu-server into a surviving kolu --stdio daemon, kolu-tui’s socket target shifts from kolu-server to the daemon — with no contract change, because both serve the same ptyHostSurface. At that point the CLI’s terminals gain server-restart survival for free, and the raw-vs-rich client split is unchanged. But that is a separate plan; nothing in kolu-tui’s phases above depends on it, and kolu-tui ships and is useful entirely on the in-process server.