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}