Skip to content

gaia ui

gaia ui is GAIA's interactive terminal cockpit: a Dashboard of ticket and run statistics, a registered-project overview, a searchable ticket list, ticket and run detail, and a set of ticket-create agents — hideable, re-attachable agent sessions that create GAIA tickets for you.

It also takes a deep-link target, so you can open the one thing you are working on in a single command instead of navigating to it every time. This page is the reference for that grammar; gaia ui --help carries the same table in terse form.

This page is the reference; the operating guide is Using gaia ui.

Synopsis

gaia ui [target] [--ticket <id>] [--project <id>] [--run <uuid>] [--here]
        [--view <mode>] [--config <path>] [--print-config]

Which control plane answers

gaia ui resolves its connection through the same single rule gaia dropsh uses:

  1. an explicit --config <path>, $GAIA_CONFIG or $DROPSH_CONFIG (first non-empty wins),
  2. the project's ./.gaia/gaia.config.js, found by walking up from the cwd,
  3. the home connection ~/.gaia/gaia.config.js,
  4. the shipped fallback.

So running it inside a repo that declares its own connection reaches that repo's control plane, and running it anywhere else reaches your machine default. See Connection config for what those files contain and how the home default is inherited.

gaia ui holds no precedence rule of its own — the one difference from gaia dropsh is that gaia dropsh reads none of the explicit inputs in step 1.

gaia ui --print-config reports the resolution and exits without launching the UI — useful when the cockpit is talking to a control plane you did not expect:

sh
gaia ui --print-config
connection: /Users/you/work/shop/.gaia/gaia.config.js
source:     project
fallback:   no
legacy:     no
base_url:   https://gaia.example.com
cwd:        /Users/you/work/shop/web
project:    /Users/you/work/shop

Where the agents run

The ticket-create agents are rooted in the repo root of the project you started gaia ui in — keyed on standing in a project (a .gaia/ directory found by walking up), deliberately not on which connection won. A repo that inherits its connection from your home config still roots its agents at its own root, and --config cannot relocate them. Outside any project the registry default applies verbatim.

Navigation has three levels, and every screen shows exactly one tab bar: the one of its own level. Tab/Shift-Tab cycle the tabs of the current level, and a left click on a tab goes straight to it; there are no letter shortcuts for tab navigation.

LevelScreensTabs
RootDashboard, Users, projects overview, project pickerDashboard (ticket/run statistics — the landing screen) · Users (accounts with 30d tickets/runs/closed-per-run) · Projects (the registered-project overview)
ProjectDashboard, Tickets, Ticket-Agents, RunsDashboard (the same statistics, scoped to this project — where opening a project lands) · Tickets (the project → ticket drill-down) · Ticket-Agents (the managed create-agent list) · Runs (the project-scoped run overview)
TicketTicket detailOverview · Comments · Related · Runs
UserUser detailOverview · Tickets · Runs
Run detail (below the ticket level)none — Esc back to the ticket's Runs tab

Dashboard is one screen, not two. The root Dashboard and a project's Dashboard are the same screen with its project filter unset or set, and the tab bar it draws follows that same bit — so the bar and the figures can never disagree. That is also why the tab id dashboard appears on both levels: a tab is resolved within the level you are on, never globally.

Every bar is drawn in the same row — under the header box. The ticket detail's sections are ordinary entries of the ticket level and the chrome draws them like any other level's, so no screen has its bar in a different place. The ticket screen's header box is five rows (frame · title · labels · state · frame) plus the bar row, and the run detail screen's is the plain four with no bar. Every screen's chrome height is derived from the rows it actually writes, which is what keeps a click on the row it points at — and it is also why the state row costs one body row on every ticket detail.

A section is a mode of the open ticket, not a destination: switching one runs no navigation, so the screen, its document and each section's expansion state — which of Overview's two entries stand open, which comments do — are untouched, and no step is added to the back stack. The cursor is the one thing a switch does reset: every section opens on its first entry.

Each bar is clickable where it is drawn, and only ever at its own level: on the Dashboard, a click at the column where the project bar's Runs would sit is inert, because that bar is not there. On the ticket screen the same column is inside Related, so it opens that section and still reaches no project screen.

Every project tab is project-scoped, so open a project first — that is why they sit on the project level and are not reachable from the root. Only tabs whose screen exists are drawn: a Conductors project tab is planned and will appear with its screen.

Users is a shipped root tab. It lists accounts except uid 0 and uid 1, with 30-day tickets, runs and closed-per-run; Enter opens that user (Overview · Tickets · Runs). The list is one JSON:API collection read (listOutcomecollection('user').list() with no explicit page[limit]), so Drupal's default OffsetPage::SIZE_MAX (50) is the bound — there is no ←→ paging on this tab.

The Dashboard's figures

The Dashboard shows ticket and run activity over the trailing 24 hours, 7 days and 30 days. Tickets are windowed on closed_date (closed tickets in the window); runs are windowed on created. The windows are rolling durations, not calendar buckets — "the last 30 days" is 30 × 86 400 seconds, so no timezone is involved — and they nest, so 24h ⊆ 7d ⊆ 30d.

Every figure is read at render time from the control plane's aggregate endpoint /jsonapi/gaia/statistics, which counts server-side and returns one document per Dashboard; nothing is counted or cached in the CLI. The root Dashboard shows the totals plus a per project and a PER USER breakdown; a project's Dashboard shows the same totals scoped to it plus the per-user breakdown, and no project list. Reading it requires the view gaia statistics permission, and the counts it returns are instance-wide — the permission, not the caller's row-level access, is what decides who may see them.

CLOSED PER RUN is tickets.total[w] / runs.total[w] to two decimals, or when that window's runs total is 0. PER USER rows that carry an id are selectable; Enter opens that user (Overview · Tickets · Runs).

A breakdown row the server could not attribute — a ticket with no assignee, a run with no ticket — is shown as — unattributed rather than dropped, so the rows always sum to the total. Each bar is scaled against the largest value in its own group, not against a quota: a full bar means "the biggest of these", never "100 %".

An older control plane

A control plane that does not serve the endpoint gets a named message saying so rather than a blank screen or invented zeroes, and every other tab keeps working. A real outage is reported as the error it is — the two are told apart, so a 500 is never dressed up as an out-of-date control plane. An instance with nothing in the window says "No activity in the last 30 days."

See Ticket metrics for what the underlying figures mean.

The ticket sections

Overview leads with the latest summary: the newest comment whose gaia_comment_type is summary — the phase handoff that says where the work stands, which is what an operator reads before the ticket's own body. It is a collapsible entry of its own, above the description, and "newest" is the Comments tab's own total order (created descending, then the server's monotonic id, then the resource id), never the order the include happened to arrive in, so the two surfaces can only ever name the same comment. Collapsed it shows a marker row — summary · <author> · <created> and then the extract below, the marker counting as one of the extract's lines; Enter/Space expands it to the whole comment, marker included. The match on the type is exact, and gaia_comment_type is a free-text field: a ticket with no comment typed summary — a typo included — shows the single explicit line "No summary comment.", and it keeps its caret, because a caret that appeared only once a handoff had been written would make the same keystroke mean different things on different tickets.

Latest spec and Latest plan sit between Latest summary and Description when comments of those types exist. Same newest-wins order and exact type match as summary; marker rows are — spec · <author> · <created> and — plan · <author> · <created>. Unlike summary, they are omitted entirely when absent — no empty-state copy and no caret — so Overview's selectable count is 2 + N where N is how many of {spec, plan} are present. Entry 0 stays Latest summary.

Description follows those typed entries, and every expandable entry is the same short summary of its text rather than the whole of it: leading blank lines and leading ATX headings (## Context, which every GAIA body opens with) are skipped, the run of consecutive non-empty lines that follows is taken, and it is capped at 5 physical lines — a line cap, because the TUI never wraps, so one line is exactly one row. When more remains, an explicit … +N more lines marker says how much, counting everything the expand reveals. Expanding is literally the Comments tab's interaction, since all of them render through one shared primitive rather than through two renderers that have to agree.

Overview therefore carries at least two expandable entries (summary + Description), and that is what its cursor is about: ↑↓ (or j/k) move the selection between them, g/G jump to the first and the last, and Enter/Space acts on the selected one. At either end of the selection — on the last entry, on the first — the arrows fall through to the body scroll instead, one row per press, so the expanded description body, Assignment, Time, Branch and Environments are still reachable with the keyboard alone. A click follows the cockpit's standard two-step: a click on an entry's header row that is not the selected one only selects it, and a second click toggles it. So the summary, which the selection opens on, toggles on one click, while later entries take two when they are not yet selected.

Above both sit the parent ticket and the links, so both are visible in the freshly opened frame without scrolling and without expanding anything. Each has an explicit empty state ("No parent ticket.", "No links."); a parent whose include was access-filtered away shows its uuid rather than a blank row.

Related lists the parent too, and a Blocked by section between sub-tickets and referenced tickets: one identity row <identifier> · <title> per ticket in the open ticket's blocked_by, the same row shape Parent uses, degrading to the raw uuid when the include did not arrive, and "Not blocked." when there are none. Sub-tickets and Referenced tickets deliberately stay counts — rows for those need their own selection and navigation, which is a separate change. A blocker row carries no state of its own: it would answer "is the blocker still open" in one glance, but it would break the row format Parent establishes on the same tab, and the identifier is the jump target.

The ticket's labels and its state are full-width rows of the header box, in that order under the title (Labels: work:code, State: coding) — each its own row rather than appended to the title, because a too-narrow terminal truncates a shared row and that would make them appear or vanish by terminal width. Labels: — when the ticket has none, State: — when it carries no state — an absent attribute and an empty string alike, which for a display row is one case and not two. The header box does not depend on the open section, which is the point of putting the state there: it stays on screen on Comments, Related and Runs alike. It is drawn in teaser and board_card too, where it repeats the compact block's own state field — accepted deliberately, because a row conditional on the view mode would make the box's height, and with it the body window's, a function of the mode.

State and Labels are additionally sections of the flat gaia ui <identifier> | cat render that no tab lists: the pipe has no header box, so without them it would be the one rendering that silently lost those facts. Its order is State, Labels, Parent, Latest summary, Description, Assignment, Time, Comments, Sub-tickets, Blocked by, Runs, Referenced tickets, Links, Branch, Environments — independent of the tab order, because each tab looks its headings up by name.

Markdown bodies render as Markdown

Every Markdown body on the ticket detail — the description, the latest summary, latest spec, latest plan, and every comment — is rendered as Markdown within the terminal's capability: on the Overview tab (the collapsed extract and the expanded body), on the Comments tab, and in gaia ui <id> | cat, which is the same rendering rather than a second renderer's, because a row's text is the rendered text. All of them go through one function, so they cannot render differently from one another.

Nothing else on the screen is parsed. State, labels, assignment, time, the runs table, the blockers, the structured links list and the branch rows hand over plain text, so a branch called feat/a_b_c or a link title holding a * cannot be re-styled or re-spelled by accident. The same holds for the marker rows inside those sections — Latest summary / Latest spec / Latest plan's — <type> · <author> · <created> and each comment's — <type> · — which are the cockpit's own text rather than the author's and are never parsed.

The rendering is line-oriented, because a cockpit row is one physical line that the renderer clips at the right edge — nothing wraps or reflows. One source line becomes one rendered line, with a fenced block as the only piece of block state.

you writeyou see
# …###### …, optional closing #sthe markers gone, the whole row bold
**strong**strong
*em*, _em_italic
***both***, **_both_**bold italic
~~struck~~dim (not strikethrough — see below)
`code`, code with a ` insidethe backticks gone, the cockpit's palette cyan
- item, * item, + item• item, at the item's own indent
- [ ] task, - [x] task☐ task, ☑ task
1. item, 1) itemthe marker kept verbatim
a fenced block, info string or not (with one exception, below)the fence rows dropped, the content two columns in and cyan, and not parsed
> quoted, >> nested per level, the quoted text dim
---, ***, ___a fixed ────────, dim
[text](url), ![alt](url)text (url) — the address stays in the text
<https://x>https://x
\*, \_, \`, \[, \~, \\the literal character, the backslash gone
a bare https://…unchanged, and tappable (OSC 8)

An inline link keeps its address in the text rather than hiding it behind the label: gaia ui <id> | cat is read by people and by agents and carries no terminal hyperlinks at all, so dropping the address there would be silent data loss. It stays tappable in the interactive screen for free.

What it does not do, and what you get instead. GFM tables, setext headings (=== / --- underneath a line), reference links and definitions, footnotes and raw HTML are shown raw, character for character — a table needs columns that a one-line renderer does not have, and the stored format renders a pipe table as a single paragraph anyway. __strong__ is also raw: it cannot be told apart from __init__, and keeping a real identifier intact matters more than a second way of writing bold, so write **strong**. ~~struck~~ takes dim rather than strikethrough, and emphasis takes italic, because those are the only additions the cockpit is willing to make to the codes it puts on the wire — underline is what a terminal hyperlink looks like and would collide with text (url). A collapsed comment header shows its preview as plain text, since the whole row is drawn bold, and a pipe carries no styling at all by definition.

Fences, exactly. A backtick fence's info string may not itself hold a backtick, so ```code``` is not a fence: it is an ordinary line carrying a code span, and that is how you see it. A ~~~ fence has no such restriction, so ~~~a~~~b does open one. An unterminated fence loses its opening row along with every other fence row — that row opens a code block whose info string is metadata rather than content, and a renderer that reads one line at a time cannot know the closer never comes. Everything after it is fenced content to the end of that body, and never past it: the fence state is held for one body only, so an unclosed fence in one comment cannot swallow the next.

Line endings. CRLF and a lone CR are normalised to LF before anything is parsed, so a description typed into the web form renders identically to one written with Unix line endings. Which editor wrote a body is not something you have to know to predict how it will look.

The invariant behind all of it: only markers the renderer matched are ever removed. Anything it cannot resolve — an unbalanced **, a lone backtick, a _ inside an identifier — comes back exactly as written. A mis-parse can cost you a marker; it can never cost you a character of content.

Runs

The ticket's Runs tab lists that ticket's runs as selectable rows — the same table the project-wide Runs screen draws, so a run looks identical in both places — ordered newest-started first by a total comparator (started_at descending, then internal id, then resource id; an unparseable date sorts last). It costs no extra request: the ticket document's runs include already ships each run's full attribute set. No runs ⇒ "No runs for this ticket."

Enter (or a click) on a run row opens the run detail screen, rendered from that same sideloaded resource — again no read, so nothing there can fail — with sections Run (#<id> · <uuid>, the owning ticket) · State (state, phase, agent, conductor) · Timing (started, heartbeat, claim expiry, duration) · Footprint (tokens, tool calls, agent turns, user prompts, prompt words) · Worktree · Log / Error log (the full text values, uncapped and scrollable — a truncated log would be a silent lie; "No log captured." when they never were). It is read-only, and Esc returns to the ticket's Runs tab.

The project-level Runs tab lists the selected project's runs (started_at descending, one page of 50) in a flat table — columns RUN (#<id>) · TICKET · STATE · PHASE (the state the run started in) · AGENT · STARTED (relative age). STATE is derived: an active (running/claimed) run whose heartbeat is stale (> 600 s) or whose claim lease has expired shows as stale. Empty projects show "No runs for this project."; loading and errors use the shared status-bar pattern.

Creating a ticket — the new-ticket popup (n)

n opens a centered popup (a real modal overlay drawn on top of the current screen) with, top to bottom, a Title, a multiline Description, a searchable Parent, and live-loaded Assignee (users), Conductor, Labels (multi-select) and Workflow type selects. Your own user (by username) and own conductor (by machine_id, from the machine context at ~/.gaia/machine.config.js) are preselected as defaults. Title comes first because gaia_ticket.title is required server-side — nothing derives it from the description. Conductor is the one select you can clear (Del, or Backspace): a set conductor means the ticket is claimed at the next poll and a run starts, a cleared one means it waits for a human, and the field is clearable precisely so that choice is yours.

The popup ends in two submit rows[ Create with agent ] and [ Create directly ] — and the key hints occupy two rows at the bottom of the popup, inside the frame (the kernel draws the status line as part of the modal's body, so it is framed like every other row). Two rows, because the frame's inner width is at most 60 columns (Math.min(60, width - 8), so a narrow terminal gets fewer) and one row does not hold them:

 ↑↓ field · ←→ change · Space toggle
 Ctrl+S agent · Ctrl+D direct · Esc cancel

Both actions are always drawn, wherever the popup was opened from. Ctrl+S presses the action row you are standing on and from any other field means the agent — exactly what it meant before there was a second action; Ctrl+D creates directly from any field; Enter on an action row fires that row. Tab and Shift+Tab walk the fields forward and back, the same two steps and take.

  • Create with agent (Ctrl+S) is the original path: the collected fields go into a temp JSON spec and an embedded agent runs the create workflow, interviewing you for whatever is missing.
  • Create directly (Ctrl+D) writes the ticket itself — one POST, no agent, no qualification handoff. The typed text becomes the ticket's description verbatim (format gaia_rich), the picked workflow, assignee, conductor, labels and parent are written as they stand, and everything the server owns (state, identifier, number, branch_name, base_branch) is left to the server, so the ticket lands in the workflow's first state. It needs a project in scope: opened from the Dashboard, [ Create directly ] refuses with no project in scope — open a project to create directly, or hand it to the agent and writes nothing, because a ticket without a project draws no number and gets no GAIA-nnn identifier. It needs a title for the same kind of reason, and refuses an empty one the same way, before the round trip: title is required — type one, or hand it to the agent. gaia_ticket.title is required server-side, so the popup already knows what the server would answer. Both refusals are the direct row's alone — the agent row resolves the project and interviews you for the title, so neither precondition narrows it. A rejected write is reported in the popup, with your typed text still in it.

The Description soft-wraps to the popup's inner width (measured in display columns), so a long line is readable inside the frame instead of clipped at it, and Labels are read from the gaia_labels vocabulary. When an option list cannot be loaded the popup says so per list (⚠ Labels could not be loaded: …) rather than presenting an empty one — an empty list means the read succeeded and there is nothing to offer.

Parent is optional and scoped to the project you are in: type to filter, ←→ to move the suggestion cursor, Enter to pick. It offers only the project's open tickets — filing new work under a ticket that is already finished is not something the form asks you to consider, so a closed one is never suggested. (The ticket list's own parent filter is a different question — narrowing a list by a parent that has since been closed is legitimate — and it still offers closed tickets.) Matching is a case-insensitive substring over <identifier> · <title>, so an identifier fragment and a title fragment both work. A page of the project's most recently changed tickets is preloaded when the popup opens and re-queried (debounced) as you type. Five suggestions are drawn at once, and whatever that render window leaves out of the matches it has is counted as +N more — narrow the search — a statement about the popup, not about the server. The server is a second, separate limit: Drupal caps a page at 50 rows, so a page that came back full may itself be a truncated answer, and the field says more may exist — narrow the search whenever it is at the cap and the counter has nothing left to report. Both notes exist for one reason: never imply the list is complete.

Opened from the Dashboard there is no project in scope and the field says (no project context — open a project to pick a parent). A picked parent travels in the spec as parent: {id, identifier, title} and becomes the created ticket's parent, from which the server derives the base branch.

On the agent submit the collected fields are written to a temp JSON spec and passed to the agent via $GAIA_NEW_TICKET_INPUT — never on the command line, so a long description cannot hit the terminal's input limit. The agent adopts them and only asks for what is missing. Each agent record carries a short name (the title, else the description's first line, shown in the list so you can tell which ticket it is), the pre-filled inputs, and an isNew flag.

The agent's base prompt defaults to a built-in one but can be overridden by config (ticket_create_prompt). Which agent runs is config-driven too: the launcher hands the resolved agents (an object keyed by id) to an optional ticketAgent(ctx) hook in the engine config, where ctx.isNew lets you route a new-ticket create to a different agent than other work:

js
// .gaia/conductor.config.js
ticketAgent: ({ isNew, agents }) => (isNew ? agents.claude : agents.codex),

Ticket agents are durable

On submit each agent becomes a split pane in its project's durable terminal tab, labelled ticket-create:<project>:<sessionId>, and is zoomed full-screen so it stays readable. The tab is per project, so the Ticket-Agents screen shows only the agents of the project you are in — never another project's. Multiple agents run concurrently (more splits in that tab). A rapid double-submit is debounced.

In Ticket-Agents, Enter switches to the selected agent (zoom full-screen and focus), z restores the tiled overview of all agents, x closes one. Agents survive a TUI restart — the screen is rediscovered from the live tab, so it always shows the host's real live set rather than a remembered one.

The UI drives agents at the level of intent — launch, list, focus, hide, kill, keyed by the project and an agent id — and never names panes, tabs, splits or zoom itself. The terminal multiplexer behind it owns the whole layout policy, so a different pane model changes nothing in the UI.

Target sources — at most ONE, ever

FormExampleOpens
(nothing)gaia uithe root Dashboard (ticket/run statistics)
bare identifiergaia ui GAIA-221 · gaia ui gaia-221that ticket's detail screen
ticket:<id|uuid>gaia ui ticket:GAIA-221 · gaia ui ticket:<uuid>that ticket
project:<name|uuid>gaia ui project:shopthat project's Dashboard (its ticket list is one Tab away)
run:<uuid>gaia ui run:<uuid>that run's detail screen
--ticket <id>gaia ui --ticket GAIA-221the flag equivalent of ticket:
--project <id>gaia ui --project shopthe flag equivalent of project:
--run <uuid>gaia ui --run <uuid>the flag equivalent of run:
--heregaia ui --herederived from the cwd — see the ladder

The conflict rule — one rule, not a pairwise matrix. Supplying two or more of those six sources exits non-zero and names which ones were supplied:

$ gaia ui GAIA-1 --project shop
gaia ui: conflicting targets — the positional target 'GAIA-1', --project were all supplied.
Give at most one of: a positional target, --ticket, --project, --run, --here.

Prefix precedence. project:x always parses as the prefix form. A project literally named ticket is therefore reachable as gaia ui --project ticket, never as gaia ui ticket:….

Identifier normalisation

An identifier is <project-key>-<number>GAIA-221 below is just an example.

  • Accepted: GAIA-221 and gaia-221, both normalised to uppercase. This is not a convenience: a branch carries the identifier lowercased (feat/gaia-221-…), so --here cannot work without it.
  • Rejected — a bare number (gaia ui 221). An identifier is <project-key>-<number> and the CLI knows a project name, not its key. The error lists the accepted forms.
  • Rejected — a bare UUID (gaia ui 1111…). It does not say ticket/project/run, so it is ambiguous by construction. Prefix it: ticket:<uuid>, project:<uuid>, run:<uuid>.
  • Project names are used verbatim — project labels are case-sensitive.

--here — the ladder

cwd
 ├─ branch carries a ticket identifier?  → that TICKET     (feat/gaia-221-… → GAIA-221)
 └─ else: the cwd's git remote → the project(s) whose repos[] claim it
      ├─ exactly one                     → that PROJECT
      ├─ several                         → a PICKER of them
      └─ none, or no remote at all       → error, exit 1

The branch decides the first rung, not git mechanics. "Am I in a linked worktree?" would be wrong twice — a worktree sitting on a non-ticket branch, and the main clone checked out on a ticket branch (which is how you work without worktrees). The parse is pure and offline:

/(?:^|[/_-])([A-Za-z][A-Za-z0-9]*-\d+)(?=$|[/_-])/     first match wins

Every convention puts the identifier before the title slug and the server never truncates it, so first-match-wins is right: feat/gaia-99-fix-404-page yields GAIA-99, not FIX-404. A version-shaped branch (release/v2-2026) can over-match; it then fails at resolution with a named error rather than opening a wrong ticket.

The project rung goes through the git remote. git remote get-url origin is normalised and matched against every project's repos[].url — the control plane's own declaration decides which project a directory belongs to. It needs no .gaia/ at all, so a fresh clone that never ran gaia conductor init still resolves.

Normalisation is required, not cosmetic: git and the stored field disagree on form.

rawnormalised
git@example.com:acme/shop.gitexample.com/acme/shop
ssh://git@example.com/acme/shop.gitexample.com/acme/shop
ssh://git@example.com:2222/acme/shop.gitexample.com/acme/shop
https://example.com/acme/shop.gitexample.com/acme/shop

Scheme, userinfo, port, a trailing .git and trailing slashes are dropped and the result lowercased. The match runs client-side, because it must: filter[repos] answers HTTP 400 — repos is a JSON field, not a relationship JSON:API can filter inside.

Several projects claim the repo → a picker, not an error. One repo mapping to several GAIA projects is designed for (a multisite repo maps each site to its own project), so --here lists the candidates and lets you choose. Enter opens the selected one, Esc falls back to the Dashboard. Without a TTY the list is printed with a gaia ui project:<name> hint and the command exits.

  Several gaia projects claim example.com/acme/shop — which one?

  ▸ shop-de
    shop-at
    shop-ch

No project claims the repo → an error, naming the repo and the known projects:

gaia ui --here: no gaia project owns the repo example.com/acme/new-thing.
  Known projects: shop, site, docs
  Run 'gaia ui' and press Tab for the project list, or 'gaia ui project:<name>' to open one directly.

No git remote at all (outside a repo, or a repo without origin) fails the same way, naming the cwd. Detached HEAD is not special-cased: no branch → no identifier → the project rung, which is the useful answer.

--view <mode> — how a ticket renders

--view is a modifier, not a target source: it never participates in the conflict rule.

ModeRenders
full (default)the tabbed detail screen — Overview, Comments, Related, Runs
teasera compact block: identifier · title, state, status, since, assignee, claim
board_carda compact block with the board-card field set: state, type, status, priority, claim
sh
gaia ui GAIA-221 --view teaser
gaia ui GAIA-221 --view board_card

The two compact field sets mirror the teaser and board_card view-mode displays of a ticket in the control plane's own web UI. They are a deliberate duplicate: those displays produce HTML, and JSON:API serves no rendered output.

Rules:

  • An unknown mode exits non-zero listing the known ones.
  • --view applies to ticket targets only; with a project: target it exits non-zero.
  • run: already means "that run, over its ticket's Runs tab", so run:<uuid> --view teaser is rejected as contradictory rather than silently resolved by a precedence rule.

What a run: target opens

The run's own detail screen, drawn on top of the owning ticket's detail screen with its Runs tab active — so Esc lands exactly where pressing Enter on that run row would have come from. Piped (| cat) it prints the same run block, so the target means one thing whether or not stdout is a terminal.

Failures never draw a screen

Every failure — a parse error, a conflict, no git remote, an unknown identifier, an unknown uuid, a repo no project claims — prints one line to stderr and exits 1 before the TUI takes the terminal: no raw mode, no alternate screen, no dashboard. Parse, conflict and no-remote failures happen entirely offline, before any config is loaded or any request is made.

The picker is the one successful outcome that is a screen, so it necessarily runs after the terminal is taken. Ambiguity is not a failure.

Booting straight into a ticket, project or picker pushes no back-history frame. Esc on an empty history therefore opens the Dashboard — the statistics screen, not the all-projects list — so a deep-linked session converges on the normal navigation model instead of leaving q as the only way out. From a project's Dashboard, Esc un-scopes it to the instance-wide one; the project list is its Tab sibling.

Piping (| cat, < /dev/null)

Without a TTY the command prints the route's content once and exits: the ticket's lines (honouring --view), the run's detail block, the project's ticket table, or the picker's candidate list plus its hint. With no target it prints the Dashboard's statistics rows; project:<name> prints the same rows scoped to that project. A control plane that does not serve /jsonapi/gaia/statistics yields the named message on stderr and exit code 1, so a script sees a failure rather than empty output.

There is consequently no non-interactive way to print the project list: the deep-link grammar has no projects: target. The same data is one gaia dropsh search gaia_conductor --include project_id away, which is what the screen itself reads.

Auto-refresh — the .gaia/ui-refresh signal

An interactive gaia ui watches one file in the project's .gaia/ directory — the same directory the cockpit resolved its connection from, which inside a git worktree is that worktree's own:

touch .gaia/ui-refresh

That is the whole writer contract. The file's content is meaningless and it may be empty; what the cockpit reads is its modification time. Anything that can write a file can bump it — an agent, a git hook, a drush wrapper inside the DDEV web container — and none of them has to find the cockpit's process. Bumping it means one thing: the data behind this cockpit may be stale.

On a bump the cockpit runs exactly what R runs on the active screen. Pressing R keeps working; auto-refresh is additive, and the footer says which triggers are armed — R reload (auto) · while the watcher is live, R reload · without it.

What deliberately does not happen:

  • A screen that cannot re-read its data is not reloaded. Forms, the project picker and every popup declare no reload, so a bump while the New-ticket popup is open does nothing at all — and nothing is remembered for later either.
  • Bursts are coalesced. Writes closer together than 300 ms produce one reload, and a continuous stream of writes produces none until it pauses for one window. This is what keeps a looping agent from storming the control plane.
  • A piped gaia ui watches nothing. Non-interactive runs render once and exit, so there is no watcher to build.
  • No project .gaia/ means no watcher. A cockpit started outside any project reloads with R only.

The file is transient and git-ignored; nothing in GAIA writes it for you yet.

Keybindings

ScreenKeys
Dashboard↑↓ select · Enter open user · PgUp/PgDn/g/G scroll · Tab/Shift-Tab cycle tabs · n new ticket agent · q quit — PER USER rows with an id are selectable; other figures scroll
Users↑↓ select · Enter open user · Tab/Shift-Tab cycle tabs · q quit
Projects↑↓ select · Enter open project · n new ticket agent · Tab/Shift-Tab cycle tabs · q quit
Tickets↑↓ select · Enter open ticket · n new ticket agent · Tab/Shift-Tab cycle tabs · / search · s status filter · t type filter · c clear · Esc back
TicketTab/Shift-Tab switch section · ↑↓/jk move between the section's entries — two on Overview, one per run on Runs — and fall through to the body scroll at either end (a section with no entries, Related, only scrolls) · g/G first/last entry · Enter/Space expand the selected entry · Enter on a run row opens it · e edit ticket (assignee · conductor · estimate/spent · cancel) · Esc back
Run detail↑↓ scroll · Esc back to the ticket's Runs tab · q quit
Ticket-Agents↑↓ select · Enter switch to agent (zoom full-screen) · z tiled overview · r retry · x close · Tab/Shift-Tab cycle tabs · Esc back
Runs↑↓ select · Enter open the run · Tab/Shift-Tab cycle tabs · Esc back
New-ticket popup (n)↑↓/Tab/Shift+Tab move field · ←→ change a select / move the label or parent cursor · Space toggle a label · Del/Backspace clear the Conductor · Enter pick a parent suggestion or press the action row you are on · Ctrl+S hand to the create agent (or press the action row you are on) · Ctrl+D create directly · Esc cancel

Tab/Shift+Tab mean two different things, and which one you get depends on what is on screen. On a screen they cycle that level's tab bar, as the rows above say. Inside a modal form — the new-ticket popup, the Who picker, the parent filter, the assignment edit — they move the form's own cursor instead, mirroring and exactly: forward and back through the fields, and — on the parent filter, the one picker that tells the key module how many fields it has — through its suggestions. The Who picker is the same one-field shape but does not tell it, so there both keys stay field moves, and a form with one field has nowhere to move them: they do nothing. That gap is real and small, and left to its own ticket — telling the key module would change behaviour on a screen outside the surface that needed the key. A modal form has no tab bar of its own, so nothing is shadowed; the tab bar belongs to the screen underneath and comes back when the form closes.

The ✎ Edit ticket popup edits assignee, conductor, estimate, spent, Parent (project-scoped autocomplete, clearable, never offering the open ticket as its own parent) and Labels (multi-select from gaia_labels). Ordinary Save (Ctrl+S, or Enter on [ Save ]) PATCHes those fields and never writes state. Cancel ticket is a second action on non-terminal tickets only (done / cancelled omit it); activating it PATCHes only state: cancelled and discards any local field edits — no confirmation step. Ctrl+S always saves even when the cursor is on Cancel ticket. Esc closes the popup without writing (Esc close in the chrome). The server remains the only transition guard — a forbidden cancel comes back as a 422 rendered in the popup.

Next