Map: exarch / frontend
The agent core and the user interface meet at one outbound event stream and
one inbound inbox, mapped by bus.rs’s module doc across its submodules:
- workers stamp a
Record—Protocol/Display/Forensic(below) — throughrecord::Emitter::emit(record/seam.rs), which appends torecord.jsonland publishes the witnessedRecorded<Record>onto the fleet channel in one lock; the channel’s other passenger is a live-onlyTransient— a streamed token or reasoning delta, a state transition, a worker’s birth/death, the seam’s own fault — with no sequence number and no durable form, published throughEmitter::transient. Together these are the twoSignalvariants (bus/signal.rs) aSink(bus/sink.rs) consumes.AgentStateis the five states an agent is ever in (Ready/AwaitingModel/Evaluating/Compacting/WaitingOnAgents— a total state named on the status rule, never recorded: the model never sees it), riding asTransient::State. A decoded surface class — aCardrender document a kit raises through thesurfacebuiltin, a structural observation, a housekeeping notice, aPin/Unpin, or a worker’sdone— is decoded by shell-eval’sdecode_surfaceinto its own closedSurfacevocabulary first, then recorded as aDisplaycommit by the commit producer (record/commit.rs) and drawn by one generic interpreter (cards). Inbox(bus/inbox.rs) is the typed inbound twin — a per-session queue ofPosts (bus/post.rs), each carrying its source and drain boundary. User steering drains mid-exchange at a tool boundary (drain_steering); a scheduled wakeup or a settled async agent drains at the exchange boundary as its own markedItem(next_item). The boundary is three-valued, because waiting for a settled session and being owed an audience first are different debts: aBoundary::Exchangepost — a session command that only reads the context (/branch,/context,/resources) — waits for the exchange boundary, butdrain_steeringpasses over it to the prompts queued behind, which it changes nothing about. ABoundary::Barrierpost stops the scan dead, because there the order the human typed is part of what they said: a command that rewrites or ends the context (Post::Barrier—/clear,/compact,/rewind,/quit), or a slash-prefixed steering line, which is itself prompt text and so must reach the model ahead of the prompt text typed after it. Without that split a/branchtyped mid-turn corked every prompt behind it until the turn ended (tool-boundary-steering, scheduled-wakeups, async-agent-tool).- a
FleetBus(bus/emitter.rs) owns the channel and the inbox;pump(bus/sink.rs) borrows it, runs the worker on a scoped thread, drains signals into the sink, and reports a worker panic as a recordedForensic::Error, prefixed so a sink can tell it from a clean completion (bus::WORKER_PANIC_PREFIX). Completion is the per-exchangedoneflag, latched bydrain_signals(bus/sink.rs, mirrored by the TUI’s own copy intui/tui_loop.rs) so an exchange ends even while a background producer keeps the channel non-empty — never the channel’s state (run-turn-host-loop). - the channel itself (
bus/channel.rs) is bounded and coalescing, not a barempscpair (BusSender/BusReceiver, samesend/try_recv/recv_timeoutshape): pushingToken/Thinking(concatenate) orState(replace) merges into the queue’s tail entry when it is the same class and the same agent id; every otherSignal— everyFact, and every otherTransient— is reserved and always enqueued on its own, so a producer flood can only ever grow one coalescing run, never bury or reorder a fact. A mergedToken/Thinkingrun’s text is capped (MERGE_TEXT_CAP, 256 KiB); past it the front elides and oneTransient::Faultoverflow marker rides the next drain, naming the class and the elided count./resourcesreadsBusReceiver::depth/bytesfor itsbus.depth/bus.bytesrows. - what the channel carries is
Signal(bus/signal.rs): a seam-witnessed fact (Signal::Fact, anAgentIdbeside itsRecorded<Record>) or an unrecorded delta (Signal::Transient, theAgentIdbeside itsTransient) — one channel, so the TUI’s single per-fleet dispatch loop routes both byAgentIdwithout restructuring.
record.rs + record/ is the one-seam-one-log module tree
(session-record): the sealed
Record { Protocol, Display, Forensic } vocabulary and the disjoint
Transient; record/seam.rs (Emitter::emit, the only publisher —
append-then-publish under the log’s own mutex, so channel order is log
order); record/log.rs (the record.jsonl io-door, with the attachable
FleetSink inside the writer’s mutex); record/replay.rs (the generic
fold == memo driver and Refusal, with Log::read streaming one entry at a
time); record/commit.rs (the worker-side
commit producer: one Chopper per lane of the model’s stream, and
SurfaceBuffer, moved whole from tui/surface.rs); record/model.rs (the model fold, Protocol alone, with
streaming resume/admission, and the Transcript persistent value + closed-span
render cache the provider-facing projection is built from — see
the transcript-as-value section
and the-transcript-is-a-value);
record/view.rs (the view fold into Blocks; block construction is private).
Viewport and Headless both implement record::Printer
(transient/sync): a live Signal::Fact steps the view fold beside the
printer and re-syncs it (Viewport::commit_fact, Headless::absorb),
the same fold resume seeds with one Printer::sync call from a record.jsonl
replay — so the live path and resume draw through the identical fold.
The TUI mints one session-lived bus, so a detached async agent clones its
sender and streams a live tab through the same id-routed draw path a sync child
uses (session-lifetime-event-bus);
the one-shot/headless conversational drivers use a per-exchange bus with
muted children, while converse_settled uses per_exchange_live so fleet work
stays visible until quiescence.
agent/event.rs is the canonical per-session record. AgentLog owns two things:
- the model fold’s
Memo(record/model.rs) — its only session state: drives the protocol state machine (is_readygates a fresh prompt andquiescewinds any in-flight exchange back to it, so an exchange never strands a prompt mid-protocol; exchange-ends-ready) and answerstranscript()— the provider-facingTranscript, a shared,Arc-backed persistent value rather than a freshVec<ChatMessage>per call — whichprovider/wire.rsalone turns into an owned request, once per HTTP attempt (the-transcript-is-a-value); - the record seam (
record::Emitter) ontosessions/<n>/record.jsonl— the one durable log every fact this session authors crosses, whose protocol fold is the model view, with display commits and forensic breadcrumbs beside it (one-seam-one-log). Oversize tool-result sections are elided head+tail at the digest caps before they ever enter the log. A directory with norecord.jsonlrefuses to resume, with a named error./clearrotates the record segment behind the same seam, so existing emitters and the attached bus continue into the fresh file (and--no-logskeeps the mirror-only seam).
The TUI renders a sibling user.log — the “user view” — as a stream the
viewport is a window over: a block is written once, when eviction drops it
(Viewport::enforce_window_caps), into a retired prefix that only ever grows,
and the blocks still resident are written past that prefix provisionally at
flush points (session end, /export), which the next retirement rewinds over
rather than repeating
(the-window-is-not-the-transcript).
So the file keeps the whole session however long it runs, while the window
keeps only what fits; crash durability still lives in record.jsonl, which is
flushed per record. Both files live under the durable per-run log directory
(bootstrap::log_run_dir,
$XDG_STATE_HOME/exarch/<project>/<run>/sessions/<id>/). Every touch of that
file lives in one place: tui/viewport.rs keeps both the writer (Log) and
the /export copy (export_log) beside each other, the single user.log I/O
door, so the /export handler (tui/commands.rs, resolve_export_path)
resolves and guards the destination but never reaches the filesystem itself.
On resume, a viewport is a fold’s memo: tui_loop::run replays
record.jsonl into record::Blocks before the worker spawns, seeds the
root viewport with one Viewport::seed call, and restores cumulative usage
from the replayed deltas — the “resumed” note is the boundary between
replayed history and the live session. seed is sync plus the fact that
the seeded window is already in user.log, written by the run that recorded
it, so the resumed transcript continues rather than repeats. For the running session the viewport
stays its own live accumulator, fed one Signal at a time: tui_loop::dispatch
routes a Signal::Fact to App::fact (which steps the view fold and re-syncs)
and a Signal::Transient to App::transient (drawn directly, with no fold
behind it) — the same two entry points a resume’s sync call primes.
Two presentation surfaces, both folding the one Signal vocabulary through
fact/transient:
tui.rs (+ tui/{app,banner,block,commands,fidelity,gesture,group,highlight,line,login,matrix,md,model_picker,palette,picker,prompt,rail,render,select,status,tabs,terminal,tui_loop,viewport}.rs) — the full-screen
TUI. It owns the alternate screen and its own scrollback: each session is a
Vec<Block> (tui/block.rs), and the whole frame is redrawn each tick from
a memoised flatten of those blocks into wrapped visual rows. A tool call is
the one collapsible block — its summary shows shut, the full ral script when
a click opens it; the wheel scrolls, click-drag selects and copies the
rail-stripped text via OSC-52, and Shift-drag falls through to the terminal’s
own selection. tui/md.rs is the streaming markdown renderer — a ral code
block in the model’s prose (tagged ral, or untagged, which is what the
indented blocks of data/ral.md teach) goes to tui/highlight.rs, the same
lexer-backed colouring the tool-call panels use, so the language reads the
same wherever it appears, and only a foreign language falls to syntect and
the two-face set. $…$ and $$…$$ are typeset rather than quoted: the
LaTeX goes to tui-math, which lays it out on a character grid — fractions
stacked over a vinculum, big operators carrying their limits — and one
distinction decides where the grid goes. A one-row grid is notation and joins
the sentence; a taller one owns its rows, so it renders only for display
math, inset from the prose. A grid too wide for the wrap budget, or a formula
the parser refuses, falls back to the LaTeX the model wrote, inked as the
literal it is. tui/group.rs is the
coalescing projection that folds an observation run into one dialable
object; tui/viewport.rs the per-session block buffer, scroll position, and
user.log writer; tui/rail.rs the data-encoding marginal rail, and
tui/row.rs the Row { gutter, content } that represents the margin so
copy and selection can never reach it. The
transcript is laid out as a graphic on two orthogonal planes
(tui-transcript-as-graphic,
Phases 0–7 landed):
- two voices, encoded as foreground and background. The transcript
collapses to two parties — the human and the agent field — read off
orthogonal channels so neither competes with the other. The agent owns the
chromatic foreground (the rail hues); the background plane carries one
distinction only — machine text. A run of script or observation output is
washed into a recessed
CODE_BGpanel (group::wash_inset): a left-inset rectangle whose left edge aligns with the content — so it nests under its intent, and script and output share one margin to read as a single region — and whose right edge still runs to the margin, so the wash reads as a stratum rather than a content-hugging swatch; model prose sits unwashed at the base; the human’s submitted prompt is opened by a full-widthPROMPT_INKrule fence (line::prompt_fence) and neutral prompt ink, found at a glance by boundary and tone rather than by reverse video, which stays reserved for an active selection. - figure and ground, on the luminance axis. Within the agent’s
foreground, communication and work split by value: the model’s prose
answer, a subagent’s returned result, and the human’s prompt stay at full
luminance (the figure); a tool call’s intent — work-narration, not the
answer — drops to the
SLATEground tier (group.rsintent ink,line::tool_call_header), joining the already-recessed machine output and widening the app’sDIMidiom from “minor chrome” to “the ground stratum”. The rail glyph and the size/sparkline bars keep their luminance — there it is magnitude — so figure/ground rides value on content marks only and never collides with the quantitative read. - the marginal rail: one cell, three variables. Shape → block kind, hue
→ the producing agent, value → magnitude. Hue is a per-view tint, not
a per-block one: every block in a tab shares that tab’s agent slot
(
Viewport::agent, threaded intoBlock::linesat render time), so the whole rail glows one hue, read on a tab-switch as “whose transcript is this”. The human’s prompt fence is the lone exception — a❖in neutralPROMPT_INKso it never reads as just another agent’s mark. - the in-flight reply as an open line. The worker cuts a
Display::Answerat every newline it completes, and the view fold grows one block from the run of records that meet (Blocks::push) — so the assistant’s prose lands in scrollback as it is spoken, a line at a time, in one block per run. Where a cut falls therefore carries no meaning, which is what frees the producer from ever having to find a safe place to break. What no record covers is exactly the text past the last newline, andViewport::live_tailrenders that open line inside the block that will absorb it: the trailing block’s own source plus the open line plus the newline it is about to gain, in that block’s ownFidelity, drawn through the one path a committed block draws by — so the record that completes the line changes the text and not the picture, and the markdown context the line sits in (an open fence, a list) is the block’s own. The absorbed block’s rows come off the flattened tail and are redrawn whole; what is on screen is the authority, which agrees with the fold because both are “the run of records of one lane”. At most one lane is ever open: prose ends the reasoning run on the printer’s side exactly as it does on the worker’s, soTransient::Tokenclears the trace’s open line asStream::pushflushes the trace lane, andTransient::Boundaryclears whatever is left when no record will cover it. Reasoning therefore streams as reasoning — dimmed throughmd::render_reasoning, on the∴rail — rather than as a magnitude, and its tail records where the prose after it resumes, never at the step’s end, which is exactly where a∴block must not land. Its grain header weighs the run against the prose it became, which the record cannot carry (it precedes it) and the view therefore measures:viewport::answer_runreads the unbroken answer run that follows each∴row. A thinking block has two rungs only — its grain header, or the whole trace — the dial hopping overContext(Block::rung_up/rung_down), which for a trace would be a dead detent. Traces also answer to one standing rung,/thinking’s datum:Tabs::tracesholds it because it outlives any one view,Viewport::set_traces_levelmoves the traces on screen through the same seam a click cycles (so the rung is remembered against a resync), and every latersyncand live seat is born there. A per-block dial still wins —Viewport::revealis consulted after the standing rung. - a surfaced general card as a bounded object. A diff-less
CardOrigin::Surfacedcard — the model’s deliberate “look at this” — renders throughline::render_card_framedas a box indentedCARD_INDENTcolumns, its heading lifted into the top rule, no marginal rail glyph (the frame is its mark) — though it still wears the blank margin every row wears, so its left edge aligns with the rest of the transcript. A file mutation — a diff card or a write card — wears the patch-shape change-bar▎; an observation card folds into its ral group. A cancelled turn isChromeKind::Cancelled: it wears the error rail╳while remaining distinct fromChromeKind::Error, soBlock::is_errorand the matrix failure cell report actual failures only.
The frame’s terminal writes are bracketed in a synchronized update
(BeginSynchronizedUpdate / EndSynchronizedUpdate) so the emulator swaps
the whole diff atomically — without it a tail-following redraw tears while a
full page streams tool calls. The same steadiness is held in the scroll
arithmetic: Viewport::render_window computes offset (first visible row,
topping at total - height) over a memoised whole-buffer flatten, and
reports scroll position as a fixed-position magnitude on the rule line
(RenderWindow::scroll_pct, rendered ⇣ 72% / ⇣ end) rather than an
animated right-margin scrollbar.
The rule_line carries a value-ramp ctx% bar, the agent’s state in a
fixed-width slot, and an elapsed-wait bar reading the time in that state —
anchored to the transition, never reset by an arriving event, so a
streamed-character count that has stopped growing under a rising Ns is a
stalled stream and reads as one. AwaitingModel is named by that count
alone rather than by its label, the count being the datum the elapsed bar
gives meaning to. Two adjacent magnitudes need telling apart, so the clock
holds three columns and its unit ( 12s, then whole minutes past 999s),
the count carries its own (1.2k chars) and the wait bar’s purple, and the
rule’s · separates them — separation and units, never a hue that means
one thing this frame and another the next. Every field on the rule takes that
same separator, the right-aligned usage block included, since the elastic
space before it collapses on a narrow terminal; the state slot is padded to
STATE_SLOT_W, so the separator after it stands in one column whatever the
state. Ready waits on nothing, so it draws the bar’s empty track and no
clock: the track is the scale the filled cells are read against and stays put
between turns, while the readout goes blank, nothing being timed. The width is
the same either way, so no field shifts. The StateSpan (state, entry instant,
characters streamed since) lives on Viewport, not App, so each tab times
its own.
A tab is a Weak<Agent> for reach, the birth facts off its Born notice
(name, parent, log path) for the record, and a linger clock — one Tab per
agent the stream has announced, in a single Vec in birth order, root first.
The frontend holds no Fleet and no Arc<Agent>: reach is
Tabs::agent(id), an upgrade held for one handler and never stored, so the
avatar alone decides how long an agent lives. Liveness and death are
different questions asked of different things — a failed upgrade says the
agent is gone now, while Transient::Died sits at a position in the bus
queue, and only that position can say which buffered events preceded it, so
Died (and /clear’s retire_all, the frontend declaring death ahead of
the fact) remains the death cue and App::admits refuses everything after
it. The birth facts ride the notice rather than being read back off the
agent, so a child that settles before the frontend drains its Born still
gets a properly labelled tree row. App’s matrix navigation is modal, and
its whole retained state is Matrix — the agent identity the cursor names,
or nothing while the strip is a status display. TAB enters and leaves,
↑/↓ and Shift-Tab move the cursor, Enter attaches to its row and
leaves navigation, so attach-and-type is one gesture, and Esc leaves the
surface without cancelling the focused exchange; a cursor whose agent has
gone reads as the attached tab. The drawn window is a pure function of that
cursor — it always holds the cursor’s own row and spends one line on each
side it hides — and connectors are derived from the whole spawn forest before
the window clips it. While navigation owns the keyboard the strip is always
drawn, taking a row from the transcript if it must; otherwise it fits in
whatever the transcript’s floor leaves over. Demotion — a child idle and
parked past DEMOTE_IDLE (tui.rs, beside LINGER) becoming a compact
slate row in place, keeping its position in the spawn tree — is read off the
agent’s own exchange clock, computed per row per frame and never stored.
Sub-agent sessions get matrix rows/tabs that linger for 90 seconds
(LINGER, tui.rs) after Died, each keeping its own scroll position; dead
rows dim and keep their final step cells without a countdown. The conversing
trunk is label-only in the matrix — no step cells, token readout, or size bar
— so those columns describe workers only. An async agent on the
session-lived bus streams its tab the same way, and /clear retires every
live sub-tab through the same linger window
(session-lifetime-event-bus).
Once LINGER elapses, Tabs::tick evicts the dead view into a Tombstone
(Viewport::evict_to_tombstone) — exactly agent id, final status, and log
path, everything else (blocks, the flatten, streaming buffers, pins)
dropped — but retired to user.log first, since the tombstone promises that
log is readable; no reload-from-user.log machinery is built. Every live
viewport also caps its own retained window — VIEWPORT_MAX_BLOCKS blocks and
VIEWPORT_MAX_ROWS rendered rows, oldest evicted first — since older
blocks are durable in the session’s record.jsonl and in user.log by the
time they go. That cap is presentational, on top of the one bound the view
fold itself keeps: BLOCKS_WINDOW resident rows (record/view.rs), oldest
dropped as new ones land, so the memo every printer syncs from is bounded
once rather than trimmed per printer. A printer that draws incrementally
instead of wholesale holds its own cursor by Seq identity
(Headless::sync_agent) — the memo keeps no cursor of anyone else’s, since a
windowed memo makes an index wrong and a flush-gated floor makes the window
never move. Viewport::sync draws wholesale but rebuilds incrementally: the
fold stamps every row with the revision it last moved at (Block::rev), and
the printer rebuilds from the first row past the revision it last synced
(Viewport::rebuild_floor), carrying every block below over whole. Rows this
viewport’s own window has already evicted (Viewport::evicted_through) are
never built again to be dropped again.
/clear also cancels the in-flight exchange: route_submit raises
cancel::raise_interrupt and cascades agents.cancel_descendants(root) before blanking
the viewport, so the streaming select! in provider::complete unwinds within
one wait_for_cancel poll (~50 ms) rather than running to its natural end.
Straggler tokens the worker already emitted into the bus before the
cancel noticed are dropped by App’s root_clear_drain guard, which arms in
App::clear and disarms at the next Transient::Cleared — or, if that
acknowledgement itself was lost, at the next Display::Prompt fact.
The TUI owns the REPL loop and the raw-mode / bracketed-paste /
alt-screen / mouse-capture guard. Every tab shares one submit path: a typed
line goes to the focused agent (the prompt chrome follows the focused tab,
not the trunk), and a prompt submitted while an exchange runs
is posted to the Inbox; run_batch drains non-slash steering at
the next safe tool boundary, and the rest lands at the exchange
boundary — a coalesced human run, or a wakeup / settled agent as its own
marked item. A committed human prompt echoes on the ChromeKind::Prompt band;
a wakeup stays dim as note chrome (ChromeKind::Plain).
Slash-prefixed prompts
stay on the REPL command path (tui/commands.rs, parsed uniformly on every
tab). View commands (/help, /legend, /copy,
/export, /model, /login, /thinking, /close, /focus) run on the UI
thread; session commands (/branch, /context, /resources, /clear,
/compact, /rewind, /quit) enter the focused
agent’s inbox as Command items and run in ReplControl; the registry’s
rewrites field is what says which of those is a barrier in the queue and
which the prompts behind it may pass over. /branch [name]
forks a conversing tab from the focused context — a peer conversation
under branch-minimal, named by the human
or by a minted branch-{N}, and parked for them rather than seeded with a
prompt — and /close,
admitted off the trunk like /focus and /thinking, kills the focused branch
and its subtree. The idle wait
selects over input, inbox (bus/inbox.rs), and the session bus (bus/channel.rs)
(tool-boundary-steering,
scheduled-wakeups).
- The command token completes as it is typed, not on request: while the
first row is a bare
/and command characters,commands::command_candidatesfuzzy-ranks every name and alias (ral_core::text::rank) andPromptStateholds the result in aprompt_editor::completion::Menu, floated out of the top of the prompt box over the transcript — a transient overlay that reserves no row, so the frame never shifts under the reader. A live popup owns ↓/Tab, ↑/⇧Tab, Enter and Esc: Enter takes the highlighted command into the line and closes rather than submitting, since the line under an open popup is not yet what the user has chosen, and a second Enter sends it — unless the line already spells the highlighted command, where accepting would be the identity: the popup then closes and declines the key, which falls through to the submit it looked like all along. Ctrl-C is never offered to it at all: the interrupt outranks every overlay. This is deliberately unlike ral’s structural frontend, which opens its menu only on Tab and splices a lone match without showing one — typing here must never move the buffer on its own. Each row also carries the registry’s ownSlashCommand.helpline — the same sentence/helplists — in a dimmer second column, so the popup never says less than the listing does;Menudrops that column first when the terminal is too narrow to hold it. /model’s model list ranks throughral_core::text::rank_by— the samenucleomatcher the command popup uses, given thelabel / modelline each row matches by rather than the row itself, so a row and its haystack travel together and two providers listing one model name stay two rows. Rows read best-match first and, an empty query included, alphabetically within a score./modeland/loginsharepicker::overlay_frame: one centred double-line bezel, shadow, palette, padding, title, and hint frame around distinct bodies. The login body drives browser or device OAuth on a background thread, receives typedLoginPhases over a channel, and carries the device expiry label from the flow rather than reconstructing it in the view. Every body row is a(label, value)pair over one shared column, and a value too long for it wraps instead of clipping;ysends the phase’s one transcribable value — the URL, or the device code — to the host clipboard over OSC 52, which is how it reaches a browser at the other end of an ssh connection. Closing the overlay sets its relaxed cancellation flag; browser accept polls it directly, while device polling checks it before each bounded request.headless.rs— one-shot pipe:--output-format textsuppresses incidental root assistant tokens and writes the deliberatereplyonce as ral’s human-readable value projection;--output-format jsonwrites one faithful result object from that same reply. Every other event condenses to one line onerr, and the process exits after one seed exchange. The sink projects onto an explicit writer pair:runis the CLI’s headless wrapper, whileconverse_onis the conversational projection that keeps streaming tokens to a non-CLI host (synod’s GUI) one exchange at a time on a parked interactive trunk.HeadlessoverridesSink::driverather than taking the default, since it folds each source agent’s facts into its ownBlocksmemo, which the default’s statelessaccepthas nowhere to keep; it takes a per-exchange bus, so its async children stay muted. It is a display only — the durablerecord.jsonlis written by each session’s ownagent/event.rsseam, in headless exactly as in the TUI.
agent/cancel.rs is the per-agent exchange cancellation layered on ral’s interrupt
handling. Every agent holds one sticky Token (an Arc<AtomicU8>) for its
whole attend; the attend loop resets it at each genuine exchange boundary. Esc /
Ctrl-C interrupt the focused tab’s current exchange — never a cascade, never a
subtree kill (cancel-per-tab): on the trunk
they route through raise_interrupt, which cancels the trunk’s published token
and asks ral to cancel the current exchange’s foreground scope; on any other
focused tab, the tab’s own Weak upgrades (Tabs::agent) and Agent::interrupt
unwinds that agent’s exchange and eval root. Only the trunk publishes its token’s flag into the
lock-free process-global slot for the OS signal handler (a handler must not
lock), so the provider’s mid-stream cancel race observes the same cancellation.
The TUI key table keeps UI control separate from cancellation: idle Ctrl-C/Ctrl-D
quit, overlays close, and only active-exchange Ctrl-C/Esc drive ral’s
non-escalating foreground cancel. A single press stops the exchange /
in-flight HTTP future and unwinds the in-flight eval at its next poll point;
because the path never escalates the signal count, repeated presses cannot
reach ral’s third-signal _exit
(esc-non-escalating-interrupt).
On Windows the same contract rides SetConsoleCtrlHandler: install registers
exarch’s routine after ral’s, so it runs first in the last-registered-first
chain, handles Ctrl-C/Ctrl-Break itself — raise plus ral’s non-escalating
relay_interrupt, which cancels the foreground scope and fans a Ctrl-Break to
every live, non-detached pipeline group — and reports them handled, so ral’s
escalating disposition never ticks for an exchange-cancel; window-close / logoff /
shutdown pass through unhandled to that disposition, the analogue of
SIGTERM/SIGHUP staying on the escalating path. Raw mode disables
ENABLE_PROCESSED_INPUT, so Ctrl-C reaches the TUI as an ordinary key event
and deliver_interrupt calls the relay in-process — never a
GenerateConsoleCtrlEvent re-injection, which would broadcast to the console
group and tick ral’s escalation counter.
A genuine external signal still routes through ral’s one cause-carrying
delivery path (signals-are-causes).
Exarch session shells are rebuilt only through bootstrap::boot_shell, which
discards stale ral interrupts before library loading and returns with the
cancel chain installed over ral’s handlers. /clear therefore works after Esc
and SIGINT after /clear still raises cancel. prompt/host.rs snapshots the machine (OS, date, cwd,
user, git state) once at startup for the system prompt.
- tui.rs — thin façade (~60 lines): module declarations and re-exports
- tui/app.rs — the App orchestrator: event routing, the root_clear_drain guard, per-kind push methods
- tui/tui_loop.rs — REPL/ui loop: run, Tui, CommandCtx, ReplControl, ui_loop, OverlayTick, overlay_tick, KeyAction, key_action, ctrl_key
- tui/terminal.rs — terminal lifetime: TerminalGuard, raw mode, alt screen, panic hook, stderr redirect, editor hatch, compose_in_editor
- tui/tabs.rs — session/view lifecycle: Tab (Weak<Agent>, birth facts, Viewport, linger clock), Tabs as one birth-ordered Vec, TabRow (the matrix’s per-frame projection, demotion included), titles, attachment management and the parent climb, tick’s tombstone eviction past LINGER
- tui/viewport.rs — per-session scrollback: Viewport, block push/flatten/render, incremental Printer::sync (rebuild_floor, evicted_through), the VIEWPORT_MAX_BLOCKS/VIEWPORT_MAX_ROWS window caps (oldest evicted first, retired to user.log on the way out), the Log transcript writer, Tombstone
- record/commit.rs — event coalescing, worker-side: Stream/Chopper, SurfaceBuffer, PatchBuf, ObservationBuf, absorb/flush into Display commits
- tui/prompt.rs — prompt editor state: PromptState, history, draft, editor request, key input, the live slash-command popup (refresh_menu, menu_key)
- tui/gesture.rs — the mouse as a transition system: Cell, FrameGeom (the one place pointer → buffer cell), Phase (Idle/Pressed/Dragging/Selected), copy Toast, hover. Reads come in as &Viewport; writes go out as an Effect (Scroll, CycleBlock, Copy) that App::apply runs — the module never mutates a viewport or touches the terminal
- tui/render.rs — strips lays the frame out as a value, draw paints it; paint_selection, paint_hover, footer_hint, emit_tab_title; the screen-side Row::into_line flatten
- tui/row.rs — the transcript row: Row { gutter, content }, seat/wrap/wash/hover/plain/into_line, the RAIL_W gutter-width invariant
- tui/banner.rs — startup metadata: SessionInfo, session_card (including the compile-time package version, omitting the disposable scratch path), legend_panel, ART/EAGLE constants; the wordmark and width-matched card use rail-free ChromeKind::Opening
- tui/commands.rs — slash command registry: SlashCommand, lookup_command, command_candidates, route_submit, handler functions
- tui/status.rs — status line: rule_line, ctx_ramp, wait_bar, wait_step
- tui/matrix.rs — bounded agent-tree matrix: Matrix (the one retained value, an agent identity), Nav/nav reading a key as a gesture, MatrixSort, forest/TreeRow and their connectors, the closed-form window and its boundary lines, neighbour, strip’s justified row projection, step_cells
- tui/palette.rs — the TUI colour constants (CODE_BG, SLATE, PROMPT_INK, the agent hues)
- tui/model_picker.rs — model switching: pick_model, drive_picker, apply_model_switch; list fetching rides provider’s Listing/Fetches pumps
- tui/login.rs — the /login overlay: LoginOverlay, drive_login, apply_login