Skip to main content

GAIA Terminal UI — Developer Manual

For working on the TUI, or driving it from a test or an assistant. Using it day to day is the user manual.

What it is made of

Three processes, and knowing which one you are looking at saves most of the debugging time:
The readiness gate straddles all three: the GAIA agent and Lemonade rows are checked by the Go side; the Language model row shells out to gaia init --check, which is Python and talks to Lemonade.
The agent subprocess is spawned once and outlives your edits. Change Python source and the running TUI keeps executing the old code — a measurement taken without restarting is a measurement of the previous build. Restart the TUI after touching anything under src/gaia/ or hub/agents/. This is the single easiest way to waste an hour.

Building

Needs git, Go, and a Python env with gaia installed.
Other targets: test, test-race, lint, vet, cover, mock-agent, dev (build + mock-agent), clean. For a dev run against your checkout:
--dev shows per-turn timings, steps, and tool arguments on screen; --trace=<path> writes the same events to JSONL instead. They are independent.

Point it at your checkout, not the published sidecar

mode: user runs the published frozen sidecar, which is routinely older than source. When testing an agent from a checkout, set the matching mode on the TUI launch so its ensure request agrees:
Starting a dev sidecar separately while the TUI still defaults to user creates a mode conflict. GET /daemon/v1/agents shows the api_version actually in use.

Switching models on the relayed route

On the subprocess route /model <id> is a turn: the agent’s stdio loop intercepts it and confirms with a model-state ping. The relay has no such turn, so /model, /provider and the startup restore go through client.TurnModelSwitcher instead: the TUI checks the flagship’s contract (2.14 or newer moves a retained session in place), checks that Lemonade lists the id, then sends it as model on every following /query. The first final on it saves the choice; a 409 could not switch session puts the previous model back (client.ModelSwitchRefusedError).

Know which branch you built

Stacked branches are siblings; no single branch has everything. A leaf build shows behaviour already fixed elsewhere — that has produced bug reports for things fixed on another branch. Build from a merged integration branch, or state explicitly which slice you tested. The header’s agent gaia v<x> and the welcome line’s GAIA core <y> come from the running agent only: the agent_version and gaia_version fields of its model-state status ping (stdio), or the peer’s own version (HTTP). Never from ~/.gaia/agents/<id>/.installed. No version on screen means the agent that answered predates those fields, which is itself the answer to “which build?”.

Patched dependencies

tui/third_party/ holds patched copies of upstream modules, wired in with replace in tui/go.mod; its README.md lists each patch and why. Bubble Tea is one of them: v1.3.10’s Windows console reader drops the Ctrl state on Backspace and Delete, so the copy reports Ctrl+Backspace as ctrl+h and adds a ctrl+delete key (also mapped from ESC[3;5~ on VT terminals). Upgrading Bubble Tea means re-applying the lines marked GAIA patch, or dropping the copy once upstream has the fix.

Status and error lines

A status or error line is one sentence on what is wrong plus the one next step. Daemon errors implement daemon.Briefer; render daemon.Brief(err) on the line and keep err.Error() (route, alternatives, log path) for a d details pane. In --dev, harness lines the user view hides (Step 4/50) show as they are, with no [harness] tag.

The control API

Off by default. --control auto-assigns a port; --control-port N pins one. It binds 127.0.0.1 only and is bearer-token authenticated. The token is written to $GAIA_TUI_HOME/control.json (mode 0600), defaulting to ~/.gaia/tui/control.json. Set GAIA_TUI_HOME to keep parallel sessions from colliding.

Endpoints

All under /control/v1/.
keys takes an array of strings, not a string. {"keys": "p"} is rejected with cannot unmarshal string into Go struct field keysRequest.keys of type []string. Named keys go in the same array: ["down"], ["enter"], ["esc"], ["ctrl+d"].
text types into the composer but does not submit. Follow it with {"keys":["enter"]}.

Wait, never sleep

POST /control/v1/wait blocks server-side until a condition holds, with timeout_ms:
A turn takes anywhere from 30s to several minutes depending on the model and the tools it reaches for. A fixed sleep is either a wasted minute or a half-rendered capture.
{"state": {"streaming": false}} can match the instant before a turn starts, because it was not streaming yet either. Submit, confirm streaming went true, and only then wait for it to go false — or poll status until it reports false.

State values

view is splash · preflight · chat · unknown. There is no hub screen: the TUI boots into one agent’s chat behind the readiness gate. On preflight, blocker names the row refusing the launch: binary, lemonade, model, daemon, sidecar, mailbox. Wait on {"state": {"view": "chat"}} rather than a screen substring. A daemon or sidecar row that is simply not running is started by the gate itself, once per launch (Row.AutoFix in internal/ui/preflight). While that runs the row is [..] starting… and there is no blocker; only a start that failed, a foreign daemon record, or a remote LEMONADE_BASE_URL leaves it waiting on f. To reproduce a reboot, stop the daemon you started for the drive (GAIA_DAEMON_HOME set) and relaunch. esc quits on preflight (there is no screen behind it) and clears the composer on an idle chat. With the d details pane open, it closes the pane first.

Copy and selection keys

The terminal owns the mouse by default, so a plain drag is the terminal’s own selection and the app never sees it. That selection can’t scroll the transcript, which is why the app has its own. Copied text is plain: each message’s span records its chrome (msgSpan.gutter, bodyStart/bodyEnd), and a message covered end to end is copied from its source markdown. The control API’s mouse actions are single-point gestures, so a drag can’t be driven through it; selection_test.go covers it, decoding the OSC 52 payload. OSC 52 is write-only, so a screen capture can’t confirm a copy.

Giving it a terminal

The TUI queries terminal size and colour support at startup, so it needs a real pty. Two things that do not work from a tool call or CI step:
  • script -q /dev/null ... — fails with tcgetattr/ioctl: Operation not supported on socket when stdout is a socket.
  • osascript -e 'tell application "Terminal" to do script ...' — can return AppleEvent timed out (-1712) and leave nothing running.
What works is allocating the pty yourself:
Call resize first in any drive script for the same reason.

Colour

GET /screen?format=ansi and count \x1b: dozens on a styled frame, zero means the colour profile degraded at launch. On Windows, launch through wt.exe — a cmd.exe window spawned by Start-Process lands in legacy conhost, reports no colour support, and the TUI then emits no escape sequences at all. It reads as “syntax highlighting is broken” but nothing is broken.

Capture mistakes that produce false bug reports

  • Always capture the whole screen. A cropped capture once produced a fabricated “uninstall silently does nothing” — the status line is the second-to-last row.
  • Check which screen you are on before sending keys. Keys sent to the wrong screen do nothing and read as “the binding is broken”.
  • Grep for the right words. The confirmation prompt’s first line is a risk badge (READ, WRITE, RUNS CODE, DESTRUCTIVE) and a question. A tool the TUI knows gets a plain question — a shell command reads RUNS CODE Run this command?, a file write WRITE Write this file? — and any other tool reads Allow <tool>?. There is no Confirm: <tool> line.
  • format=plain strips ANSI, so it cannot answer a colour question.
  • screencapture is blocked on some machines; use the control API text.

Logs and traces

The agent log is where tool calls live, with arguments and results:
The TOOL_LOADER lines record which tools were selected for each turn — the first thing to check when the agent claims a capability “isn’t available”.
A trace holds whatever the agent read — file contents, shell output, email. Review before sharing. The control log omits input contents and masks pasted credentials by design.

Running a local model on a dev box

The embedded Lemonade is self-contained and does not need a system install:
It is private: its URL and key are its own, and other configured servers never inherit them. Cloud routing needs 11.8.1 or later. gaia init --check is the exact gate the preflight screen uses, and it names the missing model id:
user.-prefixed models are custom registrations: pulling one requires checkpoint + recipe (+ embedding for an embedder). Built-in models are the opposite — pulling one with a recipe makes Lemonade treat it as a new registration and 400 (#1655). gaia init gets this right; readiness.pull_model posts model_name only and therefore cannot pull a user. model — it returns HTTP 500 not registered with Lemonade Server.