Skip to main content

atomcode_tuix/render/
mod.rs

1// crates/atomcode-tuix/src/render/mod.rs
2pub mod alt_screen;
3pub mod cell;
4pub mod plain;
5pub mod qr;
6pub mod retained;
7pub mod screen;
8pub mod theme;
9pub mod worker;
10
11use std::time::Duration;
12
13/// Semantic line to render. Renderer implementations translate this to bytes.
14///
15/// Permanent lines (User, Assistant, ToolCall, ToolResult, Diff, Approval,
16/// Error, Blank) all enter scrollback. Spinner and InputPrompt are transient.
17#[derive(Debug, Clone)]
18pub enum UiLine {
19    Welcome {
20        model: String,
21        working_dir: String,
22    },
23    User(String),
24    AssistantText(String),
25    /// LLM reasoning/thinking content (displayed in gray/dimmed style)
26    ReasoningText(String),
27    AssistantLineBreak,
28    ToolCall {
29        name: String,
30        detail: String,
31    },
32    /// Animated tool-call line. Pushed on `AgentEvent::ToolCallStarted`
33    /// instead of the static `ToolCall`, so the user sees the call land
34    /// the moment the model commits to it AND its leading icon ticks in
35    /// lockstep with the footer spinner via the live-row mechanism (see
36    /// `RetainedRenderer::push_or_update_inflight_tool`). Switched to a
37    /// static `▸` icon by `ToolCallCommit` once the matching result
38    /// lands, freeing the live-row slot for the spinner to resume.
39    ToolCallInFlight {
40        id: String,
41        name: String,
42        detail: String,
43    },
44    /// Freeze the most recent `ToolCallInFlight` row to its final
45    /// static `▸` icon. Emitted right before `ToolResult` so the
46    /// bottom body row stops animating exactly when the result is
47    /// about to be appended below it.
48    /// If `call_id` is provided, only commits if the inflight_tool matches.
49    ToolCallCommit {
50        call_id: Option<String>,
51    },
52    /// Push a parallel-tool batch as a live multi-row group: one
53    /// header line + N child rows (one per tool call), all visible
54    /// from the start. Subsequent `ToolGroupChildUpdate` events find
55    /// child rows by `call_id` and update them in place (CC-style
56    /// ✓ light-up). The group is "live" only as long as it remains
57    /// the bottom of body_lines; any other body push freezes it (in
58    /// place forever, but no further child updates take effect).
59    ToolGroupRender {
60        batch_id: String,
61        header: String,
62        children: Vec<ToolGroupChild>,
63    },
64    /// Update one child row inside an active live-group. Renderer
65    /// finds the row keyed by `call_id` and CUPs to its terminal
66    /// position to rewrite. Falls back to no-op if the group has been
67    /// frozen (other content was pushed below it).
68    ToolGroupChildUpdate {
69        batch_id: String,
70        call_id: String,
71        new_text: String,
72    },
73    /// One-shot summary line for a completed tool batch — rendered
74    /// with bold + brand-color emphasis so it stands out as the
75    /// "this is what happened" anchor (mirrors CC's task-completion
76    /// summary visual). Used by both ToolBatchCompleted and
77    /// SubAgentDispatchEnd.
78    ToolGroupSummary {
79        text: String,
80    },
81    ToolResult {
82        success: bool,
83        summary: String,
84    },
85    DiffLine {
86        added: bool,
87        text: String,
88    },
89    /// A batch of diff lines emitted in a single render call. Use this
90    /// instead of N individual `DiffLine` renders when a tool result
91    /// carries many changed lines — each `DiffLine` triggers a full
92    /// erase_footer + redraw_footer cycle, so 50 diff lines translate
93    /// into 50 footer redraws and tens of KB of ANSI, blocking the
94    /// event loop long enough to freeze the spinner. `DiffBlock` does
95    /// one erase + N writes + one redraw.
96    DiffBlock(Vec<DiffEntry>),
97    ApprovalPrompt {
98        tool: String,
99        detail: String,
100    },
101    Error(String),
102    /// Non-fatal advisory line (yellow). Visually distinct from `Error`
103    /// so the user can tell "we saw something fishy and want you to
104    /// know" apart from "the turn died." Currently used by the OpenAI
105    /// provider's truncation detector.
106    Warning(String),
107    TurnCancelled,
108    TurnComplete,
109    /// Legacy single-line spinner (kept for tests / PlainRenderer fallback).
110    /// During Streaming the event loop emits `StreamingBox` instead so the
111    /// spinner sits ABOVE the input box rather than inside it.
112    Spinner {
113        frame: &'static str,
114        label: String,
115    },
116    /// Clear the current transient line (prepares for a permanent write).
117    ClearTransient,
118    /// Draw the input prompt "> " + current buffer (transient, idle).
119    /// When `menu` is Some, a command palette is drawn above the box.
120    /// `cursor_byte` is a byte offset into `buf` — the renderer wraps
121    /// `buf` to the available input width and derives the 2D cursor
122    /// position (row, col) itself so the input box can grow multi-line
123    /// when the user exceeds a single row.
124    InputPrompt {
125        buf: String,
126        cursor_byte: usize,
127        menu: Option<MenuPayload>,
128        status: StatusLine,
129        /// Marker numbers (`N` from `[Image #N]`) that actually have
130        /// image bytes ready to ship — either freshly attached this
131        /// turn or recalled from cache via arrow-up. Renderers cross-
132        /// reference each marker against `buf` and draw a `└ [Image #N]`
133        /// preview row for the intersection right under the input box,
134        /// so users can tell "real attachment" from "literal text" at
135        /// a glance, before submit. Empty means no preview rows. Only
136        /// the main idle / streaming compose paths populate this; modal
137        /// flows that reuse `InputPrompt` for text entry pass `Vec::new()`.
138        attachments: Vec<usize>,
139    },
140    /// Streaming chrome: spinner line above a (possibly multi-line)
141    /// input box. Same `cursor_byte` semantics as `InputPrompt`.
142    /// When `menu` is Some (user typed `/` into the type-ahead buffer
143    /// mid-stream), the slash-command palette is drawn above the box
144    /// in place of the spinner — same rendering path as `InputPrompt`.
145    StreamingBox {
146        buf: String,
147        cursor_byte: usize,
148        frame: &'static str,
149        label: String,
150        status: StatusLine,
151        menu: Option<MenuPayload>,
152        /// Same semantics as `InputPrompt::attachments` — type-ahead
153        /// during streaming can carry pasted attachments too, so the
154        /// preview path needs to fire here as well.
155        attachments: Vec<usize>,
156    },
157    /// User pressed Enter: commit the current InputPrompt to scrollback.
158    InputCommit,
159    /// Slash-command output (arbitrary text, already sanitised by caller).
160    CommandOutput(String),
161    /// Image-attachment echo (`└ [Image #N]`). Emitted right after the
162    /// `UiLine::User` row that contains the matching `[Image #N]`
163    /// marker, so each renderer can align the `└` glyph at the same
164    /// column as the `[` of the marker in the user message above
165    /// (col 2). A dedicated variant rather than `CommandOutput` so
166    /// alignment stays consistent across renderers — retained's
167    /// `push_body_text` auto-prefixes PAD_COL (2 spaces) but
168    /// alt-screen's `push_command_output` does not, so the same
169    /// CommandOutput payload would land at col 2 in one and col 4
170    /// (or col 0) in the other.
171    ImageAttachment(usize),
172    /// One-line success notice for vision-preprocessor OCR. Renders as
173    /// `{msg}  {model}` where `msg` uses the default text style and
174    /// `model` uses the Muted (gray) role — visually distinct from
175    /// failure (yellow `! ...`) and from arbitrary command output.
176    /// The actual VL description is intentionally NOT shown in the UI;
177    /// it still rides into conversation history for the main model.
178    VisionPreprocessSuccess {
179        msg: String,
180        model: String,
181    },
182    /// A visible separator between turns: `────── {label} ──────`.
183    TurnSeparator {
184        label: String,
185    },
186}
187
188pub trait Renderer: Send {
189    /// Emit one UiLine. Implementations may batch internally; call `flush()` to force.
190    fn render(&mut self, line: UiLine);
191    fn flush(&mut self);
192    /// Shutdown: disable bracketed paste, disable raw mode, etc.
193    fn shutdown(&mut self);
194    /// Forget all cached rendering state (footer rows, last footer snapshot,
195    /// assistant-text mid-line buffer, markdown parser) AND clear the
196    /// physical terminal screen. Used by callers that hand control back
197    /// to a non-TUI process (e.g. the blocking OAuth flow in /login)
198    /// and then want a clean slate — without this, the next render
199    /// tries to `erase_footer` at a position the terminal cursor is no
200    /// longer at, corrupting every subsequent ANSI cursor move.
201    fn reset(&mut self);
202
203    /// Wipe the physical terminal with `\x1b[2J\x1b[H` and flush.
204    /// **Does not** touch cached footer/stream state — callers that want a
205    /// full state wipe should call `reset()` instead. Use this when only
206    /// the visible scrollback should be cleared (e.g. the `/clear`
207    /// command after which the footer immediately redraws).
208    fn clear_screen(&mut self);
209
210    /// Hand the terminal off to a non-TUI child process (blocking OAuth
211    /// flow, `/shell`, etc.): disable raw mode + bracketed paste, finish
212    /// any pending writes. After this returns, the child is free to use
213    /// the terminal in cooked mode; `resume_from_external()` must be
214    /// called before any further `render()` calls.
215    fn suspend_for_external(&mut self);
216
217    /// Take the terminal back after `suspend_for_external()`: re-enable
218    /// raw mode + bracketed paste AND call `reset()` to wipe the cached
219    /// state (the child wrote to stdout in cooked mode, so our cursor
220    /// tracking is now lying).
221    fn resume_from_external(&mut self);
222
223    /// Paint any throttled payload that's been sitting in the deferred
224    /// queue past its throttle window. Called from the event loop on a
225    /// ~50fps timer so the "trailing edge" of a burst of input renders
226    /// actually lands — without this tick a lone stale payload would
227    /// stay invisible until the next unrelated render arrived.
228    ///
229    /// Implementations without throttling (e.g. PlainRenderer) can
230    /// treat this as a flush.
231    fn flush_deferred(&mut self);
232
233    /// Remove the most recent `ApprovalPrompt` body row, if the tail
234    /// row is one. Called by the event loop after the user responds
235    /// Y/A/N so the prompt stops sitting in the body above the footer.
236    /// Default: no-op — implementations that stream body lines to
237    /// stdout (plain/pipe mode) can't retract them.
238    fn pop_approval_prompt(&mut self) {}
239
240    /// Terminal window was resized to `(cols, rows)`. DECSTBM-based
241    /// renderers must re-issue the scroll region (`\x1b[1;H-N r`) so
242    /// the fixed footer stays pinned to the new bottom. Non-DECSTBM
243    /// renderers can treat this as a redraw hint or a no-op.
244    ///
245    /// Default is no-op — backends that don't care about geometry
246    /// (Plain, tests) don't need to override.
247    fn on_resize(&mut self, _cols: u16, _rows: u16) {}
248
249    /// Scroll the body viewport up (negative `delta`) or down
250    /// (positive `delta`) by `delta` rows. Used by AltScreenRenderer
251    /// to support PageUp / PageDown / arrow-up scrollback navigation
252    /// inside the alt-screen (where the host terminal's native
253    /// scrollback is unavailable).
254    ///
255    /// Default no-op for renderers that delegate scrollback to the
256    /// host terminal (RetainedRenderer's DECSTBM path; PlainRenderer
257    /// streaming to stdout).
258    fn scroll_body(&mut self, _delta: i32) {}
259
260    /// Jump the body viewport to the absolute top / bottom of
261    /// scrollback. Used for Home / End key handling.
262    fn scroll_body_to_top(&mut self) {}
263    fn scroll_body_to_bottom(&mut self) {}
264
265    /// Mouse text-selection hooks. Backends that own mouse capture can
266    /// override these; streaming/native-scrollback backends keep host
267    /// terminal selection behavior and no-op here.
268    fn begin_selection(&mut self, _col: u16, _row: u16) {}
269    fn update_selection(&mut self, _col: u16, _row: u16) {}
270    fn end_selection(&mut self) {}
271
272    /// Copy the current mouse-selection text to the system clipboard
273    /// (using arboard, not OSC 52) and clear the selection highlight.
274    /// Returns `true` if a non-empty selection was copied.
275    ///
276    /// This is the Ctrl+C fallback for terminals (Windows Terminal,
277    /// conhost) that ignore OSC 52 — the user selects text with the
278    /// mouse, then presses Ctrl+C to copy it. AltScreenRenderer
279    /// implements this; other backends return `false` (they use the
280    /// host terminal's native selection).
281    fn copy_selection(&mut self) -> bool {
282        false
283    }
284
285    /// Update the cached welcome banner's model / working_dir fields in
286    /// place and trigger a repaint of the banner rows. Used after the
287    /// QR-onboarding `/codingplan` claim finishes: the banner was
288    /// painted at the top of scrollback with `model=""` (the claim
289    /// hadn't picked a default provider yet) — once the claim writes
290    /// `ctx.model_name`, this hook splices the resolved model into the
291    /// existing banner rows so the user doesn't see a permanently
292    /// blank model bullet.
293    ///
294    /// Default no-op: renderers without a retained body buffer can't
295    /// edit already-emitted rows in place.
296    fn refresh_welcome_banner(&mut self, _model: &str, _working_dir: &str) {}
297}
298
299/// Visual style for the menu popup. Drives whether the renderer prefixes
300/// each row with `/` (slash-command palette) or `+ ` (file/dir mention),
301/// and which marker indicates the selected row.
302#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
303pub enum MenuKind {
304    /// Default: rows shown as `/<name>`, selected row marked `▸`.
305    #[default]
306    SlashCommand,
307    /// `@`-mention popup: rows shown as `+ <path>`, no slash prefix.
308    /// Selected row uses reverse-video only (no extra arrow).
309    AtMention,
310}
311
312/// Slash-command palette payload: filtered entries + which one is selected.
313#[derive(Debug, Clone, Default)]
314pub struct MenuPayload {
315    pub items: Vec<(String, String)>, // (name, desc)
316    pub selected: usize,
317    /// Visual style. Defaults to `SlashCommand`; existing call sites
318    /// using `MenuPayload { items, selected }` get the slash style for
319    /// free. `@`-mention path explicitly sets `MenuKind::AtMention`.
320    pub kind: MenuKind,
321}
322
323/// Persistent status line drawn directly below the input box — CC-style
324/// Severity classification for the right-aligned status hint.
325/// Warning → Role::Error (red, e.g. "no provider", "model retired").
326/// Info → Role::Muted (dim, e.g. "new version available", drift notice).
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
328pub enum HintSeverity {
329    #[default]
330    Warning,
331    Info,
332}
333
334/// "model · cwd · ctx_used / ctx_window" chrome. Visible in both Idle
335/// and Streaming phases so the user always sees what provider is active
336/// and how much of the context window is currently in use. Cumulative
337/// session token totals are NOT shown here — they're per-session and
338/// don't tell the user whether the next turn is at risk of overflow.
339/// `ctx_used` answers "what does the model see right now"; `ctx_window`
340/// is the cap. Together they answer "how close are we to compaction".
341#[derive(Debug, Clone, Default)]
342pub struct StatusLine {
343    pub model: String,
344    pub cwd: String, // HOME replaced with "~"
345    /// Tokens currently in the model's context (last turn's `sent_tokens`).
346    /// Pre-first-turn this is 0; the renderer hides the field then.
347    pub ctx_used: usize,
348    /// Provider's context window (cap). 0 when not yet known — renderer
349    /// falls back to a bare "12.3k tok" display in that case.
350    pub ctx_window: usize,
351    /// Right-aligned passive hint with severity. `Warning` renders red
352    /// (no-provider nudge, CodingPlan model-missing); `Info` renders
353    /// muted (upgrade banner, CodingPlan drift notice). None → no hint.
354    pub hint: Option<(String, HintSeverity)>,
355    /// Left-aligned mode indicator, prepended before `model`. Present
356    /// only when the user explicitly switched to a non-default agent
357    /// mode (Plan today; conceivably others later). `None` for the
358    /// default Build mode so the status row doesn't gain noise for
359    /// the common case. Renders in brand color (Role::Brand) to draw
360    /// the eye — switching modes changes whether file edits and shell
361    /// run, so the user wants this prominent.
362    pub mode_indicator: Option<String>,
363    /// Current session display name, shown as a right-aligned cyan
364    /// pill overlaid on the input box's top rule. `Some` only after
365    /// the user has explicitly run `/rename` (Session::user_renamed) —
366    /// auto-named / default sessions leave this `None` to keep the
367    /// chrome quiet on fresh conversations.
368    pub session_name: Option<String>,
369}
370
371/// One line in a diff batch. `added = true` renders as `+`, false as `-`.
372#[derive(Debug, Clone)]
373pub struct DiffEntry {
374    pub added: bool,
375    pub text: String,
376}
377
378/// One child entry inside a `UiLine::ToolGroupRender` payload. `call_id`
379/// is the model-supplied tool-call id; `text` is the display string the
380/// renderer initially prints (e.g. `↳ Read File foo.rs`). Subsequent
381/// `ToolGroupChildUpdate` events with the same call_id rewrite this row
382/// in place (e.g. to `↳ ✓ Read File foo.rs`).
383#[derive(Debug, Clone)]
384pub struct ToolGroupChild {
385    pub call_id: String,
386    pub text: String,
387}
388
389/// Convert a Duration to a short label like "1.2s" or "340ms".
390pub fn fmt_dur(d: Duration) -> String {
391    let ms = d.as_millis();
392    if ms < 1000 {
393        format!("{}ms", ms)
394    } else {
395        format!("{:.1}s", d.as_secs_f64())
396    }
397}