agentleFS
pankaj-mahaurpankaj-mahaur/Agent-Daemon/skills/daemon/ad-tui-dev/SKILL.md

ad-tui-dev

Skill0 starsChanged 6 days agoMITView on GitHub

Use when working on agent-daemon's own terminal UI (maintainers, this repo): \"continue the TUI\", \"TUI ka kaam\", \"ad tui\", \"renderer\", \"fullscreen\", \"mouse\", \"select and copy\", \"keymap\", \"chords\", \"account picker\", \"tui-probe\", \"terminal UI bug\", \"/undo\", \"the fake app-server\". Loads the module map, tests and goldens, and the rules the TUI must keep: isolation from the user's own install, inline and fullscreen renderer rules, Windows input facts, the per-part loop.

The file

Show its rules and text

We split this skill into the rules it gives an agent

18 rules. About 0 are common, 5 are distinctive, and 13 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 13

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    All paths under runtime/.

    line 22
  2. Very specific:

    The fake must stay honest: every message fake-codex-app-server.mjs sends in the main scenarios must pass protocol-check (tui-resilience …

    line 95
  3. Very specific:

    Read docs/tui-architecture.md (layers, invariants, decisions).

    line 113
  4. Very specific:

    Check git status -sb: work happens on a feature branch in its own git worktree, never on main and never in a checkout a global ad is …

    line 115
  5. Very specific:

    Every real engine process starts through codexEnv() (runtime/src/engine/codex/home.mjs).

    line 118
  6. Very specific:

    Never use ~/.codex or the user's global install.

    line 120
  7. Very specific:

    never 2J or 3J;

    line 124
  8. Very specific:

    Test races with cprDelayMs in testkit/screen.mjs.

    line 139
  9. Distinctive:

    Cursor: never restyle it or force a blink, and never redraw an idle screen (each write restarts the blink).

    line 140
  10. Very specific:

    Untrusted text: always through sanitize() (tui/terminal/sanitize.mjs): "transcript" strips controls, "approval" makes every invisible …

    line 142
  11. Very specific:

    Source files: write invisible characters as \u{...} escapes.

    line 143
  12. Very specific:

    When in doubt, run node runtime/scripts/tui-probe.mjs keys|screen and read ~/.agent-daemon/logs/tui-probe-*.log.

    line 150
  13. Distinctive:

    Every applied edit is hashed when it completes and its own diff lines recorded.

    line 174
  14. Distinctive:

    Only the "changed since…" conflicts are forceable.

    line 176
  15. Very specific:

    Before every commit: run the project's ad-pre-push skill (docs, CHANGELOG, skills, learnings, hygiene, link check).

    line 182
  16. Distinctive:

    read the git diff (scripted edits have corrupted files before).

    line 188
  17. Distinctive:

    Commit by file name. Push only with the user's OK.

    line 189
  18. Very specific:

    Reaching the engine's raw server: Use the façade's capabilities (engine.threads.*, engine.turns.*, …).

    line 203

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Work on the ad terminal UI

The TUI (ad tui and bare ad: a terminal UI on the pinned engine, released in 2.1.0) was built from a reviewed plan, one part at a time. This skill puts its hard-won rules in front of you before you touch code, so a new session continues the work instead of re-deriving it. User docs: docs/tui.md. Design and the /undo safety model: docs/tui-architecture.md. Tests: docs/testing.md. The maintainer's working plan (docs/plans/ad-tui.md) and research notes (docs/research/) are local-only, not in the public repo: read them when present.

When to use

Any change under runtime/src/tui/, runtime/src/harness/{session,checkpoints}.mjs, runtime/src/engine/codex/events.mjs, runtime/testkit/, the probe, docs/tui.md, or the TUI parts of the plan.

Module map

All paths under runtime/.

PathWhat
src/cli.mjsthin launcher: routes tui and bare ad / ad --last before loading cli-full.mjs (the rest of the CLI). Keep it light
src/tui/flip.mjsbare ad → TUI (D7). TUI_IS_DEFAULT is true (flipped after FC3); AD_TUI=0 opts out; the one-time ad chat hint
src/tui/preflight.mjswhy the TUI can't run here (TTY, TERM=dumb, Node on Windows, mintty), with a working alternative
src/tui/main.mjscmdTui: preflight → terminal → engine (sign-in panel) → folder trust → session + checkpoints → header and "since last time" → app → exit hint. Builds actions (file search, git diff, editor, handoffs to ad …)
src/tui/app.mjsthe app: input → intents, session events → frames, scrollback commits, keys, SLASH_COMMANDS (each marked source: "upstream" or "ad"), slashCollisions
src/tui/commands.mjsPart 8 renderers: /mcp, /hooks, /skills, /usage, /terminal-setup, transcript pager, /export markdown, /copy clipboard, image paths
src/tui/ad-layer.mjsPart 9: /memory, learned rows, /proposals, /loop, /team, /schedule, scheduler warning
src/tui/undo.mjsPart 10 wiring: "before" started at Enter and awaited (10 s cap) so no agent action can be in it, "after" after the turn, applied edits only (hashed + line-counted per file), edit paths resolved to real paths, /undo
src/tui/history.mjsprompt history JSONL (~/.agent-daemon/tui/history.jsonl)
src/tui/keymap.mjsevery key ad handles as an action (the engine's contexts and defaults), tui.keymap overrides, chords and the leader key
src/tui/status.mjsstatus-line and title items (the engine's ids, plus ad's own statusExtra items)
src/tui/screen-mode.mjsinline or fullscreen at start (--no-alt-screen, tui.alternate_screen, tui.fullscreen_transcript, Apple Terminal over SSH, tmux mouse), the mouse default per terminal, the /status screen line
src/tui/home-launcher.mjs"Pick a project folder" before the engine starts, when the folder would load the user's own engine home
src/tui/fallback-editor.mjs/fallback: the fallback order editor over ~/.agent-daemon/fallback.json
src/sessions/the session record (ledger, coordinator, effects), switching (switch, switch-runner, capsule) and automatic fallback (fallback, fallback-controller)
src/tui/init-prompt.mjsthe engine's /init prompt, vendored (re-copy on engine upgrades)
src/tui/preview.mjsad tui --preview, a minimal preview UI on the same engine (the first walking skeleton)
src/engine/codex/upstream-commands.jsonthe engine's slash commands at the pinned tag with their rules: aliases, inline args, allowed during a task, side-conversation allowlist, visibility, popup (scripts/codex-slash.mjs --file slash_command.rs --popup command_popup.rs), reached through the façade as engineCommands
src/tui/terminal/io (raw mode, caps, handoff, restore), input (bytes → key/text/paste/mouse events), renderer (inline), fullscreen (the alternate-screen renderer), alt-screen (the guard every exit path releases), transcript (the fullscreen buffer: lazy layout, re-wrap, scroll anchor), selection, find, clipboard (native tools, tmux, OSC 52), sanitize, text (spans, SGR, wrap with soft-wrap markers), width + width-table (generated), detect (terminal name, reflow model, width profile)
src/tui/accounts.mjsthe window's accounts (P3.8b): snapshot, plan, switchTo (retarget in the same home, useEngine for another), logout, signIn; app.mjs only calls it, so tests stub it
src/tui/view/pure views: composer, markdown (streaming), cells (items, diffs, ad rows), chrome (header, status, footer, shortcuts, picker), modals (approvals with arming, questions, forms)
src/harness/session.mjsUI-agnostic session controller: submit / steer / queue, requests, restarts, thread locks (~/.agent-daemon/locks)
src/harness/checkpoints.mjsgit-tree snapshots under refs/ad/checkpoints/, private index, restore with conflict check, UNDO_LIMITS
src/engine/codex/events.mjsprotocol traffic → AdEvents; compat.json holds tested versions and "what changed"
testkit/protocol-check.mjsvalidates messages against the pinned protocol snapshot
testkit/tui-fake.mjsruns ad tui on fake-codex-app-server.mjs in a temp root (pty smoke)
testkit/golden.mjsbyte-for-byte goldens; AD_UPDATE_GOLDEN=1 rewrites them
testkit/screen.mjstest terminals: xtermScreen (xterm.js, reflows) and modelScreen (clips); cprDelayMs for races
scripts/tui-probe.mjs, scripts/tui-demo.mjslive probes (keys, screen, screen --bottom) and the engine-free renderer demo

Accounts (P3.8b) rules

  • An API key is typed only into createTextPrompt({mask: true}), a composer with no history, and is dropped on answer. It must never reach the main composer, history.jsonl, the transcript, notices or the title (test: tui-accounts "masked field").
  • Switching accounts goes through session.retarget (fork, same provider/engine/model) or session.useEngine (a new conversation on another home), never thread/resume with a new provider.
  • Picker rows may be {kind: "header"} (skipped by the cursor, kept only while a row in their group matches the filter) or {disabled: true, reason}, where Enter shows the reason in the note line.
  • Two ChatGPT homes share the openai provider entry, so session.retarget.plan alone would say "continues": the accounts layer compares homes first.
  • A long URL (a browser sign-in) is committed as its own unindented rows and copied, never put in a notice: the renderer cuts every committed line to cols − 1. Tests that copy must stub actions.copy, or the real clipboard tool runs.

The session ledger's observer (P4.1)

  • createSession({ observer }) is the ledger's seam; the hooks object stays /undo's. observe(ev) sees every thread event (top of onThreadEvent), each user message once (user.message with via submit / steer / queue), request.answered with the decision only (a question's or form's answer becomes "answered"), thread.attached / thread.detached and engine.exited. A new path that sends a prompt, answers a request or swaps the thread must call it too.
  • It runs synchronously inside try/catch: its first throw is one notice (session.record, or the error's own notice text) and the session goes on. It may return { notice } for a one-time message (the torn-tail repair).
  • Thread events carry normalized items (kind, actions), not the engine's type / commandActions; sessions/coordinator.mjs asRaw bridges them for effects.mjs. Tests that feed the coordinator by hand can use either shape; one test must go through a real session (sessions-coordinator "through a real session").
  • ad run / ad loop have no session: recordEngineThread feeds the same coordinator from engine.subscribe, and must stop() before the engine is released (else the release reads as a lost engine).
  • Tests that pass an engine home record nothing unless they pass adRoot too (ledgerEnabled), so no test writes the real ~/.agent-daemon/sessions/.
  • main.mjs's start-up on ad's own homes (the launched.lease path) is covered only by tui-live.test.mjs: an identifier used there without its import (P3.8b's openUrl) passed the whole unit suite and crashed every ad tui start. Run the live suite after any main.mjs change.

Switching a conversation (P4.2)

  • sessions/switch.mjs decides (pure: switchHow, safePoint, step, recoverSwitch); sessions/switch-runner.mjs does it; sessions/capsule.mjs builds the continuation's history. The fork and same-account paths are session.retarget (P3.4's one routine); only the continuation path is new, and it reuses engine/switch.mjs letGo for the old thread. Never a second copy of the routine.
  • The runner's effects run in a promise chain; an effect that dispatches must not await dispatch (it would wait on its own chain). hold(true) applies synchronously at the request, or a prompt sent right after it steers the running turn.
  • safePoint checks open requests before the running turn: a turn waiting on an approval can't end, so mode "now" withdraws the request first.
  • Session seams: hold(on) (Enter queues, the queue doesn't drain), useEngine(…, { threadId, thread, keepOld }) and adoptThread carry the queue, the mode and the old live terminals (state.priorTerminals, shown by /ps, stopped by /stop through their own engine), and detach with why "switched" so the ledger keeps the session. The Plan mask uses state.config.model whenever the binding names its own provider.
  • The capsule drops reasoning itself: the engine accepts and forwards a foreign reasoning item in thread/inject_items (S-RESUME).
  • Fake engine: early-complete never sends turn/started (use fail-turn when the ledger must see the turn); test/start-turn starts a turn by itself; fakes number threads from 1 per engine, so start a throwaway thread on a second fake before comparing ids or taking locks.

Fallback and limits in the TUI (P4.4–P4.6)

  • main.mjs builds the fallback controller (sessions/fallback-controller.mjs) and composes it into the session's hooks and observer; it acts only at a turn's end, through the same switch runner as /model. A turn it handles shows ad's ↪ line instead of the engine's error text (that stays in /warnings); /retry, a new prompt or a model pick ends a pause.
  • Keys typed fast arrive as one multi-character text event: an editor or picker that acts per key must split it per character, queue async key handlers, and clear any render memo after the handler finishes, not when it starts (the /fallback editor lost keys otherwise).
  • ad's own status items (limits, account, fallback) never go into the engine's tui.status_line (the engine's UI would meet unknown ids): they live in ad's prefs (statusExtra) and /statusline carries them as ad: values, split again on save.
  • The TUI draws every frame, so anything it reads from disk (the quota store, fallback.json) sits behind a short cache. A notice marked in a store asynchronously also needs an in-memory "shown" set, or the next check before the write lands repeats it (test with a mark that never resolves).
  • Usage limits are shown only as reported: a number past its reset or 6 hours old is unknown, never 0 %.

Tests and goldens

  • TUI tests (runtime/test/): tui-input, tui-io, tui-detect, tui-width, tui-text, tui-sanitize, tui-renderer (terminal layer); tui-alt-screen, tui-fullscreen, tui-fullscreen-restore, tui-transcript, tui-selection, tui-find, tui-clipboard, tui-handoff, tui-screen-mode, tui-app-fullscreen (fullscreen); tui-composer, tui-markdown, tui-cells, tui-chrome, tui-modals, tui-picker (views); tui-keymap-chords, tui-accounts, tui-fallback, tui-limits, tui-home-launcher; tui-app, tui-main, tui-parity, tui-ad, tui-hardening, tui-resilience, tui-preview, tui-pty-smoke. Also session, checkpoints, codex-events.test.mjs, codex-protocol-snapshot.test.mjs, engine-real and tui-live (both AD_REAL_ENGINE=1; tui-live is the FC3/FC4 script on the real ad tui in a pty: run it after any app, session or view change).
  • Goldens: test/golden/tui/ holds the wrap, cells, chrome, dialogs, markdown, modals, parity, styles and syntax goldens, most at 40, 80 and 120 columns. They are LF on every platform. After an intended change, run with AD_UPDATE_GOLDEN=1, then read the golden diff line by line before committing.
  • Fixtures: test/fixtures/tui/s1-keys.json (real key bytes from Windows Terminal and Zed).
  • The fake must stay honest: every message fake-codex-app-server.mjs sends in the main scenarios must pass protocol-check (tui-resilience enforces it). When you add a fake message, give it the real shape (full Thread objects, startedAtMs, the approval's itemId, …), not just the fields ad reads.
  • Mutation-check every guard. For each fix or safety check, break it on purpose (delete the line, flip the condition), run the tests, and confirm a named test fails; then restore it. A guard no test catches isn't done. Record the count in the plan ("N guards mutation-checked").
  • Slash names: a new command needs source: "upstream" (mirroring the engine's own UI) or "ad", a check against engine/codex/upstream-commands.json (slashCollisions), a row in docs/tui.md, and /help coverage. Never point users to the engine's own UI. Every upstream command is either a source: "upstream" row in SLASH_COMMANDS or an entry in NOT_IN_AD (what ad says instead; never sent to the model): implementing one means removing it from NOT_IN_AD. Upstream's "during a task" rule and popup hiding come from the generated metadata, not by hand.
  • Fullscreen renderer rules (P2.1): every write goes through io.writeSync and the renderer's writable() gate (never stdout.write: on Windows it is asynchronous and could land after the leave bytes); the alt-screen guard is marked before ?1049h and released first by every exit path; never send CSI 3J in fullscreen (it wipes the shell's scrollback); commit cells as commitView(snapshot, render) or a function over constants, never a closure over a live item. Each guard has a mutation test (tui-alt-screen, tui-fullscreen-restore, tui-fullscreen, the pty crash paths). The pty main-screen comparison only means something on POSIX: ConPTY repaints the main screen when a child starts.
  • Mouse (P2.2): SGR reports reach Node in Windows Terminal and Zed (P0.1's probe, research S-FULLSCREEN); decode SGR only and drop X10 by UTF-8 byte count (two raw bytes can arrive as one character). A mouse event that changes nothing must draw nothing. Scrolling keys sit after modals, popups, overlays, Vim's Esc and the side panel, and before the effort keys (matchKey ignores Shift on symbols, so Alt+Shift+, would otherwise step the effort). Seed fullscreen app tests with renderer.commit (one row per line); app.commit adds a blank line between cells.
  • Select, copy, Find (P2.3): layer order is modal → confirm → Find (editing) → selection → pager → scroll keys; Ctrl+C copies a selection before onCtrlC. Never rebuild a row with normalize([...prefix, ...l]) (it drops the soft-wrap marker): use withPrefix. footer() returns one line, not a list. A mutation that survives because "the other path also does nothing" usually means the test sits where both paths agree (a match at the bottom follows anyway): move the case. A pager selection's keys must also sit before onCtrlC, or Ctrl+Shift+C closes the pager. An empty (blank-cells) selection copies nothing and says nothing.
  • Handoffs and modes (P2.4, P2.5): the editor is a keep-screen handoff (freeze()/unfreeze() inside io's handoff, before its modes come back); /login and /ad are restore handoffs with the Enter wait. Check keyboard-stack balance with a per-screen push/pop model over the written bytes: it found a resume re-entering after io was restored. Programs (tmux, clipboard tools) go through resolveCommand, never a literal name (the no-shell lint). tui-live passes --no-alt-screen unless a case asks for fullscreen; a selection survives a copy, so clear it (Esc) before testing Ctrl+C quit.
  • Status line and title: items are the engine's ids in tui/status.mjs; every value through titleSafe; the title is never written while holdTitle(true) (a handoff). A new item needs the engine's id (or it stays "unsupported"). A bare percentage means "left" (ctx 94%); anything counting up says "used" (ctx 6% used, 5h 96% used): the owner read a bare 5h 96% as 96 % left.
  • Test pitfalls met: match long paths and hints with \s+ (they wrap); give "too soon" assertions a long arm delay (500 ms) and waits that end early on success a generous cap (a 50 ms query timeout flaked under load); until() must await async predicates; if a mutation isn't caught, change the setup until the old and new behaviour differ on screen. In Git Bash, --test-name-pattern="/cd" matches nothing (the slash is turned into a path): prefix that one command with MSYS_NO_PATHCONV=1, never export it for the shell (git honours it and skill-install.test.mjs's local-repo test fails). Capture a live run to a file before filtering it: a grep that keeps only ok/not ok lines eats the error block.
  • Subagent run state lives in session.state.agents (noteAgent/trackAgents in harness/session.mjs): running from turn.started/turn.completed and subAgentActivity hints, stopped so a late "started" hint for a finished agent is ignored (the engine's stopped_threads guard), closed from thread.closed. An engine restart clears every running (the engine that ran them is gone), as it clears terminals. /cd refuses while any agent is running && !closed, as the engine's app/working_directory.rs does; the fake's subagents-2 prompt leaves one running and sends a late hint, so it is the fixture for both guards.
  • Keys: a key ad handles is an action in tui/keymap.mjs (the engine's context, action name and default keys from codex-rs/tui/src/keymap.rs), checked with keymap.is(ctx, action, ev), never a hard-coded test. A new key needs a row in docs/tui.md (a test checks every default key is named there) and a cell in the key routing table test when a layer is involved. Colours come from tui/view/theme.mjs only (a test fails on a colour literal elsewhere).
  • Requests to the engine: a new request("method") call needs the method in SENT_METHODS (engine/codex/protocol-snapshot.mjs) and a regenerated snapshot (node scripts/codex-schema-snapshot.mjs, pinned binary, temp home). The fake app-server rejects params the stable protocol lacks; an experimental method or field must be on EXPERIMENTAL_ALLOWLIST (engine/codex/surface.mjs) on purpose.

Procedure

Engine-originated failures must retain their classification through wrappers: use the façade's engineErrorText for the screen and engineErrorDetail for diagnostics. A failed engine result converted to a plain Error loses that classification. Preserve raw text separately, including startup and side-conversation failures; only /warnings renders it (sanitized, eight lines per entry), including detail-bearing info notices. CLI output uses cliErrorLines; browser errors point to the server log.

  1. Load the state.
    • Read docs/tui-architecture.md (layers, invariants, decisions); when present locally, the status block of docs/plans/ad-tui.md.
    • Read .agent-daemon/memory/activeContext.md.
    • Check git status -sb: work happens on a feature branch in its own git worktree, never on main and never in a checkout a global ad is npm-linked to (the user's own hooks would run your branch against their real home).
  2. Follow the plan's loop for the (sub-)part, and finish its Done-when. Interfaces are fixed; changing one needs a revision-log entry. If the plan and the code disagree, verify against the code and fix whichever is wrong.
  3. Never touch the user's own install.
    • Every real engine process starts through codexEnv() (runtime/src/engine/codex/home.mjs).
    • Tests use temp homes, a source: "test-double" fake, or the real-engine suite (AD_REAL_ENGINE=1).
    • Never use ~/.codex or the user's global install.
    • Elevated Windows sandbox setup is machine-wide: ask first.
  4. Renderer rules (inline mode; why: docs/tui-architecture.md):
    • Live region:
      • never 2J or 3J;
      • live rows are drawn with autowrap off (?7l) and are at most cols − 1 wide;
      • the live region never exceeds rows − 1;
      • skip cursor moves whose count is 0 (ESC[0A moves one row);
      • send ESC[0m before every \r\n, ESC[K and ESC[J.
    • Frames and history:
      • one write() per frame inside ?2026;
      • history is committed by overpainting, also with autowrap off (lines are pre-wrapped);
      • width: measure with tui/terminal/width.mjs, which defaults to the codepoint profile (safe). Use grapheme only for Windows Terminal or after the CPR probe;
      • without 2026, commit at most every 150 ms.
    • The engine's own inline mode does not pin the prompt (insert_history.rs at 0.160 moves the viewport down only by the inserted rows); the bottom prompt comes from its fullscreen mode, which ad's pinBottom reproduces inline.
    • Prompt at the bottom (pinBottom, on unless prompt.atBottom is false in prefs.json): the live region ends on the last row; gap (blank rows between history and the live top) and slack (blank rows below the live region or the empty cursor row) are lower bounds. A width change re-anchors with gap 0, because a reflow may have moved history into those rows; suspend and dispose erase the gap, so what follows starts right after history. The renderer tests and their property test run both with and without it.
    • Resize: pause, wait 75 ms, then re-anchor with a cursor-position query and the terminal's reflow model. Ctrl+L is re-anchor plus redraw.
      • The rows-above-cursor estimate must be a lower bound: an over-count erases history, an under-count only leaves a ghost row.
      • Any await on CPR is a race. Re-anchors carry a generation, so a resize, redraw or suspend that arrives meanwhile makes them write nothing.
      • Test races with cprDelayMs in testkit/screen.mjs.
    • Cursor: never restyle it or force a blink, and never redraw an idle screen (each write restarts the blink).
    • Exit: restore synchronously (fs.writeSync) on every exit path.
    • Untrusted text: always through sanitize() (tui/terminal/sanitize.mjs): "transcript" strips controls, "approval" makes every invisible visible. Sanitize the accumulated text, not each delta.
    • Source files: write invisible characters as \u{...} escapes. The Write/Edit tools decode 4-hex \uXXXX into raw characters. Check with grep -nP '[^\x00-\x7F]'.
  5. Windows input facts:
    • VT input needs Node 22.17+ or 24.2+.
    • Windows Terminal 1.24 and VS Code send Shift+Enter as Enter. Zed sends Shift+Enter as LF (0a), and Windows Terminal sends Ctrl+Enter as LF. Rule (FC0): 0d submits and 0a is a newline; the footer hint is per terminal.
    • Primary live-check terminals are Windows Terminal and Zed (the user's daily terminal). VS Code is secondary.
    • Alt+Enter is taken by Windows Terminal's fullscreen toggle, and Ctrl+V by its paste.
    • The kitty keyboard protocol arrives in Windows Terminal 1.25.
    • When in doubt, run node runtime/scripts/tui-probe.mjs keys|screen and read ~/.agent-daemon/logs/tui-probe-*.log.
  6. Protocol facts:
    • Stable surface, except EXPERIMENTAL_ALLOWLIST (engine/codex/surface.mjs): only the TUI's engine sends experimentalApi: true (experimentalCapabilities(), which also opts out of every experimental notification ad doesn't handle). A new experimental use needs the user's OK, the allowlist, a regenerated snapshot (its experimental section) and a real-engine test.
    • Plan mode (the engine's collaboration modes): the session owns it (state.mode, setMode, cycleMode, modeMask); every turn/start gets the mask in startTurn only, so no turn path can miss it. Default's mask carries the user's own model/effort (never values read back after a plan turn); thread/settings/updated and the resume response set the mode; newThread() resets to Default. "Implement this plan?" opens from draw() via maybeOfferPlan() in a microtask (opening a dialog redraws), never over a draft, a popup or a request.
    • Background terminals (the engine's unified exec): a commandExecution with source: "unifiedExecStartup" is one until its item/completed, which comes only when the process exits (possibly long after the turn). session.state.terminals keys them by the engine's processId; flush() never stops at a running one: once anything follows it (or its turn ends) its cell is committed as it is, "Running" while the process lives, and its end is drawn as a new cell during a task (the engine's rule; every exec_command is such an item while it runs, so never drop one silently); /stop = thread/backgroundTerminals/clean (allowlisted), after which the engine completes them as failed, exit -1.
    • The folder is mutable (/cd): the session's state.config.cwd is what every thread start, resume and restart uses (never a captured cwd); session.changeDir() forks with cwd + excludeTurns: true (the history is on screen) and moves the lock; main.mjs actions.setCwd() rebuilds the ad layer and /undo's checkpoints (session.setHooks). A new folder-bound helper must read the current folder when used.
    • Side conversations: a second createSession({secondary: true}) on the same engine (no requests, no global events, no lock, no restarts; adopt(threadId)), made by the main session's openSide(). The pinned engine ignores a fork's developerInstructions, so the side texts go in through thread/inject_items (developer + user items). The app keeps one composer; side.focus decides where Enter, Esc, Ctrl+C and /commands go; the panel replaces main's live items (main shows only its status rows).
    • Vim mode (view/vim.mjs, the engine's 0.160 semantics, no counts or Visual mode): the composer's setVim() puts Vim in front of its own editing; Vim returns undefined for Insert typing (the composer edits), null for keys the app owns (Esc with nothing pending, Ctrl shortcuts). The app must offer Esc to composer.vim.wantsEscape() before its interrupt key. Esc and the next key sent together decode as Alt+key (a fast Esc), which Insert treats as Esc then the key; tests send Esc alone. The binding table is test/tui-vim.test.mjs.
    • Syntax highlighting (view/highlight.mjs): one table-driven lexer with per-line state (highlightLines(lang, lines, state) → {lines, state}), sticky regexes and caps (MAX_LINES, MAX_LINE, MAX_CHARS) so it stays linear. Never a regex with a lazy or nested quantifier over a whole line (/^(\s*)(.*?)(\s+#.*)$/ was quadratic on runs of spaces and threw on U+2028): scan with indexOf; the hostile test times every language on space runs at the caps. highlightLines guards each line (a throw, or tokens that don't add up to the line, make it plain). Theme colours live in theme.mjs (SYNTAX, the colours-have-one-home test). A streaming fence carries fenceState between the chunks it commits and into its live tail (createMarkdownStream). A diff's context rows are lexed as the new file reads (+ and context rows), each hunk from a fresh state (contextTokens in cells.mjs). The current theme is module state (setSyntaxTheme): tests that change it must put back ansi; a preview draws in another theme with withSyntaxTheme(name, fn), never by setting it.
    • Subagents (/subagents): state.agents keeps the engine's rows in first-seen order (noteAgent in the session: thread/started with a parent, collabAgentToolCall.receiverThreadIds unless notFound, subAgentActivity; running from their turns, closed from thread/closed). An agent's items are in state.items with its threadId; the pager (pager.agent) re-renders them on every draw. ad watches an agent beside the conversation instead of switching to it as the engine's own UI does, so prompts never go to an agent by accident.
    • Temporary threads (/recap): engine.structuredRequest() is the engine's temporary structured request (ephemeral, read-only, every tool, hook and MCP server off, threadSource: "system", unsubscribed after). Its events reach no session subscriber (no parent link), so they never land in the transcript.
    • Prompts the client writes (the engine's clear-context plan hand-off) must be skipped by ad's prompt hooks: hooks/generated-prompts.mjs.
    • Errors are -32600 with distinct messages; -32601 only means an operation is unsupported.
    • availableDecisions leaks through on exec approvals only. The engine's fallback applies otherwise. A real exec approval offers y / p ("don't ask again for commands starting with …") / Esc ("No, and stop": declines and ends the turn); n only when the engine offers "No, and say what to do instead" (patch approvals).
    • Item ids are not unique across turns with some providers (msg-1, call_0): the session keys a later turn's item <id>@<turn>, the app keys shown items per turn, and requests resolve the engine's id with session.itemFor(id, turnId).
    • The turn/start response can arrive before the turn/started notification: never rely on state.starting inside turnStarted.
    • item/started is not ordered before the disk write of a patch: nothing safety-related may depend on that race.
    • The engine's own diff of an applied patch matches git's line counts (verified live for Update File).
    • MCP tool approval per server: default_tools_approval_mode = auto | prompt | writes | approve (and per-tool approval_mode).
    • The engine runs commands in the first pwsh on PATH (PowerShell 7 adds its own folder to its children's PATH); the Windows sandbox can't start a Store (MSIX) app, so withoutStoreAliases() (engine/codex/app-server.mjs) drops every PATH entry with a WindowsApps segment at every engine spawn, always on Windows (the alias folder and the Store package folders).
    • The first sandboxed action in a fresh engine home sets up the Windows sandbox: about 35 s once (longer on a busy machine).
    • Details, when present locally: docs/research/codex-tui-and-app-server.md. 6a. /undo invariants (docs/tui-architecture.md#undo-the-safety-model): it never destroys or reverts the user's work on its own (only /undo force discards changes made after the agent's edit, and only those), and refuses rather than guesses.
    • The "before" snapshot is awaited before turn/start (10 s cap): never let it race the agent.
    • Every applied edit is hashed when it completes and its own diff lines recorded; the chain check compares the lines each step changed with that edit's own lines (summed or per-edit counts were both broken by review).
    • Paths go through realPath() (8.3 names, junctions, subst); ad's own .agent-daemon/, heavy folders, ignored and big files are never snapshotted; restores use a scratch index (never the user's index.lock).
    • Only the "changed since…" conflicts are forceable; a file moved in from outside the repo is "not in the checkpoint", never deleted.
    • Prove each case on a real temp repo in test/checkpoints.test.mjs, and mutation-check it. 6a2. Start-up isolation (P0.2a): the launcher runs before the engine starts (tui/home-launcher.mjs, engineHome.homeLayerRisk); /cd asks engine.homeFolderRisk before forking; the façade refuses any thread whose folder loads the user's ~/.codex unless allowHomeFolder. Tests that fake a home set HOME and USERPROFILE (under node --test the real account's ~/.codex is never read: riskUserHomes). In a pty test on Windows always child.kill() after the child exited, or ConPTY handles keep the test file from exiting; the full TUI quits with Ctrl+C twice and its composer reads "? for shortcuts". 6a3. Commit batching (no ?2026): commits within 150 ms of the last wait in queued; frame() flushes them in its own write, because the app removes a finished cell from the live region in the same tick it commits it. Without that, the screen briefly lacks the cell (pinBottom shows blank rows where it was). 6a4. Keys and chords: every key goes through keymap.chords().feed first, then keymap.is(ctx, action, ev). Never add a second matcher: a chord completes as a synthetic chord event that is() answers. The rules follow the engine's keymap/chords.rs and leader.rs (0.163): an unmatched second key is passed through, Esc or the leader again cancels, an ordinary chord expires after 1 s (its hint cleared), a leader chord never times out. Chord matching is off while a modal, confirm or inspector owns the keys. 6b. Live tests (test/tui-live.test.mjs, practices in docs/testing.md): wait for state not time, count occurrences instead of matching once (the scrollback keeps old text), wait for the idle footer before the next prompt, close every pty in finally, approve "retry without sandbox?" on runners that can't sandbox. Reproduce a user's live transcript in a temp home (same shell, same PATH) before fixing it.
  7. Before every commit: run the project's ad-pre-push skill (docs, CHANGELOG, skills, learnings, hygiene, link check). In particular:
    • cd runtime && node --test;
    • the goldens (UI changes) and a mutation check of each new guard;
    • the real-engine suite when engine code changed;
    • npm run lint:skills when skills changed;
    • docs/tui.md (and the TUI entries of docs/troubleshooting.md) when keys, commands, messages or files changed: the docs must match the code;
    • read the git diff (scripted edits have corrupted files before).
    • Commit by file name. Push only with the user's OK.

Examples

Example 1: continuing after a break

The plan's progress file says which parts are done and which wait on the owner (a live check in Windows Terminal and Zed, a default to pick). Pick up what is open without them (a backlog item, a review finding), and run AD_REAL_ENGINE=1 node --test test/tui-live.test.mjs after any change to the app, session or views.

Example 2: a renderer bug report

"Status line duplicates after resizing in VS Code." Ask for a screen --bottom probe log and a screenshot (troubleshooting #25), reproduce the sequence in an @xterm/headless test under both reflow models, fix the re-anchoring, and add the case to the property tests.

Anti-patterns

  • Reaching the engine's raw server. Use the façade's capabilities (engine.threads.*, engine.turns.*, …); a new protocol method means a new capability in engine/index.mjs, an entry in SENT_METHODS, and the brand lint's rule B stays at zero.
  • Starting the real engine without codexEnv(), or reading or writing ~/.codex.
  • Changing what bare ad opens (TUI_IS_DEFAULT in src/tui/flip.mjs) without the user.
  • Teaching the fake a message the real engine can't send. It must pass protocol-check.
  • Adding a slash command that takes the engine's name for something else, or a key without updating the ? overlay and docs/tui.md.
  • Reaching for an experimental protocol method because it's convenient.
  • Editing files that contain backslashes or $ through shell heredocs. Use the Write/Edit tools, and give replace() a function.
  • Moving to the next part with a red test or an open medium-or-worse finding (see big-feature-flow).
  • Basing a safety check on a timing race or on totals. /undo went through "awaited 1 s" (busy machines lost checkpoints), "not awaited, race the first edit" (commands slipped into the 'before'), "summed counts" and "per-edit counts" (user changes hid) before the awaited snapshot + per-line chain held up under review.
  • Trusting a green CI run without reading the real-engine job (it may fail without failing the run).
  • Personal names, paths or client projects in tests, goldens or examples (use shop-app, work, Sam).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.