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}