Skip to main content

atomcode_tuix/render/
plain.rs

1// crates/atomcode-tuix/src/render/plain.rs
2use std::io::{BufWriter, Stdout, Write};
3
4use super::{Renderer, UiLine};
5use crate::sanitize::scrub_controls;
6use crate::terminal::TerminalCaps;
7
8// SGR sequences. Kept short and inline so they don't need a helper struct.
9// `\x1b[K` is EL (erase to end of line); used after every spinner update so
10// a shorter frame doesn't leave glyphs from a longer previous frame.
11const SGR_RESET: &str = "\x1b[0m";
12const SGR_RED: &str = "\x1b[31m";
13const SGR_BOLD_YELLOW: &str = "\x1b[1;33m";
14const SGR_GREEN: &str = "\x1b[32m";
15const SGR_CYAN: &str = "\x1b[36m";
16const SGR_DIM: &str = "\x1b[2m";
17
18/// Plain-text renderer for pipes, CI, dumb terminals, and TUI-incompatible
19/// terminals (e.g. JetBrains JediTerm — see `lib.rs` JediTerm fallback).
20/// No raw-mode dependencies, no DECSTBM, no cursor positioning.
21///
22/// Plain mode does support a few low-effort UX wins on top of bare
23/// printf, all gated by `TerminalCaps`:
24///   * **Spinner via `\r`** — overwrites the same line during streaming,
25///     so users see "in progress" feedback without animation tearing
26///     (cooked-mode `\r` always works; this is what `read`-with-progress
27///     scripts have used for decades).
28///   * **SGR colours** — red errors, green/red ✓/✗, cyan tool-call names
29///     when `caps.colors` is on. Pure inline SGR; no positioning required.
30///   * **`❯` chevron** — replaces `> ` when `caps.unicode_symbols` is on,
31///     so the prompt visually matches the retained-mode chevron. Same
32///     two-cell width as `> ` so layout math is unchanged.
33pub struct PlainRenderer<W: Write + Send> {
34    out: W,
35    caps: TerminalCaps,
36    /// True iff stdout was a real TTY at probe time, i.e. the user
37    /// is interacting through a cooked-mode terminal (rather than
38    /// piping input from a script / CI runner / dumb sink).
39    ///
40    /// This is DISTINCT from `caps.tty` — `lib.rs` mutates `caps.tty`
41    /// to `false` whenever `force_plain` wins on a real TTY (JediTerm
42    /// auto-fallback, legacy Windows conhost auto-fallback, manual
43    /// `ATOMCODE_PLAIN=1`) so downstream branches consistently take
44    /// the cooked-mode path. The original tty value still tells us
45    /// whether the kernel will echo the user's typing for us.
46    ///
47    /// Behavioural impact:
48    /// - `interactive_terminal=true`: cooked-mode terminal does the
49    ///   echo. We write `❯ ` once on `InputPrompt`, the kernel glues
50    ///   the user's keystrokes onto it, and `UiLine::User` is
51    ///   SUPPRESSED — re-rendering would print `❯ 你好` a second
52    ///   time directly below the cooked echo (the duplicate-line bug
53    ///   from real-world reports).
54    /// - `interactive_terminal=false`: pipe / CI / dumb. Kernel does
55    ///   no echo. We SUPPRESS `InputPrompt` (no human reading) and
56    ///   render `UiLine::User` so log readers can correlate input
57    ///   with the assistant's reply.
58    interactive_terminal: bool,
59    last_prompt_written: bool,
60    /// True iff the last write was a transient (spinner) line that
61    /// hasn't been wiped yet. The next non-transient render needs to
62    /// emit `\r\x1b[K` first so it doesn't append to the spinner row.
63    transient_active: bool,
64}
65
66impl PlainRenderer<BufWriter<Stdout>> {
67    /// Convenience for the common "stdout + probe caps" path. Tests
68    /// should use `with_writer_and_caps` so they can pin caps deterministically.
69    pub fn new() -> Self {
70        Self::with_writer_and_caps(BufWriter::new(std::io::stdout()), TerminalCaps::probe())
71    }
72}
73
74impl Default for PlainRenderer<BufWriter<Stdout>> {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl<W: Write + Send> PlainRenderer<W> {
81    /// Backwards-compat constructor used by older test paths. Probes
82    /// caps from the environment — fine for production, but tests that
83    /// want predictable behaviour should use `with_writer_and_caps` or
84    /// the explicit `with_writer_caps_and_interactive`.
85    pub fn with_writer(out: W) -> Self {
86        Self::with_writer_and_caps(out, TerminalCaps::probe())
87    }
88
89    /// Defaults `interactive_terminal` to `caps.tty`. Production
90    /// callers in `lib.rs` use `with_writer_caps_and_interactive`
91    /// instead because the `force_plain` branch needs to pass the
92    /// PRE-mutation tty value (caps.tty has already been zeroed by
93    /// then so the renderer would otherwise think it's in pipe mode).
94    pub fn with_writer_and_caps(out: W, caps: TerminalCaps) -> Self {
95        let interactive = caps.tty;
96        Self::with_writer_caps_and_interactive(out, caps, interactive)
97    }
98
99    /// Explicit constructor that decouples `caps.tty` from the
100    /// echo-handling decision. Used by `lib.rs` to pass the original
101    /// tty value alongside a force_plain-mutated `caps`.
102    pub fn with_writer_caps_and_interactive(
103        out: W,
104        caps: TerminalCaps,
105        interactive_terminal: bool,
106    ) -> Self {
107        Self {
108            out,
109            caps,
110            interactive_terminal,
111            last_prompt_written: false,
112            transient_active: false,
113        }
114    }
115
116    /// If a spinner is on screen, wipe it before emitting persistent
117    /// content. Called from every match arm that writes a "real" row,
118    /// so a missing `ClearTransient` event from upstream doesn't glue
119    /// the next line onto the spinner.
120    fn drop_transient(&mut self) {
121        if self.transient_active {
122            let _ = self.out.write_all(b"\r\x1b[K");
123            self.transient_active = false;
124        }
125    }
126}
127
128impl<W: Write + Send> Renderer for PlainRenderer<W> {
129    fn render(&mut self, line: UiLine) {
130        match line {
131            UiLine::Welcome { model, working_dir } => {
132                self.drop_transient();
133                let _ = writeln!(
134                    self.out,
135                    "AtomCode  {}  {}",
136                    scrub_controls(&model),
137                    scrub_controls(&working_dir)
138                );
139            }
140            UiLine::User(text) => {
141                self.drop_transient();
142                if !self.interactive_terminal {
143                    // Pipe / CI / dumb: kernel didn't echo the user's
144                    // input, so we render it here as the only source of
145                    // input visibility for log readers correlating
146                    // request ↔ response.
147                    let chev = self.caps.prompt_chevron();
148                    let _ = writeln!(self.out, "{}{}", chev, scrub_controls(&text));
149                }
150                // Interactive force_plain (JediTerm / legacy conhost /
151                // ATOMCODE_PLAIN=1 on a real TTY): cooked-mode kernel
152                // already echoed the user's keystrokes inline after the
153                // `❯ ` prefix that InputPrompt printed. Rendering
154                // `❯ {text}\n` here would produce the duplicate
155                // `❯ 你好` / `❯ 你好` pair that real-world users hit.
156            }
157            UiLine::AssistantText(text) => {
158                self.drop_transient();
159                let _ = self.out.write_all(scrub_controls(&text).as_bytes());
160            }
161            UiLine::ReasoningText(text) => {
162                // Display reasoning in gray/dimmed style
163                let _ = write!(self.out, "\x1b[2m{}\x1b[0m", scrub_controls(&text));
164            }
165            UiLine::AssistantLineBreak => {
166                self.drop_transient();
167                let _ = self.out.write_all(b"\n");
168            }
169            UiLine::ToolCall { name, detail } | UiLine::ToolCallInFlight { id: _, name, detail } => {
170                // Plain mode has no in-place rewrite, so the in-flight
171                // variant degrades to the same single static line that
172                // the static `ToolCall` produces — the user just sees
173                // `▸ Name(detail)` once, when the call lands.
174                self.drop_transient();
175                let name = scrub_controls(&name);
176                let detail = scrub_controls(&detail);
177                let arrow_color = if self.caps.colors { SGR_CYAN } else { "" };
178                let reset = if self.caps.colors { SGR_RESET } else { "" };
179                // ● (U+25CF) — Geometric Shapes block; broadly available
180                // across Windows monospace fonts. Aligns with retained
181                // and alt-screen renderers (see retained.rs ToolCall
182                // arm for the Windows-font tofu rationale).
183                if detail.is_empty() {
184                    let _ = writeln!(self.out, "{}● {}{}", arrow_color, name, reset);
185                } else {
186                    let _ = writeln!(
187                        self.out,
188                        "{}● {}{}({})",
189                        arrow_color, name, reset, detail
190                    );
191                }
192            }
193            UiLine::ToolCallCommit { call_id: _ } => {
194                // Plain mode never animated the row, so there is
195                // nothing to freeze. Skip silently.
196            }
197            UiLine::ToolGroupRender { batch_id: _, header, children } => {
198                // Plain mode lacks CUP-rewrite — print header + each
199                // child row plainly. Subsequent ToolGroupChildUpdate
200                // events also print plainly (see the ChildUpdate arm
201                // below), so plain output ends up with header, then
202                // children, then update lines. Less elegant than
203                // retained's in-place ✓, but functional.
204                self.drop_transient();
205                let _ = writeln!(self.out, "{}", header);
206                for c in children {
207                    let _ = writeln!(self.out, "{}", c.text);
208                }
209            }
210            UiLine::ToolGroupChildUpdate { batch_id: _, call_id: _, new_text } => {
211                self.drop_transient();
212                let _ = writeln!(self.out, "{}", new_text);
213            }
214            UiLine::ToolGroupSummary { text } => {
215                self.drop_transient();
216                let _ = writeln!(self.out, "{}", text);
217            }
218            UiLine::ToolResult { success, summary } => {
219                self.drop_transient();
220                let icon = if success { "✓" } else { "✗" };
221                let icon_color = if self.caps.colors {
222                    if success { SGR_GREEN } else { SGR_RED }
223                } else {
224                    ""
225                };
226                let reset = if self.caps.colors { SGR_RESET } else { "" };
227                let _ = writeln!(
228                    self.out,
229                    "{}{}{} {}",
230                    icon_color,
231                    icon,
232                    reset,
233                    scrub_controls(&summary)
234                );
235            }
236            UiLine::DiffLine { added, text } => {
237                self.drop_transient();
238                let sign = if added { "+" } else { "-" };
239                let color = if self.caps.colors {
240                    if added { SGR_GREEN } else { SGR_RED }
241                } else {
242                    ""
243                };
244                let reset = if self.caps.colors { SGR_RESET } else { "" };
245                let _ = writeln!(
246                    self.out,
247                    "  {}{} {}{}",
248                    color,
249                    sign,
250                    scrub_controls(&text),
251                    reset
252                );
253            }
254            UiLine::DiffBlock(entries) => {
255                self.drop_transient();
256                for entry in entries {
257                    let sign = if entry.added { "+" } else { "-" };
258                    let color = if self.caps.colors {
259                        if entry.added { SGR_GREEN } else { SGR_RED }
260                    } else {
261                        ""
262                    };
263                    let reset = if self.caps.colors { SGR_RESET } else { "" };
264                    let _ = writeln!(
265                        self.out,
266                        "  {}{} {}{}",
267                        color,
268                        sign,
269                        scrub_controls(&entry.text),
270                        reset
271                    );
272                }
273            }
274            UiLine::ApprovalPrompt { tool, detail } => {
275                self.drop_transient();
276                let _ = writeln!(
277                    self.out,
278                    "{}",
279                    crate::i18n::t(crate::i18n::Msg::ApprovalPromptAlt {
280                        tool: &scrub_controls(&tool),
281                        detail: &scrub_controls(&detail),
282                    })
283                );
284            }
285            UiLine::Error(msg) => {
286                self.drop_transient();
287                let color = if self.caps.colors { SGR_RED } else { "" };
288                let reset = if self.caps.colors { SGR_RESET } else { "" };
289                let _ = writeln!(
290                    self.out,
291                    "{}[Error: {}]{}",
292                    color,
293                    scrub_controls(&msg),
294                    reset
295                );
296            }
297            UiLine::Warning(msg) => {
298                self.drop_transient();
299                let color = if self.caps.colors { SGR_BOLD_YELLOW } else { "" };
300                let reset = if self.caps.colors { SGR_RESET } else { "" };
301                let _ = writeln!(
302                    self.out,
303                    "{}! {}{}",
304                    color,
305                    scrub_controls(&msg),
306                    reset
307                );
308            }
309            UiLine::TurnCancelled => {
310                self.drop_transient();
311                let _ = writeln!(self.out, "(cancelled)");
312            }
313            UiLine::TurnComplete => {
314                self.drop_transient();
315                let _ = self.out.write_all(b"\n");
316            }
317            UiLine::Spinner { frame, label } => {
318                // CR + frame + label + EL clears any leftover glyphs
319                // from a longer previous frame. Stays on its own line
320                // until the next non-transient write triggers
321                // `drop_transient`. caps.spinner gates the whole thing
322                // off on dumb terminals (no `\r` support there either).
323                if self.caps.spinner {
324                    let dim = if self.caps.colors { SGR_DIM } else { "" };
325                    let reset = if self.caps.colors { SGR_RESET } else { "" };
326                    let _ = write!(
327                        self.out,
328                        "\r{}{} {}{}\x1b[K",
329                        dim,
330                        frame,
331                        scrub_controls(&label),
332                        reset
333                    );
334                    let _ = self.out.flush();
335                    self.transient_active = true;
336                }
337            }
338            UiLine::ClearTransient => {
339                if self.transient_active {
340                    let _ = self.out.write_all(b"\r\x1b[K");
341                    self.transient_active = false;
342                }
343            }
344            UiLine::StreamingBox { .. } => {
345                // No streaming-box rendering in plain mode — assistant
346                // text streams as plain text via AssistantText.
347            }
348            UiLine::TurnSeparator { label } => {
349                self.drop_transient();
350                let _ = writeln!(self.out, "--- {} ---", scrub_controls(&label));
351            }
352            UiLine::InputPrompt { buf, .. } => {
353                if !self.last_prompt_written {
354                    self.drop_transient();
355                    if self.interactive_terminal {
356                        // Real TTY — write `❯ ` so the user can see we
357                        // are ready and the kernel will overlay their
358                        // typed input on top of this prefix.
359                        let chev = self.caps.prompt_chevron();
360                        let _ = write!(self.out, "{}{}", chev, scrub_controls(&buf));
361                    }
362                    // Pipe mode: no human watching, prompt is just noise.
363                    // Input arrives via stdin, gets echoed via UiLine::User.
364                    self.last_prompt_written = true;
365                }
366            }
367            UiLine::InputCommit => {
368                let _ = self.out.write_all(b"\n");
369                self.last_prompt_written = false;
370            }
371            UiLine::CommandOutput(text) => {
372                self.drop_transient();
373                // CommandOutput is trusted internal text — keep SGR so
374                // colours / bold reach the terminal. See the matching
375                // alt_screen note for why this is safe.
376                let safe = crate::sanitize::scrub_controls_keep_sgr(&text);
377                let _ = self.out.write_all(safe.as_bytes());
378                if !safe.ends_with('\n') {
379                    let _ = self.out.write_all(b"\n");
380                }
381            }
382            UiLine::ImageAttachment(n) => {
383                // Plain mode echoes attachment markers with the same
384                // 2-space indent as the TTY renderers, then a newline.
385                self.drop_transient();
386                let _ = writeln!(self.out, "  └ [Image #{}]", n);
387            }
388            UiLine::VisionPreprocessSuccess { msg, model } => {
389                // Plain mode loses styling distinctions; print
390                // message + model as one line, same indent as
391                // ImageAttachment, then a blank line so the next
392                // event's output reads as a separate paragraph.
393                self.drop_transient();
394                let _ = writeln!(self.out, "  {}  {}", msg, model);
395                let _ = writeln!(self.out);
396            }
397        }
398    }
399
400    fn flush(&mut self) {
401        let _ = self.out.flush();
402    }
403
404    fn shutdown(&mut self) {
405        let _ = self.out.flush();
406    }
407
408    fn reset(&mut self) {
409        // Plain renderer has no cached footer state; just flush.
410        let _ = self.out.flush();
411    }
412
413    fn clear_screen(&mut self) {
414        // Pipe / non-TTY sink — a hardware "clear screen" is meaningless.
415        // Just flush so whatever's queued is visible before the caller
416        // (e.g. the `/clear` command) moves on.
417        let _ = self.out.flush();
418    }
419
420    fn suspend_for_external(&mut self) {
421        let _ = self.out.flush();
422    }
423
424    fn resume_from_external(&mut self) {
425        let _ = self.out.flush();
426    }
427
428    fn flush_deferred(&mut self) {
429        // PlainRenderer has no throttling — deferred queue is empty.
430        let _ = self.out.flush();
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    /// Build caps with all capabilities OFF — exercises the dumb /
439    /// pipe / CI path where PlainRenderer must emit zero SGR / unicode.
440    fn caps_dumb() -> TerminalCaps {
441        TerminalCaps {
442            tty: false,
443            colors: false,
444            spinner: false,
445            bracketed_paste: false,
446            raw_mode: false,
447            scroll_region: false,
448            unicode_symbols: false,
449        }
450    }
451
452    /// Build caps representing a JediTerm-class terminal: tty cleared
453    /// (matches what `lib.rs` does in the force_plain branch), but
454    /// colours / spinner / unicode all on. Exercises the optimised
455    /// plain-mode path.
456    fn caps_jediterm_ish() -> TerminalCaps {
457        TerminalCaps {
458            tty: false, // cleared by lib.rs force_plain branch
459            colors: true,
460            spinner: true,
461            bracketed_paste: false,
462            raw_mode: false,
463            scroll_region: false,
464            unicode_symbols: true,
465        }
466    }
467
468    #[test]
469    fn no_sgr_or_unicode_in_dumb_mode() {
470        let mut buf = Vec::new();
471        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
472        r.render(UiLine::ToolCall {
473            name: "read_file".into(),
474            detail: "x.rs".into(),
475        });
476        r.render(UiLine::ToolResult {
477            success: true,
478            summary: "done".into(),
479        });
480        r.flush();
481        let s = String::from_utf8(buf).unwrap();
482        assert!(!s.contains('\x1b'), "dumb mode must emit zero SGR. got: {}", s);
483        assert!(s.contains("● read_file(x.rs)"));
484        assert!(s.contains("✓ done"));
485    }
486
487    #[test]
488    fn colours_emitted_when_caps_on() {
489        let mut buf = Vec::new();
490        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_jediterm_ish());
491        r.render(UiLine::ToolResult {
492            success: false,
493            summary: "boom".into(),
494        });
495        r.render(UiLine::Error("kaboom".into()));
496        r.flush();
497        let s = String::from_utf8(buf).unwrap();
498        // Red ✗ and red [Error: …] both present.
499        assert!(s.contains("\x1b[31m"), "expected red SGR for failure / error. got: {}", s);
500        assert!(s.contains("\x1b[0m"), "expected SGR reset after coloured spans. got: {}", s);
501    }
502
503    #[test]
504    fn spinner_overwrites_with_carriage_return_when_capable() {
505        let mut buf = Vec::new();
506        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_jediterm_ish());
507        r.render(UiLine::Spinner {
508            frame: "⠋",
509            label: "Thinking".into(),
510        });
511        r.render(UiLine::Spinner {
512            frame: "⠙",
513            label: "Thinking".into(),
514        });
515        r.flush();
516        let s = String::from_utf8(buf).unwrap();
517        // Both frames present. With colours on the dim-SGR sits between
518        // the CR and the braille glyph, so we assert each piece exists
519        // rather than that they're contiguous: CR (so the next frame
520        // overwrites), the glyph itself, and EL after each frame.
521        assert!(s.starts_with('\r'), "spinner must start with CR. got: {:?}", s);
522        assert!(s.contains("⠋"), "first frame missing. got: {:?}", s);
523        assert!(s.contains("⠙"), "second frame missing. got: {:?}", s);
524        assert_eq!(s.matches('\r').count(), 2, "expected exactly 2 CR (one per frame). got: {:?}", s);
525        assert_eq!(s.matches("\x1b[K").count(), 2, "expected EL per frame. got: {:?}", s);
526    }
527
528    #[test]
529    fn spinner_is_noop_when_caps_disable_it() {
530        let mut buf = Vec::new();
531        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
532        r.render(UiLine::Spinner {
533            frame: "⠋",
534            label: "Thinking".into(),
535        });
536        r.flush();
537        let s = String::from_utf8(buf).unwrap();
538        assert!(s.is_empty(), "no-spinner caps must produce no output. got: {:?}", s);
539    }
540
541    /// Spinner stays on screen until something else needs to write —
542    /// then `drop_transient` wipes it via `\r\x1b[K` so the next
543    /// real line starts at column 0 of a clean row.
544    #[test]
545    fn next_write_after_spinner_wipes_it_first() {
546        let mut buf = Vec::new();
547        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_jediterm_ish());
548        r.render(UiLine::Spinner {
549            frame: "⠋",
550            label: "Thinking".into(),
551        });
552        r.render(UiLine::AssistantText("hello".into()));
553        r.flush();
554        let s = String::from_utf8(buf).unwrap();
555        // Spinner output + wipe + assistant text, in that order.
556        let spinner_pos = s.find("⠋").expect("spinner present");
557        let wipe_pos = s.find("\r\x1b[K").expect("wipe sequence present");
558        let text_pos = s.find("hello").expect("assistant text present");
559        assert!(
560            spinner_pos < wipe_pos && wipe_pos < text_pos,
561            "expected spinner → wipe → text ordering. got: {:?}",
562            s
563        );
564    }
565
566    #[test]
567    fn input_prompt_chevron_unicode_or_ascii_per_caps() {
568        // Test the chevron *output* path — InputPrompt only writes
569        // when interactive_terminal=true, so force it on for the
570        // chevron-rendering subject under test (the alternative path
571        // is covered by `input_prompt_suppressed_in_pipe_mode` below).
572        // Unicode caps → `❯ ` (U+276F + space, two display columns).
573        let mut buf = Vec::new();
574        let mut r = PlainRenderer::with_writer_caps_and_interactive(
575            &mut buf,
576            caps_jediterm_ish(),
577            true,
578        );
579        r.render(UiLine::InputPrompt {
580            buf: "hi".into(),
581            cursor_byte: 2,
582            menu: None,
583            status: crate::render::StatusLine::default(),
584            attachments: Vec::new(),
585        });
586        r.flush();
587        let s = String::from_utf8(buf).unwrap();
588        assert!(s.starts_with("\u{276f} "), "unicode caps must use ❯ chevron. got: {:?}", s);
589
590        // Dumb caps → ASCII `> ` fallback.
591        let mut buf = Vec::new();
592        let mut r = PlainRenderer::with_writer_caps_and_interactive(&mut buf, caps_dumb(), true);
593        r.render(UiLine::InputPrompt {
594            buf: "hi".into(),
595            cursor_byte: 2,
596            menu: None,
597            status: crate::render::StatusLine::default(),
598            attachments: Vec::new(),
599        });
600        r.flush();
601        let s = String::from_utf8(buf).unwrap();
602        assert!(s.starts_with("> "), "dumb caps must use ASCII chevron. got: {:?}", s);
603    }
604
605    /// Real-TTY force_plain (JediTerm / conhost / ATOMCODE_PLAIN=1):
606    /// kernel cooked-mode does its own echo of user input, so we must
607    /// NOT render UiLine::User — otherwise the user sees `❯ 你好`
608    /// twice in a row (the duplicate-line bug from the screenshot).
609    #[test]
610    fn user_echo_suppressed_on_interactive_terminal() {
611        let mut buf = Vec::new();
612        let mut r = PlainRenderer::with_writer_caps_and_interactive(
613            &mut buf,
614            caps_jediterm_ish(),
615            true, // interactive — terminal will echo
616        );
617        r.render(UiLine::User("hello".into()));
618        r.flush();
619        let s = String::from_utf8(buf).unwrap();
620        assert!(
621            s.is_empty(),
622            "interactive force_plain must suppress UiLine::User to avoid \
623             duplicating the kernel's cooked-mode echo. got: {:?}",
624            s
625        );
626    }
627
628    /// Pipe / CI / dumb: kernel does NOT echo, so UiLine::User is
629    /// the only source of input visibility for log readers.
630    #[test]
631    fn user_echo_rendered_in_pipe_mode() {
632        let mut buf = Vec::new();
633        let mut r = PlainRenderer::with_writer_caps_and_interactive(
634            &mut buf,
635            caps_dumb(),
636            false, // non-interactive (pipe)
637        );
638        r.render(UiLine::User("hello".into()));
639        r.flush();
640        let s = String::from_utf8(buf).unwrap();
641        assert!(
642            s.contains("hello"),
643            "pipe mode must render UiLine::User as the only input echo. got: {:?}",
644            s
645        );
646    }
647
648    /// Pipe mode has no human watching the screen, so InputPrompt's
649    /// `❯ ` prefix is noise. UiLine::User (which we DO render in
650    /// pipe mode, asserted above) handles input visibility.
651    #[test]
652    fn input_prompt_suppressed_in_pipe_mode() {
653        let mut buf = Vec::new();
654        let mut r = PlainRenderer::with_writer_caps_and_interactive(
655            &mut buf,
656            caps_dumb(),
657            false, // non-interactive (pipe)
658        );
659        r.render(UiLine::InputPrompt {
660            buf: "".into(),
661            cursor_byte: 0,
662            menu: None,
663            status: crate::render::StatusLine::default(),
664            attachments: Vec::new(),
665        });
666        r.flush();
667        let s = String::from_utf8(buf).unwrap();
668        assert!(
669            s.is_empty(),
670            "pipe mode must suppress InputPrompt — there's no human to read it. got: {:?}",
671            s
672        );
673    }
674
675    /// `with_writer_and_caps` (without explicit `interactive` arg)
676    /// derives the flag from caps.tty: caps.tty=true → interactive,
677    /// caps.tty=false → pipe. Lets test fixtures stay terse for
678    /// non-User / non-InputPrompt scenarios.
679    #[test]
680    fn with_writer_and_caps_defaults_interactive_from_caps_tty() {
681        // caps.tty=true → interactive=true → User suppressed.
682        let mut tty_caps = caps_jediterm_ish();
683        tty_caps.tty = true;
684        let mut buf = Vec::new();
685        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, tty_caps);
686        r.render(UiLine::User("x".into()));
687        r.flush();
688        assert!(
689            String::from_utf8(buf).unwrap().is_empty(),
690            "caps.tty=true should default to interactive (suppress User)"
691        );
692
693        // caps.tty=false (already in caps_dumb) → interactive=false → User rendered.
694        let mut buf = Vec::new();
695        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
696        r.render(UiLine::User("x".into()));
697        r.flush();
698        assert!(
699            String::from_utf8(buf).unwrap().contains('x'),
700            "caps.tty=false should default to pipe (render User)"
701        );
702    }
703
704    #[test]
705    fn assistant_text_flushed_plainly() {
706        let mut buf = Vec::new();
707        let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
708        r.render(UiLine::AssistantText("hello".into()));
709        r.render(UiLine::AssistantLineBreak);
710        r.flush();
711        let s = String::from_utf8(buf).unwrap();
712        assert_eq!(s, "hello\n");
713    }
714}