Skip to main content

rskit_cli/live/
renderer.rs

1//! [`LiveConsole`] — a multi-region live terminal renderer.
2//!
3//! Renders several concurrent output streams as fixed-height "tiles" stacked in a live area at the bottom of the terminal,
4//! each showing a bounded virtual terminal of one stream (via [`RegionScreen`]).
5//! The tiles are an ephemeral progress peek:
6//! rows that scroll off the top are dropped from the live view, not flushed to scrollback,
7//! so a chatty stream does not flood the transcript.
8//! Durable signal is emitted only at [`finish`](LiveConsole::finish) time — a one-line verdict — and,
9//! for a failed region,
10//! a bounded replay of its retained tail via [`finish_with_replay`](LiveConsole::finish_with_replay).
11//!
12//! The terminal mechanics (cursor positioning, redraw rate-limiting, width handling, resize) are delegated to `indicatif`'s multi-progress engine,
13//! so this type only owns the tile layout, the bounded failure ring, and the verdict flush.
14//! It is generic: callers feed labeled byte streams and get terminal rendering out,
15//! with no domain types involved.
16
17use std::collections::HashMap;
18use std::collections::VecDeque;
19
20use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
21
22use super::screen::RegionScreen;
23
24/// The string each tile indents its content by, beneath the region header.
25/// The virtual terminal grid is sized to the tile width minus this indent's width
26/// so a child's output fills the visible content area exactly, without being chopped.
27const TILE_INDENT: &str = "  ";
28
29/// How the live console lays out and truncates tiles.
30///
31/// The console does not auto-detect the terminal width or react to resizes:
32/// `cols` is a fixed tile width the caller supplies once.
33/// Passing a value that does not match the real terminal only over- or under-truncates the tiles;
34/// it never corrupts output, since every tile line is clamped to `cols`.
35#[derive(Debug, Clone, Copy)]
36pub struct LiveConfig {
37    /// Maximum content rows a region tile may occupy — the height of its virtual terminal
38    /// and the cap a tile grows to. A tile does not reserve this height: it starts at just its header
39    /// and grows with its content up to this many rows (see [`LiveConsole::feed`]), so a silent
40    /// or short-lived region stays small instead of padding out a tall empty block.
41    pub rows: usize,
42    /// Terminal columns: the tile width.
43    /// A child's output is applied to a grid sized to the visible content area (this width minus the content indent),
44    /// so a real width must be passed (unlike the old truncation width, `0` is not "disable").
45    pub cols: usize,
46    /// How many rows that scroll off a tile are retained per region for a failure replay.
47    /// The live tile stays a bounded peek;
48    /// on failure this many of the most-recent scrolled-off rows (plus the final on-screen rows) are flushed to scrollback as the failure block.
49    /// `0` retains nothing, so a failure replays only the rows still on screen.
50    pub scrollback: usize,
51}
52
53impl Default for LiveConfig {
54    fn default() -> Self {
55        Self {
56            rows: 5,
57            cols: 80,
58            scrollback: 200,
59        }
60    }
61}
62
63impl LiveConfig {
64    /// The inner virtual-terminal grid width:
65    /// the tile width minus the content indent rendered under each region header.
66    ///
67    /// A child whose output is fed to the tile must be told its terminal is this wide (not the full tile width),
68    /// so its own line wrapping matches the grid.
69    /// Otherwise a full-width in-place progress redraw wraps at the grid edge, scrolls the short grid,
70    /// and churns the retained failure tail with stale half-frames on every tick —
71    /// which then surface in the bounded replay when a region fails.
72    #[must_use]
73    pub fn content_cols(&self) -> usize {
74        self.cols.saturating_sub(TILE_INDENT.len()).max(1)
75    }
76}
77
78/// One in-flight region: its virtual terminal, label, backing progress line,
79/// and a bounded ring of the rows that have scrolled off its tile — kept only
80/// so a failure can replay recent context.
81struct Region {
82    bar: ProgressBar,
83    screen: RegionScreen,
84    label: String,
85    retained: VecDeque<String>,
86    /// The tallest content the tile has shown so far, capped at the grid height.
87    /// The tile is rendered to this many rows so it grows with output but never shrinks mid-run —
88    /// output that clears (an in-place progress redraw) leaves the tile at its high-water height rather than collapsing
89    /// and reflowing.
90    high_water: usize,
91}
92
93/// A live, multi-region terminal renderer.
94///
95/// Lifecycle per region: [`begin`](Self::begin) with a label,
96/// [`feed`](Self::feed) raw bytes as they arrive,
97/// then [`finish`](Self::finish) with a one-line verdict.
98/// [`set_header`](Self::set_header) updates a status line pinned above the tiles.
99pub struct LiveConsole {
100    multi: MultiProgress,
101    header: ProgressBar,
102    regions: HashMap<String, Region>,
103    config: LiveConfig,
104}
105
106impl LiveConsole {
107    /// Create a console that renders to stderr.
108    ///
109    /// stderr keeps the live UI off stdout, leaving any machine-readable stream there uncorrupted.
110    #[must_use]
111    pub fn to_stderr(config: LiveConfig) -> Self {
112        Self::with_target(ProgressDrawTarget::stderr(), config)
113    }
114
115    /// Create a console whose output is discarded — for tests
116    /// and non-terminal runs where the live area must not render.
117    #[must_use]
118    pub fn hidden(config: LiveConfig) -> Self {
119        Self::with_target(ProgressDrawTarget::hidden(), config)
120    }
121
122    fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
123        // Keep the grid and the renderer consistent: a tile always shows at least one content row
124        // and its virtual terminal needs a real width.
125        config.rows = config.rows.max(1);
126        config.cols = config.cols.max(1);
127        let multi = MultiProgress::with_draw_target(target);
128        let header = multi.add(ProgressBar::new(0));
129        header.set_style(message_style());
130        Self {
131            multi,
132            header,
133            regions: HashMap::new(),
134            config,
135        }
136    }
137
138    /// The virtual terminal grid width: the tile width minus the content indent,
139    /// so a child fills the visible content area without being chopped.
140    /// Children feeding a tile should be sized to this; see [`LiveConfig::content_cols`].
141    #[must_use]
142    pub fn content_cols(&self) -> usize {
143        self.config.content_cols()
144    }
145
146    /// Set the status line shown above the tiles.
147    pub fn set_header(&self, text: impl Into<String>) {
148        self.header.set_message(text.into());
149    }
150
151    /// Start a new region tile labeled `label`, keyed by `id`.
152    ///
153    /// A duplicate `id` discards the existing region first — dropping its tile and retained tail —
154    /// so a re-used key neither leaks a stale tile nor double-reports.
155    pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
156        let id = id.into();
157        let label = label.into();
158        if let Some(old) = self.regions.remove(&id) {
159            old.bar.finish_and_clear();
160            self.multi.remove(&old.bar);
161        }
162        let bar = self.multi.add(ProgressBar::new(0));
163        bar.set_style(message_style());
164        let region = Region {
165            bar,
166            screen: RegionScreen::new(self.config.rows, self.content_cols()),
167            label,
168            retained: VecDeque::new(),
169            high_water: 0,
170        };
171        // A fresh region shows only its header: it grows to fit content as bytes arrive,
172        // so a silent or instantly-finishing unit never paints a tall block of blank rows.
173        region.bar.set_message(render_tile(
174            &region.label,
175            &[] as &[&str],
176            self.config.cols,
177            0,
178        ));
179        self.regions.insert(id, region);
180    }
181
182    /// Feed raw output bytes to region `id`, updating its tile.
183    ///
184    /// Rows evicted from the tile are dropped from the live view
185    /// but appended to the region's bounded retention ring, so a later failure can replay recent context.
186    /// A feed for an unknown `id` is ignored, so late output after a finish cannot panic.
187    pub fn feed(&mut self, id: &str, bytes: &[u8]) {
188        let scrollback = self.config.scrollback;
189        let Some(region) = self.regions.get_mut(id) else {
190            return;
191        };
192        for line in region.screen.feed(bytes) {
193            region.retained.push_back(line);
194            while region.retained.len() > scrollback {
195                region.retained.pop_front();
196            }
197        }
198        let visible = region.screen.render();
199        // Grow the tile to the tallest content it has shown, capped at the grid height,
200        // and render exactly that many rows. `high_water` only rises,
201        // so an in-place redraw that momentarily clears rows does not shrink the tile
202        // and reflow the live area.
203        let filled = content_height(&visible);
204        region.high_water = region.high_water.max(filled).min(self.config.rows);
205        region.bar.set_message(render_tile(
206            &region.label,
207            &visible,
208            self.config.cols,
209            region.high_water,
210        ));
211    }
212
213    /// Finish region `id`, removing its tile and printing `verdict` as the only scrollback line —
214    /// the collapsed signal for a succeeding unit.
215    ///
216    /// The region's peeked output is discarded, not flushed:
217    /// on success the verdict (which the caller may enrich with a run summary) is the whole story.
218    /// A finish for an unknown `id` prints only the verdict. Returns any I/O error from the verdict write.
219    pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
220        if let Some(region) = self.regions.remove(id) {
221            region.bar.finish_and_clear();
222            self.multi.remove(&region.bar);
223        }
224        self.multi.println(verdict.as_ref())
225    }
226
227    /// Finish a failed region `id`, replaying its retained tail to scrollback as one contiguous,
228    /// label-prefixed block, then printing `verdict`.
229    ///
230    /// The replay is the region's retention ring (rows that scrolled off the tile, oldest first) followed by the rows still on screen
231    /// — a bounded, un-interleaved failure transcript. It is returned (un-prefixed, in order)
232    /// so the caller can also retain it for an end-of-run failure epilogue.
233    /// A finish for an unknown `id` prints only the verdict and returns an empty body.
234    /// Returns any I/O error from the flush or verdict write.
235    pub fn finish_with_replay(
236        &mut self,
237        id: &str,
238        verdict: impl AsRef<str>,
239    ) -> std::io::Result<Vec<String>> {
240        let mut body = Vec::new();
241        if let Some(region) = self.regions.remove(id) {
242            let Region {
243                bar,
244                screen,
245                label,
246                retained,
247                high_water: _,
248            } = region;
249            for line in replay_body(&retained, screen.drain()) {
250                let prefixed = scrollback_line(&label, &line);
251                self.multi.println(truncate(&prefixed, self.config.cols))?;
252                body.push(line);
253            }
254            bar.finish_and_clear();
255            self.multi.remove(&bar);
256        }
257        self.multi.println(verdict.as_ref())?;
258        Ok(body)
259    }
260
261    /// Print `line` to scrollback above the live area, without touching any tile.
262    ///
263    /// For output that is not tied to a live region — a header banner,
264    /// or a completed unit's buffered block on the rare path where a unit is not live-tailed.
265    /// Returns any I/O error from the write.
266    pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
267        self.multi.println(line.as_ref())
268    }
269
270    /// Drop every remaining region, then clear the live area and blank the header,
271    /// leaving the console reusable.
272    ///
273    /// Remaining regions are units that never finished;
274    /// their peeked output is discarded (durable signal is emitted at finish time).
275    /// Returns any I/O error from the terminal clear.
276    pub fn clear(&mut self) -> std::io::Result<()> {
277        for (_, region) in self.regions.drain() {
278            region.bar.finish_and_clear();
279            self.multi.remove(&region.bar);
280        }
281        self.header.set_message("");
282        self.multi.clear()
283    }
284
285    /// The rows a region has retained for a failure replay (oldest first).
286    #[cfg(test)]
287    fn retained(&self, id: &str) -> Option<Vec<String>> {
288        self.regions
289            .get(id)
290            .map(|region| region.retained.iter().cloned().collect())
291    }
292
293    /// The rendered height of a region's tile in lines:
294    /// its header plus the current high-water content rows.
295    #[cfg(test)]
296    fn tile_height(&self, id: &str) -> Option<usize> {
297        self.regions.get(id).map(|region| region.high_water + 1)
298    }
299}
300
301/// A bar style that renders only its message (no bar, timer, or spinner).
302fn message_style() -> ProgressStyle {
303    ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
304}
305
306/// The number of rows up to and including the last non-blank one —
307/// the height a tile must render to show all its current content. Trailing blank grid rows are excluded
308/// so a tile grows only to the content it actually holds.
309fn content_height<S: AsRef<str>>(lines: &[S]) -> usize {
310    lines
311        .iter()
312        .rposition(|line| !line.as_ref().is_empty())
313        .map_or(0, |index| index + 1)
314}
315
316/// Render one tile as a labeled header line plus exactly `rows` indented content lines (padded with blanks),
317/// each truncated to `cols` display columns. As with [`truncate`],
318/// `cols == 0` disables truncation (used only by tests; the live console always resolves `cols` to at least 1).
319/// `rows` is the caller's chosen tile height (its content high-water mark),
320/// so the tile is exactly as tall as the content it currently holds rather than a fixed block.
321fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
322    let header = format!("{}", console::style(format!("• {label}")).bold());
323    let mut out = truncate(&header, cols);
324    for index in 0..rows {
325        out.push('\n');
326        let line = lines.get(index).map_or("", AsRef::as_ref);
327        out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
328    }
329    out
330}
331
332/// Prefix a scrolled-out line with its region label for scrollback attribution.
333fn scrollback_line(label: &str, line: &str) -> String {
334    format!("{} {line}", console::style(format!("{label} │")).dim())
335}
336
337/// Build a failed region's replay body:
338/// its retained scrolled-off rows (oldest first) followed by the rows still on screen,
339/// with trailing blank rows trimmed. The caller label-prefixes each line for scrollback attribution.
340fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
341    let mut lines: Vec<String> = retained.iter().cloned().collect();
342    lines.extend(on_screen);
343    while lines.last().is_some_and(String::is_empty) {
344        lines.pop();
345    }
346    lines
347}
348
349/// Truncate `line` to `width` display columns (ANSI- and width-aware),
350/// leaving it untouched when `width` is `0`.
351fn truncate(line: &str, width: usize) -> String {
352    if width == 0 {
353        return line.to_string();
354    }
355    console::truncate_str(line, width, "…").into_owned()
356}
357
358#[cfg(test)]
359mod tests {
360    use std::collections::VecDeque;
361
362    use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
363
364    fn config(rows: usize, cols: usize) -> LiveConfig {
365        LiveConfig {
366            rows,
367            cols,
368            ..LiveConfig::default()
369        }
370    }
371
372    #[test]
373    fn content_cols_reserves_the_indent_and_never_underflows() {
374        assert_eq!(config(6, 80).content_cols(), 78);
375        // Degenerate widths clamp to a usable grid rather than underflowing.
376        assert_eq!(config(6, 1).content_cols(), 1);
377        assert_eq!(config(6, 0).content_cols(), 1);
378    }
379
380    #[test]
381    fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
382        let mut console = LiveConsole::hidden(config(2, 40));
383        console.set_header("wave 1/2 · running 1");
384        console.begin("u1", "rust:core#test");
385        console.feed("u1", b"compiling\r\n");
386        console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
387        console.finish("u1", "ok rust:core#test")?;
388        console.clear()
389    }
390
391    #[test]
392    fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
393        let mut console = LiveConsole::hidden(LiveConfig::default());
394        console.begin("u1", "first");
395        console.feed("u1", b"old\n");
396        console.begin("u1", "second");
397        console.feed("u1", b"new\n");
398        console.finish("u1", "ok")
399    }
400
401    #[test]
402    fn console_is_reusable_after_clear() -> std::io::Result<()> {
403        let mut console = LiveConsole::hidden(LiveConfig::default());
404        console.set_header("first pass");
405        console.begin("u1", "task");
406        console.feed("u1", b"partial output\n");
407        console.clear()?;
408        console.set_header("second pass");
409        console.begin("u1", "task");
410        console.feed("u1", b"more\n");
411        console.finish("u1", "ok")
412    }
413
414    #[test]
415    fn zero_rows_still_renders_content() -> std::io::Result<()> {
416        let mut console = LiveConsole::hidden(config(0, 40));
417        console.begin("u1", "task");
418        console.feed("u1", b"visible line\r\n");
419        console.finish("u1", "ok")
420    }
421
422    #[test]
423    fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
424        let mut console = LiveConsole::hidden(LiveConfig::default());
425        console.feed("ghost", b"noise\n");
426        console.finish("ghost", "done")
427    }
428
429    #[test]
430    fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
431        let console = LiveConsole::hidden(LiveConfig::default());
432        console.note("standalone line")
433    }
434
435    #[test]
436    fn scrolled_rows_are_retained_for_replay_not_lost() {
437        let mut console = LiveConsole::hidden(LiveConfig {
438            rows: 2,
439            cols: 20,
440            scrollback: 200,
441        });
442        console.begin("u1", "task");
443        console.feed("u1", b"l1\nl2\nl3\nl4\n");
444        // Only the last row stays on screen; the earlier rows scrolled off
445        // but are retained for a possible failure replay.
446        assert_eq!(
447            console.retained("u1"),
448            Some(vec!["l1".into(), "l2".into(), "l3".into()])
449        );
450    }
451
452    #[test]
453    fn retention_ring_is_bounded_under_a_long_feed() {
454        let mut console = LiveConsole::hidden(LiveConfig {
455            rows: 2,
456            cols: 20,
457            scrollback: 3,
458        });
459        console.begin("u1", "task");
460        for _ in 0..50 {
461            console.feed("u1", b"line\n");
462        }
463        assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
464    }
465
466    #[test]
467    fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
468        let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
469        let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
470        assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
471    }
472
473    #[test]
474    fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
475        let mut console = LiveConsole::hidden(config(2, 20));
476        console.begin("ok", "task");
477        console.feed("ok", b"noise\n");
478        console.finish("ok", "ok task")?;
479        assert!(console.retained("ok").is_none());
480
481        console.begin("bad", "task");
482        console.feed("bad", b"panic!\n");
483        console.finish_with_replay("bad", "failed task")?;
484        assert!(console.retained("bad").is_none());
485        Ok(())
486    }
487
488    #[test]
489    fn finish_with_replay_returns_the_transcript_for_an_end_of_run_epilogue() -> std::io::Result<()>
490    {
491        let mut console = LiveConsole::hidden(config(2, 20));
492        console.begin("bad", "task");
493        console.feed("bad", b"line one\nline two\n");
494        // The replayed body is returned un-prefixed and in order so a caller can retain it
495        // and re-surface all failures together after the run.
496        let body = console.finish_with_replay("bad", "failed task")?;
497        assert_eq!(body, vec!["line one".to_string(), "line two".to_string()]);
498        Ok(())
499    }
500
501    #[test]
502    fn finish_with_replay_for_an_unknown_region_returns_an_empty_body() -> std::io::Result<()> {
503        let mut console = LiveConsole::hidden(config(2, 20));
504        let body = console.finish_with_replay("ghost", "failed")?;
505        assert!(body.is_empty());
506        Ok(())
507    }
508
509    #[test]
510    fn render_tile_labels_and_indents_lines() {
511        let tile = render_tile("core", &["a", "b"], 0, 2);
512        let stripped = console::strip_ansi_codes(&tile);
513        assert_eq!(stripped, "• core\n  a\n  b");
514    }
515
516    #[test]
517    fn content_height_counts_up_to_the_last_non_blank_row() {
518        assert_eq!(super::content_height::<&str>(&[]), 0);
519        assert_eq!(super::content_height(&["", "", ""]), 0);
520        assert_eq!(super::content_height(&["a", "", ""]), 1);
521        assert_eq!(super::content_height(&["a", "", "b", ""]), 3);
522    }
523
524    #[test]
525    fn a_silent_region_renders_only_its_header() {
526        let mut console = LiveConsole::hidden(config(12, 40));
527        console.begin("u1", "rust:core#test");
528        // Nothing fed yet: the tile is a single header line, not a block of reserved blank rows.
529        assert_eq!(console.tile_height("u1"), Some(1));
530    }
531
532    #[test]
533    fn a_tile_grows_with_content_up_to_the_cap() {
534        let mut console = LiveConsole::hidden(config(3, 40));
535        console.begin("u1", "task");
536        console.feed("u1", b"one\n");
537        // header + one content row.
538        assert_eq!(console.tile_height("u1"), Some(2));
539        console.feed("u1", b"two\nthree\nfour\nfive");
540        // Capped at the grid height (3 content rows) + header.
541        assert_eq!(console.tile_height("u1"), Some(4));
542    }
543
544    #[test]
545    fn a_grown_tile_does_not_shrink_when_output_clears() {
546        let mut console = LiveConsole::hidden(config(3, 40));
547        console.begin("u1", "task");
548        console.feed("u1", b"a\r\nb\r\nc");
549        assert_eq!(console.tile_height("u1"), Some(4));
550        // An in-place redraw that collapses to a single line keeps the tile at its high-water height instead of reflowing the live area.
551        console.feed("u1", b"\x1b[H\x1b[2Jshort");
552        assert_eq!(console.tile_height("u1"), Some(4));
553    }
554
555    #[test]
556    fn render_tile_pads_to_fixed_height() {
557        let tile = render_tile("core", &["only"], 0, 3);
558        let stripped = console::strip_ansi_codes(&tile);
559        assert_eq!(stripped, "• core\n  only\n  \n  ");
560    }
561
562    #[test]
563    fn render_tile_truncates_header_and_content_to_width() {
564        let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
565        for line in console::strip_ansi_codes(&tile).lines() {
566            assert!(console::measure_text_width(line) <= 8);
567        }
568    }
569
570    #[test]
571    fn scrollback_line_prefixes_with_label() {
572        let line = scrollback_line("core", "hello");
573        let stripped = console::strip_ansi_codes(&line);
574        assert_eq!(stripped, "core │ hello");
575    }
576
577    #[test]
578    fn replay_line_clamped_to_width_never_wraps() {
579        // A content row is sized to the tile's content area,
580        // but the scrollback replay prefixes it with the full unit label —
581        // the composed line must be clamped to the console width
582        // so it never wraps into an orphan fragment.
583        let content = "x".repeat(200);
584        let composed = scrollback_line("rust@rust:core#test", &content);
585        let clamped = truncate(&composed, 140);
586        assert!(console::measure_text_width(&clamped) <= 140);
587    }
588
589    #[test]
590    fn truncate_respects_width_and_passthrough() {
591        assert_eq!(truncate("hello world", 0), "hello world");
592        let cut = truncate("hello world", 5);
593        assert!(console::measure_text_width(&cut) <= 5);
594    }
595}