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.
In this repository174 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- ad-ci-readinessSkill
- ad-pre-pushSkill
- accessibilitySkill
- agent-evalSkill
- agent-harness-constructionSkill
- agentic-engineeringSkill
- agent-introspection-debuggingSkill
- agent-sortSkill
- ai-first-engineeringSkill
- ai-regression-testingSkill
- api-connector-builderSkill
- architecture-decision-recordsSkill
- article-writingSkill
- automation-audit-opsSkill
- autonomous-agent-harnessSkill
- autonomous-loopsSkill
- backend-patternsSkill
- benchmarkSkill
- blueprintSkill
- brand-voiceSkill
- browser-qaSkill
- bun-runtimeSkill
- canary-watchSkill
- cavemanSkill
- ckSkill
- claude-devfleetSkill
- click-path-auditSkill
- codebase-onboardingSkill
- code-tourSkill
- coding-standardsSkill
- content-engineSkill
- content-hash-cache-patternSkill
- context-budgetSkill
- continuous-agent-loopSkill
- continuous-learningSkill
- continuous-learning-v2Skill
- cost-aware-llm-pipelineSkill
- councilSkill
- crosspostSkill
- ad-harnessSkill
- ad-state-migrateSkill
- agent-daemon-memorySkill
- big-feature-flowSkill
- bootstrap-daemonSkill
- bug-flowSkill
- engine-upgradeSkill
- feature-flowSkill
- gepa-evolve-inlineSkill
- harness-troubleshootSkill
- orchestrate-teamSkill
- release-flowSkill
- session-closeSkill
- skill-authorSkill
- skill-installerSkill
- dashboard-builderSkill
- database-migrationsSkill
- deep-researchSkill
- deployment-patternsSkill
- The first 60 shown.
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
- Very specific: line 22
All paths under runtime/.
- Very specific: line 95
The fake must stay honest: every message fake-codex-app-server.mjs sends in the main scenarios must pass protocol-check (tui-resilience …
- Very specific: line 113
Read docs/tui-architecture.md (layers, invariants, decisions).
- Very specific: line 115
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 …
- Very specific: line 118
Every real engine process starts through codexEnv() (runtime/src/engine/codex/home.mjs).
- Very specific: line 120
Never use ~/.codex or the user's global install.
- Very specific: line 124
never 2J or 3J;
- Very specific: line 139
Test races with cprDelayMs in testkit/screen.mjs.
- Distinctive: line 140
Cursor: never restyle it or force a blink, and never redraw an idle screen (each write restarts the blink).
- Very specific: line 142
Untrusted text: always through sanitize() (tui/terminal/sanitize.mjs): "transcript" strips controls, "approval" makes every invisible …
- Very specific: line 143
Source files: write invisible characters as \u{...} escapes.
- Very specific: line 150
When in doubt, run node runtime/scripts/tui-probe.mjs keys|screen and read ~/.agent-daemon/logs/tui-probe-*.log.
- Distinctive: line 174
Every applied edit is hashed when it completes and its own diff lines recorded.
- Distinctive: line 176
Only the "changed since…" conflicts are forceable.
- Very specific: line 182
Before every commit: run the project's ad-pre-push skill (docs, CHANGELOG, skills, learnings, hygiene, link check).
- Distinctive: line 188
read the git diff (scripted edits have corrupted files before).
- Distinctive: line 189
Commit by file name. Push only with the user's OK.
- Very specific: line 203
Reaching the engine's raw server: Use the façade's capabilities (engine.threads.*, engine.turns.*, …).
≈ “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.
Files
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/.
| Path | What |
src/cli.mjs | thin launcher: routes tui and bare ad / ad --last before loading cli-full.mjs (the rest of the CLI). Keep it light |
src/tui/flip.mjs | bare 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.mjs | why the TUI can't run here (TTY, TERM=dumb, Node on Windows, mintty), with a working alternative |
src/tui/main.mjs | cmdTui: 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.mjs | the app: input → intents, session events → frames, scrollback commits, keys, SLASH_COMMANDS (each marked source: "upstream" or "ad"), slashCollisions |
src/tui/commands.mjs | Part 8 renderers: /mcp, /hooks, /skills, /usage, /terminal-setup, transcript pager, /export markdown, /copy clipboard, image paths |
src/tui/ad-layer.mjs | Part 9: /memory, learned rows, /proposals, /loop, /team, /schedule, scheduler warning |
src/tui/undo.mjs | Part 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.mjs | prompt history JSONL (~/.agent-daemon/tui/history.jsonl) |
src/tui/keymap.mjs | every key ad handles as an action (the engine's contexts and defaults), tui.keymap overrides, chords and the leader key |
src/tui/status.mjs | status-line and title items (the engine's ids, plus ad's own statusExtra items) |
src/tui/screen-mode.mjs | inline 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.mjs | the engine's /init prompt, vendored (re-copy on engine upgrades) |
src/tui/preview.mjs | ad tui --preview, a minimal preview UI on the same engine (the first walking skeleton) |
src/engine/codex/upstream-commands.json | the 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.mjs | the 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.mjs | UI-agnostic session controller: submit / steer / queue, requests, restarts, thread locks (~/.agent-daemon/locks) |
src/harness/checkpoints.mjs | git-tree snapshots under refs/ad/checkpoints/, private index, restore with conflict check, UNDO_LIMITS |
src/engine/codex/events.mjs | protocol traffic → AdEvents; compat.json holds tested versions and "what changed" |
testkit/protocol-check.mjs | validates messages against the pinned protocol snapshot |
testkit/tui-fake.mjs | runs ad tui on fake-codex-app-server.mjs in a temp root (pty smoke) |
testkit/golden.mjs | byte-for-byte goldens; AD_UPDATE_GOLDEN=1 rewrites them |
testkit/screen.mjs | test terminals: xtermScreen (xterm.js, reflows) and modelScreen (clips); cprDelayMs for races |
scripts/tui-probe.mjs, scripts/tui-demo.mjs | live 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) orsession.useEngine(a new conversation on another home), neverthread/resumewith 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
openaiprovider entry, sosession.retarget.planalone 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 stubactions.copy, or the real clipboard tool runs.
The session ledger's observer (P4.1)
createSession({ observer })is the ledger's seam; thehooksobject stays/undo's.observe(ev)sees every thread event (top ofonThreadEvent), each user message once (user.messagewithviasubmit / steer / queue),request.answeredwith the decision only (a question's or form's answer becomes"answered"),thread.attached/thread.detachedandengine.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 ownnoticetext) 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'stype/commandActions;sessions/coordinator.mjsasRawbridges them foreffects.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 loophave no session:recordEngineThreadfeeds the same coordinator fromengine.subscribe, and muststop()before the engine is released (else the release reads as a lost engine).- Tests that pass an engine
homerecord nothing unless they passadRoottoo (ledgerEnabled), so no test writes the real~/.agent-daemon/sessions/. main.mjs's start-up on ad's own homes (thelaunched.leasepath) is covered only bytui-live.test.mjs: an identifier used there without its import (P3.8b'sopenUrl) passed the whole unit suite and crashed everyad tuistart. Run the live suite after anymain.mjschange.
Switching a conversation (P4.2)
sessions/switch.mjsdecides (pure:switchHow,safePoint,step,recoverSwitch);sessions/switch-runner.mjsdoes it;sessions/capsule.mjsbuilds the continuation's history. The fork and same-account paths aresession.retarget(P3.4's one routine); only the continuation path is new, and it reusesengine/switch.mjsletGofor 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. safePointchecks 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 })andadoptThreadcarry the queue, the mode and the old live terminals (state.priorTerminals, shown by/ps, stopped by/stopthrough their own engine), and detach with why "switched" so the ledger keeps the session. The Plan mask usesstate.config.modelwhenever the binding names its own provider. - The capsule drops reasoning itself: the engine accepts and forwards a foreign
reasoningitem inthread/inject_items(S-RESUME). - Fake engine:
early-completenever sendsturn/started(usefail-turnwhen the ledger must see the turn);test/start-turnstarts 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.mjsbuilds 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
/fallbackeditor 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/statuslinecarries them asad: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. Alsosession,checkpoints,codex-events.test.mjs,codex-protocol-snapshot.test.mjs,engine-realandtui-live(bothAD_REAL_ENGINE=1;tui-liveis the FC3/FC4 script on the realad tuiin 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 withAD_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.mjssends in the main scenarios must passprotocol-check(tui-resilienceenforces it). When you add a fake message, give it the real shape (fullThreadobjects,startedAtMs, the approval'sitemId, …), 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 againstengine/codex/upstream-commands.json(slashCollisions), a row indocs/tui.md, and/helpcoverage. Never point users to the engine's own UI. Every upstream command is either asource: "upstream"row inSLASH_COMMANDSor an entry inNOT_IN_AD(what ad says instead; never sent to the model): implementing one means removing it fromNOT_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.writeSyncand the renderer'swritable()gate (neverstdout.write: on Windows it is asynchronous and could land after the leave bytes); the alt-screen guard is marked before?1049hand released first by every exit path; never sendCSI 3Jin fullscreen (it wipes the shell's scrollback); commit cells ascommitView(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 (
matchKeyignores Shift on symbols, so Alt+Shift+, would otherwise step the effort). Seed fullscreen app tests withrenderer.commit(one row per line);app.commitadds 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 withnormalize([...prefix, ...l])(it drops the soft-wrap marker): usewithPrefix.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 beforeonCtrlC, 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);/loginand/adare 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 throughresolveCommand, never a literal name (the no-shell lint).tui-livepasses--no-alt-screenunless 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 throughtitleSafe; the title is never written whileholdTitle(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 bare5h 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 withMSYS_NO_PATHCONV=1, never export it for the shell (git honours it andskill-install.test.mjs's local-repo test fails). Capture a live run to a file before filtering it: a grep that keeps onlyok/not oklines eats the error block. - Subagent run state lives in
session.state.agents(noteAgent/trackAgentsinharness/session.mjs):runningfromturn.started/turn.completedandsubAgentActivityhints,stoppedso a late "started" hint for a finished agent is ignored (the engine'sstopped_threadsguard),closedfromthread.closed. An engine restart clears everyrunning(the engine that ran them is gone), as it clearsterminals./cdrefuses while any agent isrunning && !closed, as the engine'sapp/working_directory.rsdoes; the fake'ssubagents-2prompt 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 fromcodex-rs/tui/src/keymap.rs), checked withkeymap.is(ctx, action, ev), never a hard-coded test. A new key needs a row indocs/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 fromtui/view/theme.mjsonly (a test fails on a colour literal elsewhere). - Requests to the engine: a new
request("method")call needs the method inSENT_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 onEXPERIMENTAL_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.
- Load the state.
- Read
docs/tui-architecture.md(layers, invariants, decisions); when present locally, the status block ofdocs/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 globaladis npm-linked to (the user's own hooks would run your branch against their real home).
- Read
- 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.
- 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
~/.codexor the user's global install. - Elevated Windows sandbox setup is machine-wide: ask first.
- Every real engine process starts through
- Renderer rules (inline mode; why:
docs/tui-architecture.md):- Live region:
- never
2Jor3J; - live rows are drawn with autowrap off (
?7l) and are at mostcols − 1wide; - the live region never exceeds
rows − 1; - skip cursor moves whose count is 0 (
ESC[0Amoves one row); - send
ESC[0mbefore every\r\n,ESC[KandESC[J.
- never
- 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 thecodepointprofile (safe). Usegraphemeonly for Windows Terminal or after the CPR probe; - without 2026, commit at most every 150 ms.
- one
- The engine's own inline mode does not pin the prompt (
insert_history.rsat 0.160 moves the viewport down only by the inserted rows); the bottom prompt comes from its fullscreen mode, which ad'spinBottomreproduces inline. - Prompt at the bottom (
pinBottom, on unlessprompt.atBottomis false in prefs.json): the live region ends on the last row;gap(blank rows between history and the live top) andslack(blank rows below the live region or the empty cursor row) are lower bounds. A width change re-anchors withgap0, 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
cprDelayMsintestkit/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\uXXXXinto raw characters. Check withgrep -nP '[^\x00-\x7F]'.
- Live region:
- 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):0dsubmits and0ais 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|screenand read~/.agent-daemon/logs/tui-probe-*.log.
- Protocol facts:
- Stable surface, except
EXPERIMENTAL_ALLOWLIST(engine/codex/surface.mjs): only the TUI's engine sendsexperimentalApi: 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 (itsexperimentalsection) and a real-engine test. - Plan mode (the engine's collaboration modes): the session owns it (
state.mode,setMode,cycleMode,modeMask); everyturn/startgets the mask instartTurnonly, 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/updatedand the resume response set the mode;newThread()resets to Default. "Implement this plan?" opens fromdraw()viamaybeOfferPlan()in a microtask (opening a dialog redraws), never over a draft, a popup or a request. - Background terminals (the engine's unified exec): a
commandExecutionwithsource: "unifiedExecStartup"is one until itsitem/completed, which comes only when the process exits (possibly long after the turn).session.state.terminalskeys them by the engine'sprocessId;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'sstate.config.cwdis what every thread start, resume and restart uses (never a capturedcwd);session.changeDir()forks withcwd+excludeTurns: true(the history is on screen) and moves the lock;main.mjsactions.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'sopenSide(). The pinned engine ignores a fork'sdeveloperInstructions, so the side texts go in throughthread/inject_items(developer + user items). The app keeps one composer;side.focusdecides 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'ssetVim()puts Vim in front of its own editing; Vim returnsundefinedfor Insert typing (the composer edits),nullfor keys the app owns (Esc with nothing pending, Ctrl shortcuts). The app must offer Esc tocomposer.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 istest/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 withindexOf; the hostile test times every language on space runs at the caps.highlightLinesguards each line (a throw, or tokens that don't add up to the line, make it plain). Theme colours live intheme.mjs(SYNTAX, the colours-have-one-home test). A streaming fence carriesfenceStatebetween 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 (contextTokensincells.mjs). The current theme is module state (setSyntaxTheme): tests that change it must put backansi; a preview draws in another theme withwithSyntaxTheme(name, fn), never by setting it. - Subagents (
/subagents):state.agentskeeps the engine's rows in first-seen order (noteAgentin the session:thread/startedwith a parent,collabAgentToolCall.receiverThreadIdsunlessnotFound,subAgentActivity; running from their turns, closed fromthread/closed). An agent's items are instate.itemswith itsthreadId; 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.
availableDecisionsleaks through on exec approvals only. The engine's fallback applies otherwise. A real exec approval offersy/p("don't ask again for commands starting with …") / Esc ("No, and stop": declines and ends the turn);nonly 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 withsession.itemFor(id, turnId). - The
turn/startresponse can arrive before theturn/startednotification: never rely onstate.startinginsideturnStarted. item/startedis 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-toolapproval_mode). - The engine runs commands in the first
pwshon PATH (PowerShell 7 adds its own folder to its children's PATH); the Windows sandbox can't start a Store (MSIX) app, sowithoutStoreAliases()(engine/codex/app-server.mjs) drops every PATH entry with aWindowsAppssegment 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./undoinvariants (docs/tui-architecture.md#undo-the-safety-model): it never destroys or reverts the user's work on its own (only/undo forcediscards 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'sindex.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);/cdasksengine.homeFolderRiskbefore forking; the façade refuses any thread whose folder loads the user's~/.codexunlessallowHomeFolder. Tests that fake a home set HOME and USERPROFILE (under node --test the real account's~/.codexis never read:riskUserHomes). In a pty test on Windows alwayschild.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 inqueued;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 throughkeymap.chords().feedfirst, thenkeymap.is(ctx, action, ev). Never add a second matcher: a chord completes as a syntheticchordevent thatis()answers. The rules follow the engine'skeymap/chords.rsandleader.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 indocs/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 infinally, 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.
- Stable surface, except
- Before every commit: run the project's
ad-pre-pushskill (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:skillswhen skills changed;docs/tui.md(and the TUI entries ofdocs/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 inengine/index.mjs, an entry inSENT_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
adopens (TUI_IS_DEFAULTinsrc/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 anddocs/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 givereplace()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.
/undowent 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).
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
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.

