Console Overview
The interactive console is the Rust edition's product shell — the ratatui REPL a TTY launch hands a session to take over the terminal. It is a deliberate re-architecture of the TypeScript Ink/React surface: one immutable
ConsoleState, one pureconsole_reducer, a typedConsoleEventenum, and an immediate-modeview::draw(frame, &state)that paints a masthead, a tailing transcript of streaming markdown / tool cards / diffs, a bordered composer, and a bottom chrome strip — every decision unit-testable without a terminal. It is built on the Rustindusagiframework'stui/tui_renderimmediate-mode rendering layer and lives behind the crate'stuifeature.
Table of Contents
- What it is
- Module map
- Reaching the console
- The state backbone
- The reducer
- The event union
- Input handling
- The signal cursor and live turn projection
- The view tree
- The mount loop
- Overlays
- Public surface
- See also
What it is
The console crate ports indus-code-rebuild/src/console/** (≈5,826 LOC of
Ink/React) onto the frozen indusagi-tui / indusagi-tui-render immediate-mode
framework. It is not a line-by-line translation. The Ink useReducer +
conductor.subscribe design becomes four cooperating pieces, exactly as the crate
docstring (console/mod.rs) describes:
- One immutable
ConsoleState— every observable property of the surface (composer buffer/caret, history ring, transcript rows, the active overlay, status line, display toggles). Live session data (messages, usage, the bound model) is deliberately absent: it is projected on every draw from the conductor's snapshot, so the reducer never knows about the agent loop. - One pure
console_reducer—(ConsoleState, ConsoleEvent) -> ConsoleState, an exhaustivematch(no_arm) with no I/O, so every transition is unit-/snapshot-testable headless. - Immediate-mode
view—draw(frame, &state)over ratatui, with streaming markdown made flicker-free by a stable-prefix cache and an inline viewport for the transcript (not the alt-screen). - A typed
ConsoleEventenum — the closeddomain:verbaction union the reducer folds; each variant snapshots cleanly.
mount_console is the boot entry. It
inverts how the TS repl-runner.ts mounted Ink: the boot ReplRunner TTY branch
calls into this crate rather than the crate spinning up its own host. The whole
subsystem performs exactly one terminal takeover (ratatui::init); everything else
stays pure or presentational.
The crate ships two binaries from one entry — indusr and indusagir
(Cargo.toml [[bin]]) — both gated behind the crate's tui feature
(tui = ["indusagi/tui"]). A headless build (--no-default-features) proves the
rest of the agent compiles without a terminal in the loop; mount_console then
refuses cleanly instead of mounting.
Module map
The console module (console/mod.rs) re-exports the names every writer and the
boot wire code against:
| Module | File | Role |
|---|---|---|
state |
console/state.rs |
The frozen ConsoleState + ViewRow / ModalKind / ThemeScheme value types. |
events |
console/events.rs |
The typed ConsoleEvent action union and its type_tag discriminants. |
reducer |
console/reducer.rs |
The single pure total fold console_reducer + init_console_state. |
input |
console/input.rs |
The keymap, chord latch, paste coalescing, completion engine, and live InputController. |
mount |
console/mount.rs |
mount_console, the SignalCursor turn projection, and the live ratatui loop. |
tool_row |
console/tool_row.rs |
Projects a settled ToolEnd into a rendered ToolCard via the framework descriptor. |
slash |
console/slash.rs |
The slash-command registry, dispatch, and effects. |
theme |
console/theme.rs |
Maps a ThemeScheme to a resolved framework adapter. |
overlays |
console/overlays/ |
The 13 modal overlays keyed by ModalKind. |
view |
console/view/ |
The immediate-mode painter (banner, chrome, composer, transcript, diff, markdown). |
The top-level re-exports are:
pub use events::ConsoleEvent;
pub use mount::{MountConsoleOptions, MountResult, mount_console};
pub use state::{
ConsoleState, ModalKind, ModalPayload, ModalState, NO_MODAL, ThemeScheme, ViewRow, ViewRowKind,
transition_modal,
};
Reaching the console
In an attended terminal the launcher selects the interactive REPL by default; the
boot ReplRunner TTY branch awaits mount_console. A run mode passes a
Conductor and a MountConsoleOptions bundle:
use induscode::console::{mount_console, MountConsoleOptions, MountResult};
let result: MountResult = mount_console(
conductor, // the assembled session this console drives
MountConsoleOptions {
scheme: Some("midnight".into()),
initial_input: Some("explain this repo".into()),
cwd: Some(workspace.cwd.clone()),
auth_path: Some(workspace.auth_path.clone()),
sessions_dir: Some(workspace.sessions_dir.clone()),
prefs: Some(prefs),
..Default::default()
},
).await?;
Everything but the conductor is optional and defaults sensibly.
MountConsoleOptions (ports MountConsoleOptions, console/mount.ts:27-49)
carries:
| Field | Purpose |
|---|---|
scheme |
The colour-scheme name (midnight/daylight/midnight-cb/daylight-cb); None → the default scheme. |
initial_input |
An optional first user turn submitted on mount (streams in as the agent publishes its run events). |
initial_images |
Local image paths to attach to the first turn (retained for parity). |
verbose |
Render verbose diagnostics in the banner. |
cwd |
The working directory shown in the footer (--cwd); also roots @path completion. |
auth_path |
The auth.json the sign-in / sign-out overlays read and write through a DiskAuthVault; also filters the /model picker to authenticated providers. |
sessions_dir |
The workspace sessions/ the data overlays read (/resume, /tree, /branch, /timeline). |
prefs |
The two-tier PreferenceStore the settings overlay and theme picker read and persist through. |
mount_console returns a MountResult { clean: bool } once the surface exits.
Off a TTY (a pipe, CI, a test) it refuses cleanly — ratatui::init would panic in
raw mode — so the boot runner maps that to its headless fallback exit:
if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() {
anyhow::bail!("the interactive console requires a terminal (stdout/stdin are not a TTY)");
}
The state backbone
ConsoleState (console/state.rs, ports ConsoleState, contract.ts:363-392) is
the single immutable value the entire surface is reduced from. The surface holds
exactly one and never mutates it in place. Live session data is not here — it is
projected from the conductor snapshot on every draw.
pub struct ConsoleState {
pub rows: Vec<ViewRow>, // the rendered transcript, newest row last
pub blocks: Vec<UiDisplayBlock>, // out-of-band display blocks (changelog, etc.)
pub buffer: String, // the composer text buffer
pub caret: usize, // caret offset in CHARS (not bytes)
pub history: Vec<String>, // the submitted-input ring, oldest first
pub history_at: Option<usize>, // cursor into history, or None when not browsing
pub stash: String, // buffer stashed before history browsing began
pub modal: ModalState, // the active modal overlay
pub modal_selected: usize, // highlighted row in the active list overlay
pub status: Option<StatusMessage>, // the transient status line
pub scheme: ThemeScheme, // the active colour scheme
pub show_reasoning: bool, // whether reasoning/thinking rows are shown
pub show_images: bool, // whether inline images are rendered
pub expand_tools: bool, // whether settled tool output is expanded (Ctrl+O)
pub busy: bool, // whether a turn is in flight
pub tick: u64, // monotonic re-render tick
}
ConsoleState::default() is the freshly-seeded state a session opens with (ports
EMPTY_CONSOLE_STATE): empty collections, caret 0, history_at None, modal NO_MODAL, scheme Midnight, show_reasoning false, show_images true, busy false, tick 0.
Transcript rows
The console renders its scrollback as a flat list of typed rows, not one message
blob. A ViewRow (ports ViewRow, contract.ts:266-275) is identity-stable: a
streaming row keeps its id while its text grows, so the renderer reconciles in
place.
pub struct ViewRow {
pub id: String,
pub kind: ViewRowKind, // Prompt | Answer | Reason | ToolRun | Notice
pub text: String,
pub run_id: Option<String>, // tool-run correlation id, ToolRun only
pub output: Option<String>, // a settled tool's rendered body (full per-tool logs)
pub diff: Option<RowDiff>, // a settled tool's file-edit diff
}
ViewRowKind is Prompt (a user turn), Answer (streamed assistant text),
Reason (streamed thinking, shown only when enabled), ToolRun (a tool invocation
correlated by run_id), or Notice (an out-of-band changelog/system note). A
RowDiff { path, old, new } mirrors the conductor's ToolDiff payload that the
framework diff engine consumes.
Modal state
ModalKind (ports ModalKind, contract.ts:301-314) is the closed set of
overlays. Exactly one is active at a time:
| Variant | Overlay |
|---|---|
None |
No overlay; the composer has focus. |
Settings |
The settings list. |
Models |
The single-model picker. |
ScopedModels |
The per-scope model picker. |
Theme |
The scheme picker (preview-on-move, commit-on-Enter, revert-on-Esc). |
Sessions |
The session resume/list picker. |
Tree |
The transcript-tree navigator. |
UserTurns |
The prior-user-turn picker (for branching/forking). |
SignIn |
The provider sign-in launcher. |
SignOut |
The provider sign-out confirmation. |
Oauth |
The in-flight OAuth device/redirect flow. |
Plugin |
A plugin-supplied select/confirm/input/custom overlay. |
Approval |
A pending tool-permission prompt (allow once / always / deny). |
Rust has no unknown, so where the TS payload was payload?: unknown the Rust port
uses a typed ModalPayload enum (Approval, Oauth, Plugin, ScopedModels,
SignIn); overlays that carry no data simply never construct one. The pure
transition_modal(next, payload) helper computes the next ModalState: opening
ModalKind::None (or closing) returns the inert NO_MODAL constant; any other kind
raises that overlay carrying its payload.
The reducer
console_reducer (console/reducer.rs, ports consoleReducer,
reducer.ts:186-260) is the one place every transition lives. It is pure and
total: each event has a defined transition, and the input is taken by value so
it can never be observed mutated — "never mutate the input" is enforced by the move.
The match is exhaustive (no _ arm), the Rust analogue of the TS const _never: never = event guard, so adding a variant without handling it is a compile error.
pub fn console_reducer(mut state: ConsoleState, event: ConsoleEvent) -> ConsoleState { /* ... */ }
pub fn init_console_state(scheme: Option<ThemeScheme>) -> ConsoleState { /* ... */ }
Transitions fall into families mirroring the event grouping:
- Composer edits (
buffer:*,caret:*) splice and navigate the live input buffer, keeping the caret clamped into[0, buffer.len_chars()]. - History recall (
history:*) walks the submitted-input ring, stashing the live buffer on the way in and restoring it on the way out.push_historyignores blank lines and an immediate duplicate of the newest entry. - Transcript view (
rows:*,block:append,blocks:clear) appends and patches the rows projected from the conductor's signal stream.patch_rowgrows a streaming row in place by id (a missing id is a harmless no-op);patch_tool_rowreconciles a settledToolRunrow (text + output + diff). - Overlays / status / theme / toggles / busy flip the small UI-local flags.
Notably,
BusySet(true)also clears a sticky status toast so a stale permission-mode line can't masquerade as live progress while theWorkingIndicatorshows the agent is busy; ending a turn keeps whatever terminal status the turn set.
The char-offset hazard
ConsoleState::caret is a char offset (a count of chars), not a byte offset.
Rust String is UTF-8, so slicing it at a raw caret value would panic on a
non-ASCII boundary. Every splice/erase walks the buffer with char_indices to
convert a char offset to its byte offset before touching the string. This is the
single place that hazard is handled — byte_at, splice_at, with_buffer, and the
erase_prev/erase_next helpers all go through it. The reducer's tests pin this
with multi-byte buffers (héllo, inserting ü, erasing the é).
The event union
ConsoleEvent (console/events.rs, ports ConsoleEvent, contract.ts:435-458)
is the closed action union the reducer folds. Each variant carries the domain:verb
discriminant the TS console used (returned by type_tag() for logging/filtering).
The variants carry only induscode-owned data plus the two framework value types
the transcript needs (StatusMessage, UiDisplayBlock) — never live session data.
| Variant | Tag | Effect |
|---|---|---|
BufferSet(String) |
buffer:set |
Replace the whole buffer; caret to end. |
BufferInsert(String) |
buffer:insert |
Splice text in at the caret; caret advances. |
BufferErasePrev |
buffer:erasePrev |
Delete the char before the caret. |
BufferEraseNext |
buffer:eraseNext |
Delete the char after the caret. |
CaretMove(i64) |
caret:move |
Move the caret by a signed delta (clamped). |
CaretJump(usize) |
caret:jump |
Set the caret to an absolute char offset. |
HistoryPush(String) |
history:push |
Append the just-submitted input (dedups, exits browsing). |
HistoryBack |
history:back |
Recall the previous (older) entry. |
HistoryForward |
history:forward |
Recall the next entry, or restore the stash. |
RowsSet(Vec<ViewRow>) |
rows:set |
Replace the whole row list. |
RowsAppend(ViewRow) |
rows:append |
Append one row. |
RowsPatch { id, text } |
rows:patch |
Replace a row's text by id. |
RowsPatchTool { id, text, output, diff } |
rows:patchTool |
Patch a settled tool row (text + body + diff). |
BlockAppend(UiDisplayBlock) |
block:append |
Append an out-of-band display block. |
BlocksClear |
blocks:clear |
Drop every display block. |
ModalOpen { kind, payload } |
modal:open |
Raise an overlay (resets modal_selected to 0). |
ModalClose |
modal:close |
Drop back to the composer. |
ModalSelectMove(i32) |
modal:selectMove |
Move the list cursor by a signed delta (saturates at 0). |
ModalSelectSet(usize) |
modal:selectSet |
Set the list cursor to an absolute index. |
StatusSet(StatusMessage) |
status:set |
Show a transient status message. |
StatusClear |
status:clear |
Clear the status line. |
SchemeSet(ThemeScheme) |
scheme:set |
Switch the active colour scheme. |
ToggleReasoning |
toggle:reasoning |
Flip reasoning-row visibility. |
ToggleImages |
toggle:images |
Flip inline-image rendering. |
ToggleExpandTools |
toggle:expandTools |
Flip tool-output expansion (Ctrl+O). |
BusySet(bool) |
busy:set |
Set the in-flight flag (true also clears a sticky status). |
Tick |
tick |
Bump the status-bar re-render tick. |
The two modal:select* cursor verbs are added over the 22 ported variants for the
shared list-overlay navigation. Because block:append / status:set embed
framework types that are not Serialize, ConsoleEvent is intentionally Debug
only — snapshot tests assert on the Debug rendering, which pins every variant.
Input handling
console/input.rs decodes crossterm events into reducer-bound effects. It ports
the five Ink-free console/input/* modules plus the useInput glue, keeping the
pure decision functions terminal-free so they unit-test exactly as the TS modules
tested without Ink. Only KeyChord::from_crossterm and InputController touch
crossterm types.
The keymap and chord latch
read_key(input, &KeyChord) -> ConsoleIntent (ports readKey,
keymap.ts:211-286) is a pure, memoryless classifier. Resolution order is
load-bearing: control combos and named keys win over raw text, so Ctrl+U never
also types "u". KeyChord::from_crossterm adapts a crossterm KeyEvent into the
boolean flag-bag the branch order reads (folding BackTab to tab + shift, and
Delete/DEL/BS into erase-backward). The intents are ConsoleVerbs grouped by
concern (editing, caret, flow, model, mode, queue, input-sources, overlays,
view-toggles). The live chord bindings:
| Chord | Verb / effect |
|---|---|
Ctrl+C |
FlowInterrupt — abort a busy turn; idle, leaves. |
Ctrl+Z |
FlowSuspend — background the process (SIGTSTP). |
Ctrl+U |
EditClearLine — clear the line; twice on an empty buffer fires Clear2 (request exit). |
Ctrl+A / Ctrl+E |
NavHome / NavEnd. |
Ctrl+J |
TextNewline — soft newline. |
Ctrl+N / Ctrl+P |
FlowCycleModel — rotate the bound model. |
Ctrl+T |
ModelCycleThinking — step the reasoning-effort ladder. |
Ctrl+V |
InputPasteImage — stage a clipboard image. |
Ctrl+G |
InputExternalEditor — hand the buffer to $EDITOR. |
Ctrl+R |
OverlayOpen(Sessions) — raise the session picker. |
Ctrl+L |
OverlayOpen(Models) — raise the model picker. |
Ctrl+O |
ViewExpandTools — toggle full tool-output rendering. |
Alt+Up |
QueueDequeue — pull the newest queued input back into the composer. |
Enter / Shift+Enter |
FlowSubmit / TextNewline. |
Tab / Shift+Tab |
FlowAccept (accept completion / 2-space indent) / ModeCyclePermission. |
Esc |
FlowDismiss; twice within the window fires Dismiss2 (open the tree). |
advance_chord(&ChordLatch, &ConsoleIntent) -> ChordStep (ports advanceChord)
layers the double-tap latch over the pure classifier. A first chord-eligible key
arms; a second matching tap within CHORD_WINDOW_MS (600 ms) fires the outcome
(Dismiss2 or Clear2); any other key clears the latch. The window expiry is the
surface's job — InputController::expire_chord resets a stale latch synchronously
via Instant comparison (no async timer).
Paste coalescing
A large pasted chunk is hidden behind a [Pasted text #N +K lines] marker rather
than spliced verbatim. classify_paste(chunk, &PASTE_THRESHOLDS) (defaults: 8
newlines or 800 chars) returns Inline or Coalesce; coalesce_paste mints a
marker into a fresh immutable PasteVault (bodies: BTreeMap<u64, String> +
minted); expand_markers rewrites every marker back to its body at submit time.
A hand-rolled next_marker scanner replaces the TS regex (no regex/once_cell
dep), so there is no JS lastIndex hazard. Bracketed paste arrives as one
Event::Paste; non-bracketing terminals fall back to a per-char burst the
controller folds (PASTE_BURST_GAP_MS = 24 ms) and classifies as a unit.
Completion
complete_at(buffer, caret, &SlashRegistry, &DirReader) -> CompletionResult (ports
completeAt) routes on the active token's stem: a / only at buffer-start
yields slash completion (a slash mid-prose is a path separator); an @/path-like
stem yields directory completion; anything else is inert. complete_slash matches
the stem against each command's name and aliases by prefix with exact matches
floated front; complete_path lists the resolved directory (dirs-first,
alphabetical, trailing-slash on directories) through the injected DirReader. The
one impure seam is create_dir_reader(cwd), an std::fs-backed reader where every
error collapses to an empty Vec so the composer never panics mid-type.
apply_suggestion(buffer, span, &Suggestion) splices the accepted value over the
token's char-offset span.
The InputController
InputController is the live glue — the React useInput replacement. It owns
every value the pure modules can't hold: the chord latch (+ window), the paste
vault, the per-char burst fallback, the completion highlight, and the last offering.
on_event(ev, &ConsoleState) returns the InputEffects the surface applies — it
performs no I/O itself:
pub enum InputEffect {
Edit(ConsoleEvent), // a buffer/caret/history edit to dispatch
Submit(String), // commit the buffer (burst-folded + expanded)
OpenOverlay(ModalKind),
AcceptCompletion(Suggestion, TokenSpan),
MoveHighlight(isize),
HistoryBack, HistoryForward,
Interrupt, Dismiss, Suspend,
CycleModel, CyclePermission, TogglePlan, CycleThinking,
ExpandTools, ToggleReasoning,
PasteImage, ExternalEditor, Dequeue,
DoubleDismiss, RequestExit,
None,
}
refresh_completion is called after every edit and on every frame, but resets the
highlight to the first row only when the (buffer, caret) signature actually changed
— so a NavUp/NavDown move survives the next per-frame refresh (the documented
"BUG A" fix). With a completion menu open, Enter on a slash row accepts and
submits the completed line in one press (so /logout runs on one Enter), while an
@file row only accepts into the buffer.
The signal cursor and live turn projection
SignalCursor (console/mount.rs) is the mutable cursor a conductor
SessionSignal stream is projected through into
transcript rows. It re-derives the deleted signal→state projection: it tracks which
streaming Answer/Reason rows are "live" so successive deltas grow the same row
in place (a stable id the reducer patches) rather than appending a fresh row per
delta. project(&SessionSignal) -> Vec<ConsoleEvent> is pure of I/O, so the whole
projection is headlessly testable; apply_signal(&mut cursor, state, signal) is the
single application path the live loop and tests share (it also syncs
cursor.show_images from the live state so a settled card honours the toggle).
How each signal renders a live turn:
SessionSignal |
Projection |
|---|---|
Prompt { text } |
Close open streams, append a Prompt row, BusySet(true). |
Text { delta } |
Grow the open Answer row (rows:patch), or open one (rows:append). |
Thinking { delta } |
Grow/open the Reason row (gated by show_reasoning at render). |
ToolStart { id, name } |
Append a ToolRun row "> <FriendlyTitle> (running)", tracked by run_id. |
ToolEnd { id, name, ok, output, diff } |
Build the full settled card and patch the same row (or append if pruned). |
TurnEnd { .. } / Idle |
Close streams, BusySet(false). |
Fault { fault } |
Close streams, BusySet(false), set a status (calm "Stopped" for Aborted, else a "Turn faulted" warning). |
Compacted |
Append a muted Notice row "Context condensed to fit the window." |
Persisted { .. } / Queue { .. } |
No transcript surface. |
A ToolEnd is turned into a ToolCard by console/tool_row.rs, which reuses the
framework descriptor (describe_tool_source from
indusagi::tui_render::utils::tool_display) — the same per-tool switch
(read/write/edit/ls/find/grep/bash/task/todo/default) the TS console uses — so every
tool renders its real output/logs/diff rather than just name · done:
pub fn build_tool_card(
name: &str, ok: bool, output: Option<&Value>, has_diff: bool, show_images: bool,
) -> ToolCard; // -> { text: "<marker> <Title> (<summary>)", body, has_diff }
The status marker is = on success, ! on error (> while running). A diff-shaped
card (edit/write, or whenever the conductor extracted a { path, old, new } triple)
leaves the plain body empty so the diff renderer owns the change — no double
rendering.
The view tree
console/view/ is the immediate-mode painter: draw(frame, &state) over ratatui.
The transcript scrolls inline in the primary buffer (not the alt-screen), and
streaming markdown is flicker-free via a stable-prefix cache. Each leaf maps one TS
component family.
Banner (`view/banner.rs`)
The startup masthead — the big six-row "INDUS CODE" ANSI-Shadow block-figlet
(WORDMARK_ROWS, copied verbatim), accent-tinted with a static per-row row_gradient
sweep, the indus console vVERSION brand line, and a welcome_line greeting. It is
built as Vec<Line<'static>> and prepended to the transcript lines, so it sits
above the messages and scrolls with the content — exactly like the TS <Banner> sat
above <MessageList>. The gradient is a single render pass (no timer), reduced-motion
safe by construction.
Transcript (`view/transcript.rs`)
build_transcript_lines(rows, show_reasoning, expand_tools, theme) turns &[ViewRow]
into the full ordered line list (pure, snapshot-testable), and render_transcript
paints the last area.height lines so the newest rows always tail on screen — O(visible)
per frame regardless of scrollback depth. Each ViewRowKind is themed independently:
Prompt— a bold accentYouheader + the literal prompt text (no markdown).Answer— a bold accentAssistantheader + the streamed text throughrender_streamingwith the syntect highlighter, so a settled answer matches its streaming form byte-for-byte and code fences carry syntax colours.Reason— a dim…glyph + muted body; skipped entirely whenshow_reasoningis off (mirrorsMessageList { show_thinking }).ToolRun— a dim⏵glyph + the settled card header; beneath it the output body (clamped toTOOL_BODY_COLLAPSED_LINES= 10 with a... N more line(s)footer collapsed, full when expanded via Ctrl+O) and, for edit/write tools, the colored unified diff (only when expanded).Notice— a dim›glyph + muted body.
A blank gap row separates entries (the framework MessageList inter-entry gap).
Streaming markdown (`view/markdown.rs`)
StreamingMarkdownCache caches the settled stable prefix's rendered lines across
deltas so only the growing unstable tail is re-lexed per frame. It is built on the
framework's render_streaming / last_stable_boundary: last_stable_boundary is
monotonic under growth, so the cached prefix only ever grows, and a cache hit is
valid only when the new content still starts with the cached prefix bytes and its
boundary did not regress. A reset / a different answer fails the guard and rebuilds —
so a stale prefix can never be orphaned onto an unrelated tail. The output is always
byte-identical to a fresh render_streaming; the cache is a pure speedup.
render_stream(content, theme) is the un-cached one-shot convenience for callers
(e.g. a settled transcript row) that don't hold a cache.
Diff (`view/diff.rs`)
render_file_diff(old, new, path, theme) turns an (old, new, path) triple into the
cached Vec<Line<'static>> a transcript diff row paints, or None when old == new.
The heavy lifting — the line diff with CONTEXT_LINES=3 hunking, word-level
intra-line spans, the dim line-number gutter, the diffAddedBg/diffRemovedBg
truecolor tints, bold changed-word emphasis, and dim … inter-hunk separators —
lives in the frozen framework (build_structured_diff + render_diff). This module
adds only the console header (path +N -M) and the DIFF_INDENT body indent.
Composer (`view/composer.rs`)
The prompt-input surface: a rounded box (muted-frame tone, borderMuted → accent → gray fallthrough) with a › prompt glyph and the live buffer wrapped to the inner
width by visible columns. The caret is drawn by inverting the cell under it (a
Modifier::REVERSED span — the immediate-mode analogue of <Text inverse>), or a
trailing reversed space at end-of-buffer, so the composer is a pure draw with no real
cursor escape. Beneath the box it draws up to SUGGESTION_WINDOW (8) completion rows,
the highlighted one as ▸ label in accent. When busy, the buffer text is dimmed
but the caret stays legible.
Chrome (`view/chrome.rs`)
Two stacked presentational rows:
WorkingIndicator— the live "agent is working" row: a braille spinner (SPINNER_FRAMES), a rotating whimsical word (WORKING_WORDS, 30 of them, picked bypick_word) or a concretephase_label(tooling → "Running tools",condensing → "Compacting context"), an elapsed-seconds clock, and an "esc to interrupt" hint. Renders nothing (height 0) when idle. Animation state is passed in as plain data (frame, elapsed_ms, word_offset) — no internal timer.StatusBar— the frameworkStatusLinetoast row, an optional● permission: <label>badge (permission_badgelabels each cycled mode and colours it:bypass-permissionsred,plancyan,accept-editsyellow,defaultgrey), and the frameworkFootersession/usage strip (cwd, branch, modelprovider/name, token/cost/context-window counters). It is fed a projectedSessionSnapshotand runs withshow_busy_text=falseso the toast never competes with theWorkingIndicator.
The mount loop
The live loop lives in console/mount.rs's live module (gated behind tui).
After taking over the terminal (crossterm bracketed paste on, ratatui::init
installing the panic hook that restores the terminal on a crash), each iteration:
- Drain + project signals. The conductor subscription pushes raw signals onto a
shared queue; the loop drains them and folds each through
apply_signal(&mut cursor, state, signal). - Drain approvals. Tool-permission requests parked by the gate's
askdecisions are pulled off a channel into a serialisingApprovalQueue; the head is mirrored into theApprovalmodal (one prompt at a time). - Refresh completion against the live buffer.
- Draw the frame.
draw(...)measures the composer + working row + status bar heights, gives the transcript the remainder via a bottom-anchoredLayout::vertical, paintsrender_transcript, theWorkingIndicator, theComposer, and theStatusBar, and draws the active overlay on top.project_snapshotresolves the bound model id to aprovider/namepair and aContextUsage(per-model context window + live cost) from the gateway catalog. - Poll input with a 40 ms timeout (
POLL_TIMEOUT) so streamed signals flush even while the user is idle. - Route the key. With a modal open, keys route to the overlay (list overlays
share
modal_selected; the theme picker previews-on-move; the dialog overlays own theirhandle_key; the approval prompt settles the head choice). Otherwise the key goes throughInputControllerand the resultingInputEffects are applied byapply_input_effect.
A submit is routed by submit_line: the buffer is cleared and pushed to history,
then slash::dispatch_slash(&line, registry) runs. A NotSlash line is sent to the
conductor as a prompt (spawn_submit); a Handled/Submit result drains its
SlashEffects (Event, OpenModal, CloseModal, Status, SetBuffer,
AppendBlock, RequestExit, Conductor(action)) into the state and conductor; an
Unknown(name) raises a warning toast. The slash effects are not sent to the
model. Ctrl+C while busy aborts the turn and stays up; idle, it leaves. The
turn-boundary edge (busy false→true) restarts the per-turn clock and re-seeds the
WorkingIndicator word.
Overlays
The 13 overlays live in console/overlays/, keyed by ModalKind and mirrored onto
the framework dialog layer at draw time while ConsoleState.modal stays the
reducer-owned source of truth. draw_overlay dispatches on state.modal.kind, drawing
each over the whole frame on top of the content. The data-bearing overlays follow a
"fetch before the push" discipline (OverlayData): a synchronous draw can't await,
so the async session reads (/resume session list, /tree branch DAG, /branch
prior turns) happen when the modal opens and the result is stashed for the pure
draw_* functions; the reducer/ConsoleState stays I/O-free. The model catalog is
global + synchronous, built once at mount and filtered to authenticated providers.
The full overlay catalog and flows are documented in
Dialogs.
Public surface
| Name | Kind | Source | Purpose |
|---|---|---|---|
mount_console |
async fn | console/mount.rs |
The single entry a run mode awaits to take over the terminal; returns MountResult. |
MountConsoleOptions |
struct | console/mount.rs |
The mount option bundle (scheme, initial input, cwd, auth/sessions/prefs paths). |
MountResult |
struct | console/mount.rs |
{ clean: bool } — whether the surface tore down cleanly. |
console_reducer |
fn | console/reducer.rs |
The single pure total fold over ConsoleState; exhaustive match. |
init_console_state |
fn | console/reducer.rs |
Build the initial state, overlaying the optional scheme. |
ConsoleState |
struct | console/state.rs |
The frozen UI-local state slice. |
ConsoleEvent |
enum | console/events.rs |
The closed domain:verb action union. |
ViewRow / ViewRowKind |
struct / enum | console/state.rs |
One immutable transcript row + its kind. |
ModalKind / ModalPayload / ModalState |
enum / enum / struct | console/state.rs |
The 13-kind overlay machine + its typed payload. |
NO_MODAL / transition_modal |
const / fn | console/state.rs |
The inert "no overlay" state + the pure transition helper. |
ThemeScheme |
enum | console/state.rs |
The four built-in colour schemes (+ parse/as_str). |
Internal-but-load-bearing: SignalCursor / apply_signal (turn projection),
build_tool_card (console/tool_row.rs), StreamingMarkdownCache /
render_stream (console/view/markdown.rs), InputController / InputEffect
(console/input.rs), and WorkingIndicator / StatusBar / Composer /
render_transcript (console/view/).
See also
- Conductor — the session core whose
SessionSignalstream the console projects into transcript rows. - Slash commands — the registry, dispatch, and
SlashEffectcatalog the composer routes a/-line through. - Dialogs — the 13
ModalKindoverlays (pickers, sessions, auth, approval, plugin). - Boot — how the
ReplRunnerTTY branch reachesmount_console. - Launch & CLI flags — the flag grammar that selects the interactive runner.
indusagiframework — the Rusttui/tui_renderimmediate-mode rendering layer (render_streaming,build_structured_diff,Footer,StatusLine, the dialog widgets) the console wraps.- Console (Python edition) and Console (TS edition) — the parity references this Rust surface re-architects.
Notes:
- The crate is
induscode(Rust edition); the binaries areindusrandindusagir, both gated behind thetuifeature. ConsoleState.caretis a char offset, never a byte offset — every splice goes throughchar_indices(the §6.1 hazard).- The reducer is pure, total, and exhaustive (no
_arm); the view is immediate-mode and bottom-anchored inline (not the alt-screen). - Live session data (messages/usage/model) is projected from the conductor snapshot
on every draw, never stored in
ConsoleState.
