npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 quit

Requirements

  • 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 --help

The 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, run npm i -g atlas-tui@latest once — 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 -- --help

The 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,lifecycle

The response is NDJSON, one StreamEnvelope per line:

  • protocol / snapshot — arrives first, as seq: 0. Its payload holds the conversation's full current item list, current as of from_seq - 1.
  • canonical / chat_itemuser_message, assistant_text, tool_use, tool_result, compaction_marker.
  • lifecycleturn-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. Passing item.id gets a 404 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; /inbox says so instead of failing.
  • applied: false is 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 answers 200 {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_COLOR is honoured by the palette itself. ink v4 does not check it (chalk does, but ink does not route colours through chalk's level), so theme.ts drops every named colour when NO_COLOR is set and no explicit FORCE_COLOR overrides 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.usageinput_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_tokens is 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_id set) 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.ts measures each entry before drawing it so the scroll window knows what fits. That needs a pure text → lines function; 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 — ⌃G is 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 on

opus, 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 close

While 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 cadencebase + spread per 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 text
npm test
npm run typecheck

test/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.log

Not 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.