Goa’s TUI is an ANSI-native terminal UI engine with differential rendering,
viewport management, and a component tree. It wraps the agentic SDK for LLM
interaction and provides keyboard-driven chat, markdown rendering, scrollable
history, and a status bar with animated spinner.
Goa’s TUI is a compositor MVC. Concerns are strictly separated:
Name, Rect position/size, Z order, styled Content), plus the input
cursor. The conversation state lives in a Conversation of MessageData.tui/compositor.go) — the single owner of terminal
protocol. It composes Layers onto a canvas, diffs against the previous
frame, and emits every escape sequence (CSI 2026 synchronized output, cursor
movement, line clears, scrollback scrolling, hardware-cursor positioning).
Nothing else in the codebase constructs output escape codes.ChatViewport is a
thin view over the Conversation Model.tui/focus.go) — the single authority for who receives
input; overlays push/pop.TUI.AgentFrame() / TUI.VisibleText() produce a
structured, ANSI-free view of the screen so AI tooling can “see” the TUI
without parsing escape codes. Agent and terminal always agree (both consume
the same Scene).Controller (input keys + agent events) ──► Model (Conversation + Scene)
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
Compositor AgentView tests
(terminal bytes) (plain text for AI)
┌──────────────────────────────────────────────────────────────┐
│ Header: goa coding agent v0.1 │
│ Ctrl+C/D exit | / commands | Tab complete | ↑↓ history│
├──────────────────────────────────────────────────────────────┤
│ │
│ ChatViewport (scrollable message history) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • Connected to LM Studio (qwen3.5-9b) │ │
│ │ │ │
│ │ User: hello │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Assistant response... │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ◉ bash ls -la │ │
│ │ ← [bash: ls -la]\nExit: 0\n... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
├──────────────────────────────────────────────────────────────┤
│ StatusBar: ⠋ thinking │
├──────────────────────────────────────────────────────────────┤
│ Input area (editor, grows up to 12 lines max) │
├──────────────────────────────────────────────────────────────┤
│ Footer: ~/dev/goa (⎇ main) coder │ YOLO │
│ ↑120 ↓80 200/4096 tok qwen3.5-9b │
└──────────────────────────────────────────────────────────────┘
tui/tui.go)The TUI orchestrates components into a Scene and delegates output to the
Compositor. It manages:
LayersbuildScene: renders components + overlays into protocol-free Layers and
extracts the explicit Scene.CursorLayersengine := tui.NewTUI(terminal) // engine owns a Compositor bound to terminal
engine.AddChild(header)
engine.AddChild(chatViewport)
engine.AddChild(editor)
engine.SetFocus(editor)
engine.Start()
// engine.AgentFrame() / engine.VisibleText() → ANSI-free view for AI tooling
tui/terminal.go)ProcessTerminal implements raw-mode I/O with:
Components implement the Component interface:
type Component interface {
Render(width int) []string
HandleInput(data string)
Invalidate()
}
Standard components:
| Component | File | Description |
|———–|——|————-|
| Header | tui/header.go | App name, version, keybinding hints |
| ChatViewport | tui/chat_viewport.go | Scrollable message history with typed messages |
| Editor | tui/editor.go | Multi-line text input with undo, kill-ring, autocomplete |
| Footer | tui/footer.go | Status bar with workdir, git branch, model, stats |
| StatusMsg | tui/status.go | Ephemeral status line with animated spinner |
| Separator | tui/box.go | Horizontal line across the terminal width |
| Selector | tui/selector.go | Interactive item picker overlay |
| SelectList | tui/select_list.go | Scrollable list for completion popups |
The ProcessTerminal.readLoop goroutine reads raw bytes from stdin,
decodes them through StdinBuffer (which handles partial escape sequences,
Kitty CSI-u, bracketed paste), and forwards decoded key strings to
TUI.handleKey. The key is routed to the focused component’s HandleInput.
renderNow() acquires the TUI mutexrenderTree() calls each component’s Render(width), concatenating linesclipToViewport() slices visible portion for terminal heightdiffRender() compares against previous frame, writes changed lines
using ANSI positioning (\x1b[N;1H\x1b[K...)extractCursorPos() finds the hardware cursor marker (\x1b_pi:c\x07)
emitted by the focused componentpositionCursor() places the terminal cursor for IME supportscrollTop offset for scrolling
through message historyMarkdown in assistant messages is rendered by MDStreamRenderer
(tui/markdown.go) which handles:
# through ######) — colored and bold**text**), Italic (*text*), ~~text~~)[text](url))>) — dim with indent marker• / 1. markers---, ***, ___)All goa-originated text (slash-command output such as /help, /docs,
/hotkeys, /goal:status) is rendered inside a bordered goa panel
(renderGoaPanel in tui/chat_viewport_components.go). The panel:
╭─┄╮ / ╰─┄╯ / │) using the goa_panel_border
theme token,goa_panel_bg dark background so the text
is visually distinct from the terminal/chat background,This is the global convention for goa-authored output across the tool.
Information ordering — when a command both needs user input and shows context, the input title is presented first (on the editor top border) and the supporting goa text is shown as a panel immediately after, avoiding redundant or ambiguous bubbles.
All user text input must flow through the main input zone (the persistent
Editor), never through a throwaway overlay Input.
requestMainInput(prompt, onSubmit) (or the
core.Context.RequestMainInput callback), which sets the editor title
(top border) to the prompt so the user always knows what is being asked.Editor.SetText(current) before
registering the request (the review handlers in events.go use this idiom).onCancel restores prior UI state.Rationale: the main editor carries history, kill-yank, autocomplete, undo, and
the live title cue. Spawning a separate Input overlay (ShowInput) forfeits
all of these and produces a second, inconsistent input region. The legacy
ShowInput/ShowInputFunc overlay path is therefore deprecated for
free-text capture and retained only as a transition shim; new code must not use
it for user input.
The Editor supports inline autocomplete via a Completer interface.
Tab triggers completion; Enter accepts and submits. Down/Up cycle
through candidates when the popup is active.
Agent SDK → OutputEvent → AgentManager.OnEvent → TUI events channel
→ handleAgentOutputEvent → chat.AddMessage / statusMsg.Show / footer updates
→ renderNow()
The TUI has no external dependencies beyond:
golang.org/x/term — raw mode terminal I/Oansi package — ANSI escape sequence helpers, width calculation,
text wrapping