// crates/atomcode-tuix/src/render/mod.rs
#[cfg(windows)]
pub mod conhost;
pub mod cell;
pub mod plain;
pub mod qr;
pub mod retained;
pub mod screen;
pub mod theme;
pub mod worker;
use std::time::Duration;
/// Boundary marker for an originated message in the body buffer. Drives
/// "jump to prev/next message" navigation keys. Marked at push time;
/// kept in sync when body_lines drains from the front.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkKind {
User,
Assistant,
ToolCall,
ToolResult,
}
#[derive(Debug, Clone, Copy)]
pub struct MessageMark {
/// Index into the renderer's visible body buffer (`Vec<Vec<Cell>>`).
/// Drives "jump to message" — viewport_top is compared against this.
pub line_idx: usize,
pub kind: MarkKind,
}
/// Semantic line to render. Renderer implementations translate this to bytes.
///
/// Permanent lines (User, Assistant, ToolCall, ToolResult, Diff, Approval,
/// Error, Blank) all enter scrollback. Spinner and InputPrompt are transient.
#[derive(Debug, Clone)]
pub enum UiLine {
Welcome {
model: String,
working_dir: String,
},
User(String),
AssistantText(String),
/// LLM reasoning/thinking content (displayed in gray/dimmed style)
ReasoningText(String),
AssistantLineBreak,
ToolCall {
name: String,
detail: String,
},
/// Animated tool-call line. Pushed on `AgentEvent::ToolCallStarted`
/// instead of the static `ToolCall`, so the user sees the call land
/// the moment the model commits to it AND its leading icon ticks in
/// lockstep with the footer spinner via the live-row mechanism (see
/// `RetainedRenderer::push_or_update_inflight_tool`). Switched to a
/// static `▸` icon by `ToolCallCommit` once the matching result
/// lands, freeing the live-row slot for the spinner to resume.
ToolCallInFlight {
id: String,
name: String,
detail: String,
},
/// Freeze the most recent `ToolCallInFlight` row to its final
/// static `▸` icon. Emitted right before `ToolResult` so the
/// bottom body row stops animating exactly when the result is
/// about to be appended below it.
/// If `call_id` is provided, only commits if the inflight_tool matches.
ToolCallCommit {
call_id: Option<String>,
},
/// Push a parallel-tool batch as a live multi-row group: one
/// header line + N child rows (one per tool call), all visible
/// from the start. Subsequent `ToolGroupChildUpdate` events find
/// child rows by `call_id` and update them in place (CC-style
/// ✓ light-up). The group is "live" only as long as it remains
/// the bottom of body_lines; any other body push freezes it (in
/// place forever, but no further child updates take effect).
ToolGroupRender {
batch_id: String,
header: String,
children: Vec<ToolGroupChild>,
},
/// Update one child row inside an active live-group. Renderer
/// finds the row keyed by `call_id` and CUPs to its terminal
/// position to rewrite. Falls back to no-op if the group has been
/// frozen (other content was pushed below it).
ToolGroupChildUpdate {
batch_id: String,
call_id: String,
new_text: String,
},
/// One-shot summary line for a completed tool batch — rendered
/// with bold + brand-color emphasis so it stands out as the
/// "this is what happened" anchor (mirrors CC's task-completion
/// summary visual). Used by both ToolBatchCompleted and
/// SubAgentDispatchEnd.
ToolGroupSummary {
text: String,
},
ToolResult {
success: bool,
summary: String,
},
DiffLine {
added: bool,
text: String,
},
/// A batch of diff lines emitted in a single render call. Use this
/// instead of N individual `DiffLine` renders when a tool result
/// carries many changed lines — each `DiffLine` triggers a full
/// erase_footer + redraw_footer cycle, so 50 diff lines translate
/// into 50 footer redraws and tens of KB of ANSI, blocking the
/// event loop long enough to freeze the spinner. `DiffBlock` does
/// one erase + N writes + one redraw.
DiffBlock(Vec<DiffEntry>),
ApprovalPrompt {
tool: String,
detail: String,
},
Error(String),
/// Non-fatal advisory line (yellow). Visually distinct from `Error`
/// so the user can tell "we saw something fishy and want you to
/// know" apart from "the turn died." Currently used by the OpenAI
/// provider's truncation detector.
Warning(String),
TurnCancelled,
TurnComplete,
/// Legacy single-line spinner (kept for tests / PlainRenderer fallback).
/// During Streaming the event loop emits `StreamingBox` instead so the
/// spinner sits ABOVE the input box rather than inside it.
Spinner {
frame: &'static str,
label: String,
},
/// Clear the current transient line (prepares for a permanent write).
ClearTransient,
/// Draw the input prompt "> " + current buffer (transient, idle).
/// When `menu` is Some, a command palette is drawn above the box.
/// `cursor_byte` is a byte offset into `buf` — the renderer wraps
/// `buf` to the available input width and derives the 2D cursor
/// position (row, col) itself so the input box can grow multi-line
/// when the user exceeds a single row.
InputPrompt {
buf: String,
cursor_byte: usize,
menu: Option<MenuPayload>,
status: StatusLine,
/// Marker numbers (`N` from `[Image #N]`) that actually have
/// image bytes ready to ship — either freshly attached this
/// turn or recalled from cache via arrow-up. Renderers cross-
/// reference each marker against `buf` and draw a `└ [Image #N]`
/// preview row for the intersection right under the input box,
/// so users can tell "real attachment" from "literal text" at
/// a glance, before submit. Empty means no preview rows. Only
/// the main idle / streaming compose paths populate this; modal
/// flows that reuse `InputPrompt` for text entry pass `Vec::new()`.
attachments: Vec<usize>,
},
/// Streaming chrome: spinner line above a (possibly multi-line)
/// input box. Same `cursor_byte` semantics as `InputPrompt`.
/// When `menu` is Some (user typed `/` into the type-ahead buffer
/// mid-stream), the slash-command palette is drawn above the box
/// in place of the spinner — same rendering path as `InputPrompt`.
StreamingBox {
buf: String,
cursor_byte: usize,
frame: &'static str,
label: String,
status: StatusLine,
menu: Option<MenuPayload>,
/// Same semantics as `InputPrompt::attachments` — type-ahead
/// during streaming can carry pasted attachments too, so the
/// preview path needs to fire here as well.
attachments: Vec<usize>,
},
/// User pressed Enter: commit the current InputPrompt to scrollback.
InputCommit,
/// Slash-command output (arbitrary text, already sanitised by caller).
CommandOutput(String),
/// Image-attachment echo (`└ [Image #N]`). Emitted right after the
/// `UiLine::User` row that contains the matching `[Image #N]`
/// marker, so each renderer can align the `└` glyph at the same
/// column as the `[` of the marker in the user message above
/// (col 2). A dedicated variant rather than `CommandOutput` so
/// alignment stays consistent across renderers — retained's
/// `push_body_text` auto-prefixes PAD_COL (2 spaces) but
/// alt-screen's `push_command_output` does not, so the same
/// CommandOutput payload would land at col 2 in one and col 4
/// (or col 0) in the other.
ImageAttachment(usize),
/// One-line success notice for vision-preprocessor OCR. Renders as
/// `{msg} {model}` where `msg` uses the default text style and
/// `model` uses the Muted (gray) role — visually distinct from
/// failure (yellow `! ...`) and from arbitrary command output.
/// The actual VL description is intentionally NOT shown in the UI;
/// it still rides into conversation history for the main model.
VisionPreprocessSuccess {
msg: String,
model: String,
},
/// A visible separator between turns: `────── {label} ──────`.
TurnSeparator {
label: String,
},
}
pub trait Renderer: Send {
/// Emit one UiLine. Implementations may batch internally; call `flush()` to force.
fn render(&mut self, line: UiLine);
fn flush(&mut self);
/// Shutdown: disable bracketed paste, disable raw mode, etc.
fn shutdown(&mut self);
/// Forget all cached rendering state (footer rows, last footer snapshot,
/// assistant-text mid-line buffer, markdown parser) AND clear the
/// physical terminal screen. Used by callers that hand control back
/// to a non-TUI process (e.g. the blocking OAuth flow in /login)
/// and then want a clean slate — without this, the next render
/// tries to `erase_footer` at a position the terminal cursor is no
/// longer at, corrupting every subsequent ANSI cursor move.
fn reset(&mut self);
/// Wipe the physical terminal with `\x1b[2J\x1b[H` and flush.
/// **Does not** touch cached footer/stream state — callers that want a
/// full state wipe should call `reset()` instead. Use this when only
/// the visible scrollback should be cleared (e.g. the `/clear`
/// command after which the footer immediately redraws).
fn clear_screen(&mut self);
/// Hand the terminal off to a non-TUI child process (blocking OAuth
/// flow, `/shell`, etc.): disable raw mode + bracketed paste, finish
/// any pending writes. After this returns, the child is free to use
/// the terminal in cooked mode; `resume_from_external()` must be
/// called before any further `render()` calls.
fn suspend_for_external(&mut self);
/// Take the terminal back after `suspend_for_external()`: re-enable
/// raw mode + bracketed paste AND call `reset()` to wipe the cached
/// state (the child wrote to stdout in cooked mode, so our cursor
/// tracking is now lying).
fn resume_from_external(&mut self);
/// Paint any throttled payload that's been sitting in the deferred
/// queue past its throttle window. Called from the event loop on a
/// ~50fps timer so the "trailing edge" of a burst of input renders
/// actually lands — without this tick a lone stale payload would
/// stay invisible until the next unrelated render arrived.
///
/// Implementations without throttling (e.g. PlainRenderer) can
/// treat this as a flush.
fn flush_deferred(&mut self);
/// Remove the most recent `ApprovalPrompt` body row, if the tail
/// row is one. Called by the event loop after the user responds
/// Y/A/N so the prompt stops sitting in the body above the footer.
/// Default: no-op — implementations that stream body lines to
/// stdout (plain/pipe mode) can't retract them.
fn pop_approval_prompt(&mut self) {}
/// Terminal window was resized to `(cols, rows)`. The retained
/// backend uses this to re-flow body width and reposition the
/// pinned footer; non-geometry-sensitive backends (Plain, tests)
/// keep the no-op default.
fn on_resize(&mut self, _cols: u16, _rows: u16) {}
/// Scroll the body viewport up (negative `delta`) or down
/// (positive `delta`) by `delta` rows.
///
/// Default no-op for renderers that delegate scrollback to the
/// host terminal (RetainedRenderer's append-only path; PlainRenderer
/// streaming to stdout).
fn scroll_body(&mut self, _delta: i32) {}
/// Jump the body viewport to the absolute top / bottom of
/// scrollback. Used for Home / End key handling.
fn scroll_body_to_top(&mut self) {}
fn scroll_body_to_bottom(&mut self) {}
/// Update the cached welcome banner's model / working_dir fields in
/// place and trigger a repaint of the banner rows. Used after the
/// QR-onboarding `/codingplan` claim finishes: the banner was
/// painted at the top of scrollback with `model=""` (the claim
/// hadn't picked a default provider yet) — once the claim writes
/// `ctx.model_name`, this hook splices the resolved model into the
/// existing banner rows so the user doesn't see a permanently
/// blank model bullet.
///
/// Default no-op: renderers without a retained body buffer can't
/// edit already-emitted rows in place.
fn refresh_welcome_banner(&mut self, _model: &str, _working_dir: &str) {}
/// Jump body viewport to the prev/next message boundary. No-op when no
/// such boundary exists in the configured direction.
fn scroll_to_prev_message(&mut self) {}
fn scroll_to_next_message(&mut self) {}
fn scroll_to_prev_user_message(&mut self) {}
fn scroll_to_next_user_message(&mut self) {}
}
/// Visual style for the menu popup. Drives whether the renderer prefixes
/// each row with `/` (slash-command palette) or `+ ` (file/dir mention),
/// and which marker indicates the selected row.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MenuKind {
/// Default: rows shown as `/<name>`, selected row marked `▸`.
#[default]
SlashCommand,
/// `@`-mention popup: rows shown as `+ <path>`, no slash prefix.
/// Selected row uses reverse-video only (no extra arrow).
AtMention,
/// `$`-trigger skills picker. Rows show the bare skill name + description,
/// no `/`, `/skills`, or `$` prefix; selection marked with `▸`.
Skill,
/// Two-column list: name left-aligned, desc right-aligned,
/// selected row uses reverse-video (no prefix, no arrow).
/// Used by session picker.
/// `row_prefix` is prepended before the name (e.g. `/`).
/// `selected_marker` is shown before the prefix for the selected row;
/// unselected rows get `display_width(marker)` spaces.
TwoColumn {
row_prefix: &'static str,
selected_marker: &'static str,
},
}
impl MenuKind {
/// Max visible rows for this menu kind. Both `paint_footer` and
/// `current_footer_rows` use this so the estimate matches actual
/// rendering.
pub fn max_visible_rows(&self, screen_height: usize, item_count: usize) -> usize {
match self {
MenuKind::SlashCommand | MenuKind::AtMention | MenuKind::Skill => item_count.min(4),
// Window cap is `max(h/2, 4)`, but never reserve more rows than
// there are items — `paint_footer` only paints `item_count`
// rows when there are fewer than the cap, so `.max(4)` MUST
// apply to the cap, not the final value, or `current_footer_rows`
// over-estimates the footer height for short lists (< 4 items)
// and desyncs from the actual paint.
MenuKind::TwoColumn { .. } => item_count.min((screen_height / 2).max(4)),
}
}
}
/// Slash-command palette payload: filtered entries + which one is selected.
#[derive(Debug, Clone, Default)]
pub struct MenuPayload {
pub items: Vec<(String, String)>, // (name, desc)
pub selected: usize,
/// Visual style. Defaults to `SlashCommand`; existing call sites
/// using `MenuPayload { items, selected }` get the slash style for
/// free. `@`-mention path explicitly sets `MenuKind::AtMention`.
pub kind: MenuKind,
}
/// Persistent status line drawn directly below the input box — CC-style
/// Severity classification for the right-aligned status hint.
/// Warning → Role::Error (red, e.g. "no provider", "model retired").
/// Info → Role::Muted (dim, e.g. "new version available", drift notice).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HintSeverity {
#[default]
Warning,
Info,
}
/// "model · cwd · ctx_used / ctx_window" chrome. Visible in both Idle
/// and Streaming phases so the user always sees what provider is active
/// and how much of the context window is currently in use. Cumulative
/// session token totals are NOT shown here — they're per-session and
/// don't tell the user whether the next turn is at risk of overflow.
/// `ctx_used` answers "what does the model see right now"; `ctx_window`
/// is the cap. Together they answer "how close are we to compaction".
#[derive(Debug, Clone, Default)]
pub struct StatusLine {
pub model: String,
pub cwd: String, // HOME replaced with "~"
/// Tokens currently in the model's context (last turn's `sent_tokens`).
/// Pre-first-turn this is 0; the renderer hides the field then.
pub ctx_used: usize,
/// Provider's context window (cap). 0 when not yet known — renderer
/// falls back to a bare "12.3k tok" display in that case.
pub ctx_window: usize,
/// Right-aligned passive hint with severity. `Warning` renders red
/// (no-provider nudge, CodingPlan model-missing); `Info` renders
/// muted (upgrade banner, CodingPlan drift notice). None → no hint.
pub hint: Option<(String, HintSeverity)>,
/// Left-aligned mode indicator, prepended before `model`. Present
/// only when the user explicitly switched to a non-default agent
/// mode (Plan today; conceivably others later). `None` for the
/// default Build mode so the status row doesn't gain noise for
/// the common case. Renders in brand color (Role::Brand) to draw
/// the eye — switching modes changes whether file edits and shell
/// run, so the user wants this prominent.
pub mode_indicator: Option<String>,
/// Right-aligned bypass indicator, appended after `hint` on the
/// right side of the status row. Shown when
/// `--dangerously-skip-permissions / -y` is active, rendering a
/// yellow warning badge so the user is always aware that all tool
/// calls are auto-approved. Kept separate from `mode_indicator`
/// (left-aligned PLAN/Build badge) so BYPASS does not displace
/// the mode indicator.
pub bypass_indicator: Option<String>,
/// Current session display name, shown as a right-aligned cyan
/// pill overlaid on the input box's top rule. `Some` only after
/// the user has explicitly run `/rename` (Session::user_renamed) —
/// auto-named / default sessions leave this `None` to keep the
/// chrome quiet on fresh conversations.
pub session_name: Option<String>,
/// Current reasoning_effort for the active provider's model.
/// None = not set (API uses its own default). Cycled via Ctrl+T.
pub reasoning_effort: Option<String>,
}
/// One line in a diff batch. `added = true` renders as `+`, false as `-`.
#[derive(Debug, Clone)]
pub struct DiffEntry {
pub added: bool,
pub text: String,
}
/// One child entry inside a `UiLine::ToolGroupRender` payload. `call_id`
/// is the model-supplied tool-call id; `text` is the display string the
/// renderer initially prints (e.g. `↳ Read File foo.rs`). Subsequent
/// `ToolGroupChildUpdate` events with the same call_id rewrite this row
/// in place (e.g. to `↳ ✓ Read File foo.rs`).
#[derive(Debug, Clone)]
pub struct ToolGroupChild {
pub call_id: String,
pub text: String,
}
/// Convert a Duration to a short label like "1.2s" or "340ms".
pub fn fmt_dur(d: Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
format!("{}ms", ms)
} else {
format!("{:.1}s", d.as_secs_f64())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn two_column() -> MenuKind {
MenuKind::TwoColumn {
row_prefix: "",
selected_marker: "▸",
}
}
// `current_footer_rows` reserves `max_visible_rows` rows while
// `paint_footer` only paints `min(item_count, cap)` rows. For the two
// to agree, `max_visible_rows` must never exceed `item_count`.
#[test]
fn two_column_never_reserves_more_than_item_count() {
let k = two_column();
// Short lists: must equal item_count, NOT the 4-row floor.
assert_eq!(k.max_visible_rows(40, 0), 0);
assert_eq!(k.max_visible_rows(40, 1), 1);
assert_eq!(k.max_visible_rows(40, 2), 2);
assert_eq!(k.max_visible_rows(40, 3), 3);
}
#[test]
fn two_column_caps_at_half_screen_for_long_lists() {
let k = two_column();
// 50 items, height 40 → window cap = max(20, 4) = 20.
assert_eq!(k.max_visible_rows(40, 50), 20);
// At the cap boundary.
assert_eq!(k.max_visible_rows(40, 20), 20);
assert_eq!(k.max_visible_rows(40, 19), 19);
}
#[test]
fn two_column_floor_keeps_at_least_four_on_tiny_screens() {
let k = two_column();
// Tiny screen (h/2 = 3) with plenty of items → floor lifts cap to 4.
assert_eq!(k.max_visible_rows(6, 50), 4);
// ...but still never more than the item count.
assert_eq!(k.max_visible_rows(6, 2), 2);
}
#[test]
fn fixed_kinds_cap_at_four() {
for k in [MenuKind::SlashCommand, MenuKind::AtMention, MenuKind::Skill] {
assert_eq!(k.max_visible_rows(40, 2), 2);
assert_eq!(k.max_visible_rows(40, 10), 4);
}
}
}