atlas-tui
v0.14.1
Published
Terminal UI client for the Atlas daemon
Readme
atlas-tui
A Claude Code-style terminal client for the Atlas daemon. Attach to Athena (the orchestrator) — or any Atlas agent — from a terminal: full history, live streaming turns, inline tool blocks, and an input bar that sends a real user turn.
atlas · orchestrator · ⚑ 8 surfacings · 41 inbox working ● 15:32
──────────────────────────────────────────────────────────────────────────────
● athena 15:31
Acknowledged — send-path test received as a user turn.
▸ Bash (node "$ATLAS_EMIT_CJS" project list --json)
│ [{"slug":"agent-sandbox","title":"agent-sandbox","status":"paused"…
──────────────────────────────────────────────────────────────────────────────
❯ Message Athena...
⏎ send · ⌃T tools · PgUp/PgDn scroll · ⌃L latest · /help · ⌃C quitRequirements
- Node 20+
- The Atlas app running locally, so
the engine daemon's socket is up at
~/.atlas/engine.sock - A valid host-control token at
~/.atlas/engine.token(written by the Atlas app on first launch)
Install
npm i -g atlas-tui
atlas # attach to the orchestrator
atlas --agent <name> # attach to a specific agent
atlas --helpThe package is public on npm, so the install needs no authentication. See atlas-tui on npm for all published versions.
Migrating from a pre-0.14.0 install: releases used to be distributed as GitHub Release tarballs on
georgeandtonic/atlas-tui-releases. That feed is retired and npm is now the only channel. If you installed from a tarball URL, runnpm i -g atlas-tui@latestonce — after that the built-in updater tracks the registry on its own.
Develop from source
git clone https://github.com/georgeandtonic/atlas-tui
cd atlas-tui
npm install
npm run dev # attach to the orchestrator (Athena)
npm run dev -- --agent atlas-tui-pm-3
npm run dev -- --helpThe engine socket needs a bearer token
Every verb on the engine socket — reads included — requires the
host-control bearer token, and the token is simply the contents of
~/.atlas/engine.token:
Authorization: Bearer <contents of ~/.atlas/engine.token>Without it the socket answers 401 unauthorized, and a POST looks like a
permission wall rather than a missing header. This is the single most
misleading thing about the socket: it is not read-only. With the token, the
whole POST surface is available (/conversations/{id}/input,
/conversations/{id}/interrupt, /tasks, /attention/{id}/answer, …). An
earlier investigation concluded the write path was blocked and that landing
user input would need an engine change; that conclusion came from an
unauthenticated request. No engine change is needed.
src/lib/engine.ts reads the token once and attaches it to every request.
How it talks to Atlas
Plain HTTP/1.1 over the Unix socket (http.request({ socketPath })), no
dependencies beyond ink and react.
Streaming a conversation
GET /conversations/{conversationId}/stream
?from_seq=<n>&mode=snapshot&lanes=canonical,lifecycleThe response is NDJSON, one StreamEnvelope per line:
protocol/snapshot— arrives first, asseq: 0. Its payload holds the conversation's full current item list, current as offrom_seq - 1.canonical/chat_item—user_message,assistant_text,tool_use,tool_result,compaction_marker.lifecycle—turn-started,turn-completed,session-started,session-ended.
The protocol trap: pass from_seq = headSeq + 1. The snapshot always
carries the whole history, and the engine then replays every envelope from
from_seq onward. So from_seq=1 delivers all history twice — once as the
snapshot, once as replay. Read headSeq from GET /conversations and ask for
headSeq + 1; history arrives exactly once, followed by live events only.
(from_seq must be ≥ 1 — log seqs are dense from 1 and 0 is rejected.)
On reconnect, the snapshot is treated as authoritative and replaces the rendered list rather than appending to it, which makes a dropped connection self-healing.
Streaming a reply as it is written
The stream has four lanes, and live is the one that carries token deltas:
raw claude stream-json, passed through verbatim. Ask for it explicitly —
GET /conversations/{id}/stream?lanes=live,canonical,lifecycle— and assistant text arrives as content_block_delta / text_delta while it is
being written. thinking_delta says a reasoning block is streaming; the
reasoning text itself is never rendered. An event carrying parent_tool_use_id
belongs to a subagent, and folding it in would put a sub-task's output in the
agent's own voice.
The deltas are provisional. The engine prunes live entries when the turn
ends, and the durable canonical assistant_text that follows is the truth — so
the streamed copy is dropped in the same update that adds the real one. History
still comes from canonical; the live lane is only ever about the turn in
flight, which is also why a reconnect discards a half-streamed block instead of
rendering a reply missing its middle.
Deltas are coalesced on a 60ms timer before they reach React: ink redraws its whole tree per state change, and one repaint per token would thrash a terminal.
Sending input
POST /conversations/{conversationId}/input {"text": "..."}
→ 202 {"ok": true}This is the same path the desktop app's chat box uses, so the text lands as a
user turn — a lifecycle/turn-started with the text as the prompt, then a
canonical user_message. It is not an inter-agent message and is not
attributed to another agent. atlas emit message --to orchestrator is
deliberately not used anywhere in this codebase: that path would arrive as
agent-to-agent traffic.
The typed message is echoed locally the moment you hit enter (dimmed), and the echo is replaced when the stream confirms the real item.
Other endpoints used
| Endpoint | For |
|---|---|
| GET /conversations | head seq per conversation |
| GET /attention | the Inbox: the status-bar count, and /inbox |
| GET /sessions | whether the target agent is mid-turn |
| POST /conversations/{id}/interrupt | /interrupt |
Acting on the Inbox
GET /attention returns the full item, not just a count — every field the
desktop Inbox renders and every id its actions need — so /inbox needs no new
read endpoint. The writes below are the same routes the desktop app calls when it
runs in attach mode (its IPC handlers forward to this socket), so acting from
the terminal is the app's own path rather than a side door.
| Action | Endpoint | Notes |
|---|---|---|
| answer a decision | POST /attention/{signalId}/answer {answer} | signalId is item.source.signalId, not item.id |
| approve a gate | POST /tasks/{taskId}/resolve-gate {note?} | → {applied} |
| reject a gate | POST /tasks/{taskId}/deny-gate {reason} | → {applied} |
| answer an inline question | POST /attention/question/{sessionId}/answer {toolUseId, answer} | → {delivered, alreadyAnswered} |
| dismiss a signal | POST /attention/{signalId}/dismiss | idempotent |
| acknowledge a reminder | POST /attention/reminders/{reminderId}/acknowledge | |
| dismiss a set-aside | POST /set-aside/{setAsideId}/dismiss | restorable in-app |
Three things about this surface are worth knowing before touching it:
- The id matters. A decision is answered by its
source.signalId. Passingitem.idgets a404 decision_not_found, which reads like a missing item rather than a wrong key. A legacy file-sourced decision has no signal id at all and can only be answered in the app;/inboxsays so instead of failing. applied: falseis not an error. Both gate verbs re-validate against the live human-gate feed first, so a card resolved from another surface while it sat open answers200 {applied: false}and mutates nothing. That is a stale card, and the TUI reports it as "already resolved elsewhere".- Attribution is
host. The engine stamps this lane as a human on a detached client — not as the orchestrator, and not as agent-to-agent traffic. That is the correct semantics for George acting from his own terminal, and it is what the app's own attach mode records too.
The one parity gap, and how it is closed. On a rejection the desktop does
two things: deny-gate for the durable record, and then a separate send so the
agent actually acts on it. The socket's deny verb writes the record and echoes a
line into the agent's pane, but the echo does not wake it. So a rejection from
the TUI is followed by a best-effort POST /conversations/{id}/input carrying the
reason, worded explicitly as a gate denial to act on rather than as ambient user
chatter. That endpoint is the same lane the desktop's own sessions.send uses,
and it accepts a session id as well as a conversation id — which is what lets
an Inbox action reach the asking agent with no lookup. When there is no session to
reach (most of the live gates carry none), the rejection is recorded only, and the
status line says so rather than implying delivery.
Which conversation to attach to is resolved from ~/.atlas/sessions.json
(kind: "orchestrator" by default, or --agent <name>), since
GET /conversations returns ids without labels.
Theming — it adapts to your terminal, it does not impose a palette
atlas-tui contains no hex colours and no background colours. It draws with what your terminal already gives it, which is why it stays legible on a near-black theme, a light theme, and a dark-teal one alike:
- Body text — message bodies and your typed input — sets no colour at all, so it is the terminal's default foreground.
- Backgrounds are never set. The one highlight, the input caret, is inverse video, so it takes your own foreground and background.
- Accents are named ANSI colours (
cyan,magenta,yellow,green,red), which resolve through the 16-colour palette your theme has already tuned for contrast — rather than a fixed RGB value chosen against one background. - Secondary text (timestamps, tool output, scroll markers, the placeholder) uses the ANSI dim attribute on default-foreground text, so it recedes relative to your theme. Where the dim attribute is too faint on some themes, it is deliberately not used: the keybinding row is plain default foreground with bold key glyphs, because it is the app's only discoverability.
NO_COLORis honoured by the palette itself. ink v4 does not check it (chalk does, but ink does not route colours through chalk's level), sotheme.tsdrops every named colour whenNO_COLORis set and no explicitFORCE_COLORoverrides it. Bold, dim and inverse survive, since those are attributes rather than colours — the hierarchy still reads in a monochrome terminal.- Low-colour terminals need nothing special: named colours are what the 16-colour palette is, and no truecolor sequence is ever emitted.
src/lib/theme.ts is the only place colour is decided. It exports semantic
tokens (accent, userName, assistantName, flag, ok, warn, error,
dim, body, hintKey) shaped as ink <Text> props, so call sites spread a
token instead of naming a colour: <Text {...tone.flag}>. test/theme.test.tsx
fails the build if a hex colour, a background colour, or a non-named colour
reappears anywhere in src/.
Token usage and the context gauge
Both are read off the live lane. There is no context API on the socket — the
engine exposes usage nowhere else, and system:init reports the resolved model
id but no window size — so this is derived exactly the way the desktop app
derives it, and nothing here is estimated:
| Reading | Source |
|---|---|
| context fill | message_start.message.usage — input_tokens plus both cache counters, since cached context still occupies the window |
| output tokens | message_delta.usage.output_tokens |
| context window | looked up from the model id in system:init |
| compaction | the compact_boundary system event |
Two details that are easy to get wrong, and that the tests pin:
output_tokensis cumulative for the message in flight, not a per-delta increment. Summing the deltas inflates a turn quadratically, so it replaces the current message's count and is banked into a base when the next message starts.- A subagent's usage (
parent_tool_use_idset) is skipped. Its tokens are real, but they are not this conversation's context, and counting them would make the gauge lie.
The window comes from the id because nothing reports it: a family can be
provisioned at either size, so a [1m] variant marker outranks the family rules.
Past that, the family table answers only "what is the largest window this model
has" — the catalogue maximum, which an id actually can answer — so Sonnet 4.6
resolves to 1M alongside Opus 4.6+, Sonnet 5 and Fable 5. Opus 4.5 is
deliberately absent and falls through to the 200K default. The table used to
encode a guess at the provisioned default instead, which is what once measured
a 1M session against a 200K denominator and cried compaction that was not
coming.
The bar is drawn with block glyphs, filled and empty runs being the same glyph
separated by tone, so it keeps its shape under NO_COLOR where a
background-based bar would vanish. It appears only once a real measurement has
arrived — before the first message_start of a session there is genuinely
nothing to show, and rendering 0% would be a claim rather than a reading.
Markdown rendering
Message bodies — the agent's and yours — are rendered rather than shown as raw
syntax: headings, **bold**, *italic*, inline code, fenced code blocks with
their language, ordered and unordered lists (nested, with continuation lines
aligned under the text rather than the bullet), links, blockquotes and
horizontal rules.
It is hand-rolled (src/lib/markdown.ts), for three reasons that all point
the same way:
- The theming contract. Every terminal markdown package on npm ships its own chalk palette with hardcoded colours — including a background for code blocks, which is the one thing this app may never set. Adopting one would mean overriding it token by token and re-auditing it on every upgrade.
- Height prediction.
layout.tsmeasures each entry before drawing it so the scroll window knows what fits. That needs a puretext → linesfunction; a library handing back one ANSI-escaped string cannot be measured, because the escapes inflate.length. - Streaming. Partial markdown has to degrade gracefully, which is not something a document-oriented parser offers.
So the module emits structure — a flat list of already-wrapped RenderLines
carrying styled spans — and components/Markdown.tsx paints them with
theme.ts tokens. One pass feeds both the measurement and the draw, so the
window's prediction and what lands on screen cannot disagree.
Emphasis rides ANSI attributes (bold, italic, underline) wherever an
attribute will do, because an attribute cannot clash with a background the way a
colour can. Inline code takes the accent hue; a fenced block is marked out
structurally instead — an indent and a dim rail, the same device ToolBlock
already uses for command output — since the conventional coloured background is
off-limits. test/markdownRender.test.tsx asserts this against the bytes ink
actually emits: no truecolor, no 256-colour, no background, every colour inside
the terminal's own 16-colour palette. It sets FORCE_COLOR before importing ink
so the assertion is not vacuous, which is why it is its own file.
Streaming
One rule covers it: a delimiter styles nothing until its closer has been
seen. **bol renders as the literal text **bol and quietly becomes bold when
the closing ** arrives; a half-typed `npm te stays literal. Nothing is
buffered and nothing is hidden, so the view is clean mid-turn and correct when
the turn completes.
Two deliberate exceptions, both because they read better:
- An unclosed fence opens the block anyway. Mid-stream that is what the
author meant, and rendering the tail as code beats leaving a stray
``` sitting in the prose. - Once
](has arrived, a link shows its label and swallows the incomplete target, so the line does not reflow when the closing paren lands.
test/markdown.test.ts renders every prefix of a streaming document and
asserts each one stays inside the width with no empty spans.
Keys
| Key | Action |
|---|---|
| enter | send |
| ⌃T | cycle tool output: collapsed → compact (4 lines) → expanded (24) |
| PgUp / PgDn | scroll; ⌃L snaps back to the live tail |
| wheel | scroll (needs set -g mouse on under tmux) |
| ⌃Y | send the last agent message to the terminal clipboard (OSC 52; in tmux needs set -g set-clipboard on) |
| ⌃G | open the last link in the last agent message in your browser |
| ↑ / ↓ | move a wrapped line, or recall input history |
| ← / →, ⌃A, ⌃E, ⌃U, ⌃K | line editing |
| ⌥⌫ / ⌃W | delete the word behind the cursor |
| ⌥← / ⌥→ | move by word |
| esc | clear the input |
| ⌃C | clear the input, or quit when it is already empty |
| link click | Cmd-click in iTerm2, Shift-click in WezTerm/kitty/Ghostty (OSC 8) |
Two notes on what the terminal, rather than this app, decides:
- Clicking a link needs a modifier because the app holds mouse tracking on
for the scroll wheel, so a plain left-click goes to the app and never reaches
the terminal's link handler. Terminals that do not implement OSC 8 just draw
the label —
⌃Gis the affordance that always works. - Copying writes an OSC 52 sequence and gets no reply, so the app cannot
know whether it landed. Your terminal may ignore it: if nothing pastes, check
its clipboard-write setting (in tmux,
set -g set-clipboard on).
Slash commands — type / and the menu opens
/ opens a filterable menu of every command and every key binding, each with
a one-line description, arrows to move and ⏎/⇥ to pick. It is the
discoverability surface — there is no hint row any more — so nothing the app can
do is reachable only by knowing it already.
| | |
|---|---|
| /inbox | act on what needs you — surfacings, decisions, gates |
| /model [model] | show or switch the model this agent runs on |
| /interrupt | stop the turn in flight |
| /tools | cycle tool output: collapsed → compact → expanded |
| /refresh | re-read the Inbox now |
| /help | list the commands |
| /quit | leave |
Prefix matches win, with a substring pass as the fallback, so /box still finds
/inbox. That fallback matches a command on its name only — searching
descriptions too would make /box find /refresh, whose description mentions
the Inbox. A binding is the exception: ⌃T is not something anyone types, so its
description is the only way to reach it.
Bindings are documentation rather than commands, so ⏎ on one leaves the line
alone instead of pretending to run something. The list scrolls to keep the
selection in view, since a hard top-N would put the bindings permanently out of
reach. ⏎ picks while the line is unfinished and sends once it is
already a runnable command — /hel completes to /help, /help runs it — so a
fully typed command never needs a second Enter. ⇥ always completes. Every
other key falls through, so typing keeps filtering and the editing keys keep
working while the menu is up.
Completion is generic over commands and their arguments: picking /model
leaves a trailing space, and that space turns the same menu into the model list.
/model
Shows the model the attached session is on, and switches it. The change lands on the next turn — the running one keeps the model it started with.
❯ /model op
❯ opus claude-opus-5 Most capable — what the fleet runs onopus, sonnet, haiku, fable, default, or a full model id typed
verbatim. Setting it is POST /sessions/{id}/config, the same verb (and the
same SessionManager call) the desktop's own picker uses; default goes through
clear: ['model'], since the engine has no null form and refuses both together.
The live list comes from the engine. GET /models — the engine's
ModelDiscoveryService, proxying Anthropic's /v1/models — reports what this
account can actually run, and the TUI uses that answer to replace the built-in
picker rows at startup; it falls back to the built-in list on a 404, and still
consults it for alias resolution. An engine that predates the route 404s, and
then the built-in list stands alone.
That fallback list is hardcoded, and that is a known compromise. It is
maintained by hand, under two rules that keep it honest: it tracks the
current family rather than copying the desktop's (that one predates
claude-opus-5, which is what the fleet runs on, so mirroring it would have
shipped a /model that cannot select Opus), and any well-formed id is accepted
when typed in full, so the list falling behind can never block a switch. There
is also a deny-list (UNAVAILABLE_MODELS) for ids that are advertised but fail
at session start, each carrying the reason it is refused — it is currently
empty, so nothing is refused on this account.
The current model is read from ~/.atlas/sessions.json, which the engine
rewrites on every config change — GET /sessions returns the fleet shape (kind,
status, queue depth, controls) and does not carry it. It is re-read per call
rather than captured at attach, so it cannot go stale after a switch.
Commands: /inbox, /model, /interrupt, /tools, /refresh, /help, /quit.
The Inbox — /inbox
Act on what is waiting on you without opening the desktop app: answer a surfacing or a decision, approve or reject a human gate, acknowledge a reminder, dismiss a signal or a parked set-aside.
⚑ inbox — 3/41
19h decision GAP-7b — project-less orchestrator log rows atlas-app
❯ 21h gate Add fenced yaml criteria block to land-search.md austin-land-scraper
21h gate Live-validate Zillow adapter (heaviest anti-bot) austin-land-scraper
↓ 30 more
loadCriteria expects a fenced yaml block with keys: counties, minAcres, …
↑↓ select · a approve · r reject · g refresh · esc closeWhile it is open the panel owns the keyboard, and the input bar says so.
| Key | Action |
|---|---|
| ↑/↓, k/j | select; PgUp/PgDn jump to the ends |
| ⏎ | the item's primary action |
| a | answer, or approve on a gate |
| r | reject a gate (a reason is required) |
| d | dismiss, or acknowledge a reminder |
| g | refetch |
| esc, ⌃C, q | close — and in a prompt, cancel it |
The list is ordered the way the desktop Inbox orders it: what needs an answer first, then newest first. Items are offered only the actions the socket can actually perform for them, and an item that cannot be acted on from here — a failed session, a legacy file-sourced decision — says why in place of its hints rather than going quiet.
Both gate choices route through a prompt, even though the approval note is
optional: approving completes a task an agent is waiting on, and a keystroke that
consequential should take a confirming Enter rather than fire on a single letter.
Acting refetches /attention immediately, so the status-bar count settles without
waiting for the 15-second poll.
The logic is pure and lives in src/lib/inbox.ts — which affordances an item
offers, what call each choice becomes, and the picker's key handling — so it is
tested as state transitions rather than through a DOM harness this repo has no way
to run. src/lib/engine.ts is the only part that talks, and
test/engineWrites.test.ts drives it against a stub bound on a real unix socket,
because the path, method, body shape and bearer header are a contract with the
engine that pure logic cannot check.
Row widths are computed rather than left to flexbox, and the panel's row budget is allocated in priority order. Both are load-bearing: ink wraps an over-wide row instead of clipping it, and it draws an over-tall column straight past the bottom of the viewport, pushing the input bar off the screen.
Layout
Single column (Option A), fixed regions: a slim status bar, the scrolling conversation, and the input bar pinned to the bottom.
The Athena eyes sit at the bottom, between the transcript and the input — where the reply forms and where you are already looking, which is the desktop app's placement too. The row is permanent: the eyes at rest are the idle state, not an absence, and a row that came and went would shift the transcript by a line every time a turn started.
They are modelled on the app's own ThinkingEyes, because what makes those read
as alive is behaviour rather than artwork — and behaviour ports to a terminal
even though geometry does not:
- A pair, so they read as a face rather than as a cursor.
- An expression pool per phase. The agent scans while a tool runs (quick darting saccades), concentrates while it reasons, sweeps horizontally while it writes as if reading the line, and settles to a low-lidded rest when a turn goes quiet.
- Randomised cadence —
base + spreadper phase, never a fixed interval. A metronome is exactly what the eye stops seeing after a few seconds. - Blinks by chance, sometimes doubled (~32%), which is the single biggest contributor to "alive" over merely "animated".
They are grounded by a rule directly above them and indented to sit over the input's prompt glyph, so the rule, the eyes and the framed input read as one anchored region at the foot of the screen rather than as something floating in the gap.
Beside them is the phase, in the app's own words — Thinking… / Working… / Responding… / Still working… — with a calm shimmer sweeping through it, a turn timer, and the turn's output tokens. The phase is derived from real signal only (text actually streaming, a tool actually unanswered, actual wall-clock silence), so it never claims to know more than it does; the word is deliberately tool-agnostic, since the running tool is already on screen as its own block. The timer is the quiet part that separates "thinking hard" from "hung", which is why a long silent tool run still feels alive.
The shimmer is built from the dim attribute, not a colour gradient: a short
band of un-dimmed characters sweeping through dim text. That is what lets it
survive any theme and NO_COLOR alike, where a gradient could not. A shimmer,
never a spinner — it says alive without implying progress it cannot measure.
Every expression is the same display width, so nothing beside them can jitter, and the scheduler tears down on idle so a finished turn cannot leave them mid-blink. The status bar keeps a plain connection dot.
Scrolling
PgUp / PgDn page the conversation and ⌃L snaps back to the live tail — and
the wheel scrolls it too, which needs saying because it is the part that does
not work by default anywhere.
A terminal sends no wheel events unless an application asks for them. Ink has no
mouse support, so the TUI enables tracking itself (?1000h for button events,
?1006h for the SGR encoding, which is the only one that survives past column
223) and reads the reports off stdin alongside ink's key handling. Tracking is
turned back off on exit, or the user's shell would receive mouse escapes as typed
input afterwards.
In tmux, put set -g mouse on in ~/.tmux.conf. With it, tmux forwards the
wheel to a pane whose application has tracking enabled — which is now this one —
so the wheel scrolls the conversation rather than tmux's own copy-mode
scrollback. Without it, tmux never hands the wheel over at all.
A notch moves two blocks rather than a page: entries here are variable height, so a page per notch turns a trackpad flick into a jump of hundreds of blocks.
The input
The input is a framed box, and the frame is doing real work: a background is the one thing this palette may not set, so the border — in the accent colour, turning warm while a message is in flight — is what makes the input read as its own surface rather than one more line of transcript.
Text wraps. It used to scroll sideways through a one-line letterbox, which meant you could not see what you had written; the box now grows downward and the layout reserves the rows for it, so the transcript yields space instead of the input running off the bottom.
Wrapping makes the cursor two-dimensional, so the keys follow:
| Key | Action |
|---|---|
| ↑ / ↓ | move a display line — and recall history only when there is no line to move to |
| ⌃A / ⌃E | start / end of the display line |
| ⌥⌫ / ⌃W | delete the word behind the cursor, and the space in front of it |
| ⌥← / ⌥→ | move by word |
Terminals disagree about how to send an Alt chord — some report the ESC prefix as a meta flag, some deliver ESC and the key as one chunk — so both forms are accepted. A binding that works in one terminal and not another is experienced as flakiness, not as a missing feature.
There is deliberately no keybinding hint row any more. It was kept
un-dimmed while it was the app's only discoverability; the slash menu is that
surface now, so / lists the key bindings alongside the commands and /help
carries both.
Ink redraws its whole tree every frameInk redraws its whole tree every frame, so handing it a 1,500-item
conversation would be unworkable. src/lib/layout.ts measures entries from the
bottom up — simulating ink's word wrap so the prediction matches what is drawn
— and renders only the tail that fits the terminal. That windowing is what
makes paging cheap, and it keeps a 25,000-seq conversation responsive.
Rendering follows the tail unless you page up. A single block taller than the
whole viewport is clipped from the top (with a marker saying how much was cut)
rather than allowed to overflow, because overflowing would push the input bar
off the screen and leave you typing blind. Resizes are picked up from
process.stdout's resize event; verified at 80 and 220 columns.
Bracketed paste is on by default. Set ATLAS_TUI_BRACKETED_PASTE to
off, 0, false, or no (case-insensitive) to revert to v0.13.0
keystroke behaviour if your terminal misbehaves on ?2004h.
Code map
src/
index.tsx arg parsing, render
App.tsx attach, polls, key handling, viewport state
components/ StatusBar · MessageList · Markdown · InboxPanel
ThinkingBar · CommandMenu · ToolBlock · InputBar
hooks/useEye eye animation timer, torn down when idle
hooks/useMouseWheel mouse tracking + SGR wheel parsing
hooks/useTerminalSize columns/rows across resize
lib/engine.ts socket HTTP + NDJSON stream (token, reconnect/backoff)
lib/conversation.ts envelopes → rendered entries (reducer)
lib/eye.ts the working-indicator frame table (pure)
lib/keymap.ts keys → editor state + effects (pure)
lib/markdown.ts markdown → wrapped, styled render lines (pure)
lib/inbox.ts inbox affordances, action plans, picker keys (pure)
lib/commands.ts the slash-command + key-binding registry, menu matching
lib/input.ts input wrapping, cursor mapping, word boundaries (pure)
lib/watchPhase.ts which phase a turn is in, and its word (pure)
lib/usage.ts token usage, the context window table, the gauge (pure)
lib/models.ts the model list, aliases and switch wording (pure)
lib/layout.ts wrap + height math + the scroll window
lib/target.ts which conversation to attach to
lib/theme.ts palette
test/ reducer, keymap, viewport, markdown, theme, input-path
scripts/frame.mjs dump the last frame of a captured run as plain textnpm test
npm run typechecktest/input.test.tsx renders the real App against a fake TTY (a
PassThrough with isTTY and a no-op setRawMode) and writes keystrokes into
it, so typing, enter, and slash-command routing are covered end to end with
send stubbed out. That indirection exists because ink does not receive stdin
when the process is driven by script or expect, even though plain Node does
— so a pty harness can check the rendering but never the input.
For a headless layout check at an arbitrary width:
COLUMNS=220 LINES=50 script -q /dev/null node --import tsx src/index.tsx \
< /dev/null > /tmp/tui.log
node scripts/frame.mjs /tmp/tui.logNot in v0
No task board, and no agent switcher while running (relaunch with --agent).
/inbox covers surfacings, decisions, gates, reminders and set-asides, but not
the memory-accept card or the AskUserQuestion multiple-choice UI — the latter is
answerable as free text, not as a numbered pick. Scrolling
moves by block rather than by line, so the clipped head of an over-tall block is
not reachable from the TUI yet.
Releases
Releases are npm-only. Push a v* tag; .github/workflows/release.yml builds,
tests, and publishes to npm. See RELEASING.md.
