// crates/atomcode-tuix/src/render/plain.rs
use std::io::{BufWriter, Stdout, Write};
use super::{Renderer, UiLine};
use crate::sanitize::scrub_controls;
use crate::terminal::TerminalCaps;
// SGR sequences. Kept short and inline so they don't need a helper struct.
// `\x1b[K` is EL (erase to end of line); used after every spinner update so
// a shorter frame doesn't leave glyphs from a longer previous frame.
const SGR_RESET: &str = "\x1b[0m";
const SGR_RED: &str = "\x1b[31m";
const SGR_BOLD_YELLOW: &str = "\x1b[1;33m";
const SGR_GREEN: &str = "\x1b[32m";
const SGR_CYAN: &str = "\x1b[36m";
const SGR_DIM: &str = "\x1b[2m";
/// Plain-text renderer for pipes, CI, dumb terminals, and the
/// `ATOMCODE_PLAIN=1` user opt-in. No raw-mode dependencies, no
/// DECSTBM, no cursor positioning.
///
/// Plain mode does support a few low-effort UX wins on top of bare
/// printf, all gated by `TerminalCaps`:
/// * **Spinner via `\r`** — overwrites the same line during streaming,
/// so users see "in progress" feedback without animation tearing
/// (cooked-mode `\r` always works; this is what `read`-with-progress
/// scripts have used for decades).
/// * **SGR colours** — red errors, green/red ✓/✗, cyan tool-call names
/// when `caps.colors` is on. Pure inline SGR; no positioning required.
/// * **`❯` chevron** — replaces `> ` when `caps.unicode_symbols` is on,
/// so the prompt visually matches the retained-mode chevron. Same
/// two-cell width as `> ` so layout math is unchanged.
pub struct PlainRenderer<W: Write + Send> {
out: W,
caps: TerminalCaps,
/// True iff stdout was a real TTY at probe time, i.e. the user
/// is interacting through a cooked-mode terminal (rather than
/// piping input from a script / CI runner / dumb sink).
///
/// This is DISTINCT from `caps.tty` — `lib.rs` mutates `caps.tty`
/// to `false` whenever `force_plain` wins on a real TTY (JediTerm
/// auto-fallback, legacy Windows conhost auto-fallback, manual
/// `ATOMCODE_PLAIN=1`) so downstream branches consistently take
/// the cooked-mode path. The original tty value still tells us
/// whether the kernel will echo the user's typing for us.
///
/// Behavioural impact:
/// - `interactive_terminal=true`: cooked-mode terminal does the
/// echo. We write `❯ ` once on `InputPrompt`, the kernel glues
/// the user's keystrokes onto it, and `UiLine::User` is
/// SUPPRESSED — re-rendering would print `❯ 你好` a second
/// time directly below the cooked echo (the duplicate-line bug
/// from real-world reports).
/// - `interactive_terminal=false`: pipe / CI / dumb. Kernel does
/// no echo. We SUPPRESS `InputPrompt` (no human reading) and
/// render `UiLine::User` so log readers can correlate input
/// with the assistant's reply.
interactive_terminal: bool,
last_prompt_written: bool,
/// True iff the last write was a transient (spinner) line that
/// hasn't been wiped yet. The next non-transient render needs to
/// emit `\r\x1b[K` first so it doesn't append to the spinner row.
transient_active: bool,
}
impl PlainRenderer<BufWriter<Stdout>> {
/// Convenience for the common "stdout + probe caps" path. Tests
/// should use `with_writer_and_caps` so they can pin caps deterministically.
pub fn new() -> Self {
Self::with_writer_and_caps(BufWriter::new(std::io::stdout()), TerminalCaps::probe())
}
}
impl Default for PlainRenderer<BufWriter<Stdout>> {
fn default() -> Self {
Self::new()
}
}
impl<W: Write + Send> PlainRenderer<W> {
/// Backwards-compat constructor used by older test paths. Probes
/// caps from the environment — fine for production, but tests that
/// want predictable behaviour should use `with_writer_and_caps` or
/// the explicit `with_writer_caps_and_interactive`.
pub fn with_writer(out: W) -> Self {
Self::with_writer_and_caps(out, TerminalCaps::probe())
}
/// Defaults `interactive_terminal` to `caps.tty`. Production
/// callers in `lib.rs` use `with_writer_caps_and_interactive`
/// instead because the `force_plain` branch needs to pass the
/// PRE-mutation tty value (caps.tty has already been zeroed by
/// then so the renderer would otherwise think it's in pipe mode).
pub fn with_writer_and_caps(out: W, caps: TerminalCaps) -> Self {
let interactive = caps.tty;
Self::with_writer_caps_and_interactive(out, caps, interactive)
}
/// Explicit constructor that decouples `caps.tty` from the
/// echo-handling decision. Used by `lib.rs` to pass the original
/// tty value alongside a force_plain-mutated `caps`.
pub fn with_writer_caps_and_interactive(
out: W,
caps: TerminalCaps,
interactive_terminal: bool,
) -> Self {
Self {
out,
caps,
interactive_terminal,
last_prompt_written: false,
transient_active: false,
}
}
/// If a spinner is on screen, wipe it before emitting persistent
/// content. Called from every match arm that writes a "real" row,
/// so a missing `ClearTransient` event from upstream doesn't glue
/// the next line onto the spinner.
fn drop_transient(&mut self) {
if self.transient_active {
let _ = self.out.write_all(b"\r\x1b[K");
self.transient_active = false;
}
}
}
impl<W: Write + Send> Renderer for PlainRenderer<W> {
fn render(&mut self, line: UiLine) {
match line {
UiLine::Welcome { model, working_dir } => {
self.drop_transient();
let _ = writeln!(
self.out,
"AtomCode {} {}",
scrub_controls(&model),
scrub_controls(&working_dir)
);
}
UiLine::User(text) => {
self.drop_transient();
if !self.interactive_terminal {
// Pipe / CI / dumb: kernel didn't echo the user's
// input, so we render it here as the only source of
// input visibility for log readers correlating
// request ↔ response.
let chev = self.caps.prompt_chevron();
let _ = writeln!(self.out, "{}{}", chev, scrub_controls(&text));
}
// Interactive force_plain (JediTerm / legacy conhost /
// ATOMCODE_PLAIN=1 on a real TTY): cooked-mode kernel
// already echoed the user's keystrokes inline after the
// `❯ ` prefix that InputPrompt printed. Rendering
// `❯ {text}\n` here would produce the duplicate
// `❯ 你好` / `❯ 你好` pair that real-world users hit.
}
UiLine::AssistantText(text) => {
self.drop_transient();
let _ = self.out.write_all(scrub_controls(&text).as_bytes());
}
UiLine::ReasoningText(text) => {
// Display reasoning in gray/dimmed style
let _ = write!(self.out, "\x1b[2m{}\x1b[0m", scrub_controls(&text));
}
UiLine::AssistantLineBreak => {
self.drop_transient();
let _ = self.out.write_all(b"\n");
}
UiLine::ToolCall { name, detail } | UiLine::ToolCallInFlight { id: _, name, detail } => {
// Plain mode has no in-place rewrite, so the in-flight
// variant degrades to the same single static line that
// the static `ToolCall` produces — the user just sees
// `▸ Name(detail)` once, when the call lands.
self.drop_transient();
let name = scrub_controls(&name);
let detail = scrub_controls(&detail);
let arrow_color = if self.caps.colors { SGR_CYAN } else { "" };
let reset = if self.caps.colors { SGR_RESET } else { "" };
// ● (U+25CF) — Geometric Shapes block; broadly available
// across Windows monospace fonts. Aligns with retained
// and alt-screen renderers (see retained.rs ToolCall
// arm for the Windows-font tofu rationale).
if detail.is_empty() {
let _ = writeln!(self.out, "{}● {}{}", arrow_color, name, reset);
} else {
let _ = writeln!(
self.out,
"{}● {}{}({})",
arrow_color, name, reset, detail
);
}
}
UiLine::ToolCallCommit { call_id: _ } => {
// Plain mode never animated the row, so there is
// nothing to freeze. Skip silently.
}
UiLine::ToolGroupRender { batch_id: _, header, children } => {
// Plain mode lacks CUP-rewrite — print header + each
// child row plainly. Subsequent ToolGroupChildUpdate
// events also print plainly (see the ChildUpdate arm
// below), so plain output ends up with header, then
// children, then update lines. Less elegant than
// retained's in-place ✓, but functional.
self.drop_transient();
let _ = writeln!(self.out, "{}", header);
for c in children {
let _ = writeln!(self.out, "{}", c.text);
}
}
UiLine::ToolGroupChildUpdate { batch_id: _, call_id: _, new_text } => {
self.drop_transient();
let _ = writeln!(self.out, "{}", new_text);
}
UiLine::ToolGroupSummary { text } => {
self.drop_transient();
let _ = writeln!(self.out, "{}", text);
}
UiLine::ToolResult { success, summary } => {
self.drop_transient();
let icon = if success { "✓" } else { "✗" };
let icon_color = if self.caps.colors {
if success { SGR_GREEN } else { SGR_RED }
} else {
""
};
let reset = if self.caps.colors { SGR_RESET } else { "" };
let _ = writeln!(
self.out,
"{}{}{} {}",
icon_color,
icon,
reset,
scrub_controls(&summary)
);
}
UiLine::DiffLine { added, text } => {
self.drop_transient();
let sign = if added { "+" } else { "-" };
let color = if self.caps.colors {
if added { SGR_GREEN } else { SGR_RED }
} else {
""
};
let reset = if self.caps.colors { SGR_RESET } else { "" };
let _ = writeln!(
self.out,
" {}{} {}{}",
color,
sign,
scrub_controls(&text),
reset
);
}
UiLine::DiffBlock(entries) => {
self.drop_transient();
for entry in entries {
let sign = if entry.added { "+" } else { "-" };
let color = if self.caps.colors {
if entry.added { SGR_GREEN } else { SGR_RED }
} else {
""
};
let reset = if self.caps.colors { SGR_RESET } else { "" };
let _ = writeln!(
self.out,
" {}{} {}{}",
color,
sign,
scrub_controls(&entry.text),
reset
);
}
}
UiLine::ApprovalPrompt { tool, detail } => {
self.drop_transient();
let _ = writeln!(
self.out,
"{}",
crate::i18n::t(crate::i18n::Msg::ApprovalPromptAlt {
tool: &scrub_controls(&tool),
detail: &scrub_controls(&detail),
})
);
}
UiLine::Error(msg) => {
self.drop_transient();
let color = if self.caps.colors { SGR_RED } else { "" };
let reset = if self.caps.colors { SGR_RESET } else { "" };
let _ = writeln!(
self.out,
"{}[Error: {}]{}",
color,
scrub_controls(&msg),
reset
);
}
UiLine::Warning(msg) => {
self.drop_transient();
let color = if self.caps.colors { SGR_BOLD_YELLOW } else { "" };
let reset = if self.caps.colors { SGR_RESET } else { "" };
let _ = writeln!(
self.out,
"{}! {}{}",
color,
scrub_controls(&msg),
reset
);
}
UiLine::TurnCancelled => {
self.drop_transient();
let _ = writeln!(self.out, "(cancelled)");
}
UiLine::TurnComplete => {
self.drop_transient();
let _ = self.out.write_all(b"\n");
}
UiLine::Spinner { frame, label } => {
// CR + frame + label + EL clears any leftover glyphs
// from a longer previous frame. Stays on its own line
// until the next non-transient write triggers
// `drop_transient`. caps.spinner gates the whole thing
// off on dumb terminals (no `\r` support there either).
if self.caps.spinner {
let dim = if self.caps.colors { SGR_DIM } else { "" };
let reset = if self.caps.colors { SGR_RESET } else { "" };
let _ = write!(
self.out,
"\r{}{} {}{}\x1b[K",
dim,
frame,
scrub_controls(&label),
reset
);
let _ = self.out.flush();
self.transient_active = true;
}
}
UiLine::ClearTransient => {
if self.transient_active {
let _ = self.out.write_all(b"\r\x1b[K");
self.transient_active = false;
}
}
UiLine::StreamingBox { .. } => {
// No streaming-box rendering in plain mode — assistant
// text streams as plain text via AssistantText.
}
UiLine::TurnSeparator { label } => {
self.drop_transient();
let _ = writeln!(self.out, "--- {} ---", scrub_controls(&label));
}
UiLine::InputPrompt { buf, .. } => {
if !self.last_prompt_written {
self.drop_transient();
if self.interactive_terminal {
// Real TTY — write `❯ ` so the user can see we
// are ready and the kernel will overlay their
// typed input on top of this prefix.
let chev = self.caps.prompt_chevron();
let _ = write!(self.out, "{}{}", chev, scrub_controls(&buf));
}
// Pipe mode: no human watching, prompt is just noise.
// Input arrives via stdin, gets echoed via UiLine::User.
self.last_prompt_written = true;
}
}
UiLine::InputCommit => {
let _ = self.out.write_all(b"\n");
self.last_prompt_written = false;
}
UiLine::CommandOutput(text) => {
self.drop_transient();
// CommandOutput is trusted internal text — keep SGR so
// colours / bold reach the terminal. The sanitizer
// strips C0 controls but lets SGR through; downstream
// consumers (other terminals, pipes) handle the bytes
// unchanged.
let safe = crate::sanitize::scrub_controls_keep_sgr(&text);
let _ = self.out.write_all(safe.as_bytes());
if !safe.ends_with('\n') {
let _ = self.out.write_all(b"\n");
}
}
UiLine::ImageAttachment(n) => {
// Plain mode echoes attachment markers with the same
// 2-space indent as the TTY renderers, then a newline.
self.drop_transient();
let _ = writeln!(self.out, " └ [Image #{}]", n);
}
UiLine::VisionPreprocessSuccess { msg, model } => {
// Plain mode loses styling distinctions; print
// message + model as one line, same indent as
// ImageAttachment, then a blank line so the next
// event's output reads as a separate paragraph.
self.drop_transient();
let _ = writeln!(self.out, " {} {}", msg, model);
let _ = writeln!(self.out);
}
}
}
fn flush(&mut self) {
let _ = self.out.flush();
}
fn shutdown(&mut self) {
let _ = self.out.flush();
}
fn reset(&mut self) {
// Plain renderer has no cached footer state; just flush.
let _ = self.out.flush();
}
fn clear_screen(&mut self) {
// Pipe / non-TTY sink — a hardware "clear screen" is meaningless.
// Just flush so whatever's queued is visible before the caller
// (e.g. the `/clear` command) moves on.
let _ = self.out.flush();
}
fn suspend_for_external(&mut self) {
let _ = self.out.flush();
}
fn resume_from_external(&mut self) {
let _ = self.out.flush();
}
fn flush_deferred(&mut self) {
// PlainRenderer has no throttling — deferred queue is empty.
let _ = self.out.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build caps with all capabilities OFF — exercises the dumb /
/// pipe / CI path where PlainRenderer must emit zero SGR / unicode.
fn caps_dumb() -> TerminalCaps {
TerminalCaps {
tty: false,
colors: false,
spinner: false,
bracketed_paste: false,
raw_mode: false,
scroll_region: false,
unicode_symbols: false,
legacy_conhost: false,
}
}
/// Build caps representing a JediTerm-class terminal: tty cleared
/// (matches what `lib.rs` does in the force_plain branch), but
/// colours / spinner / unicode all on. Exercises the optimised
/// plain-mode path.
fn caps_jediterm_ish() -> TerminalCaps {
TerminalCaps {
tty: false, // cleared by lib.rs force_plain branch
colors: true,
spinner: true,
bracketed_paste: false,
raw_mode: false,
scroll_region: false,
unicode_symbols: true,
legacy_conhost: false,
}
}
#[test]
fn no_sgr_or_unicode_in_dumb_mode() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
r.render(UiLine::ToolCall {
name: "read_file".into(),
detail: "x.rs".into(),
});
r.render(UiLine::ToolResult {
success: true,
summary: "done".into(),
});
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains('\x1b'), "dumb mode must emit zero SGR. got: {}", s);
assert!(s.contains("● read_file(x.rs)"));
assert!(s.contains("✓ done"));
}
#[test]
fn colours_emitted_when_caps_on() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_jediterm_ish());
r.render(UiLine::ToolResult {
success: false,
summary: "boom".into(),
});
r.render(UiLine::Error("kaboom".into()));
r.flush();
let s = String::from_utf8(buf).unwrap();
// Red ✗ and red [Error: …] both present.
assert!(s.contains("\x1b[31m"), "expected red SGR for failure / error. got: {}", s);
assert!(s.contains("\x1b[0m"), "expected SGR reset after coloured spans. got: {}", s);
}
#[test]
fn spinner_overwrites_with_carriage_return_when_capable() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_jediterm_ish());
r.render(UiLine::Spinner {
frame: "⠋",
label: "Thinking".into(),
});
r.render(UiLine::Spinner {
frame: "⠙",
label: "Thinking".into(),
});
r.flush();
let s = String::from_utf8(buf).unwrap();
// Both frames present. With colours on the dim-SGR sits between
// the CR and the braille glyph, so we assert each piece exists
// rather than that they're contiguous: CR (so the next frame
// overwrites), the glyph itself, and EL after each frame.
assert!(s.starts_with('\r'), "spinner must start with CR. got: {:?}", s);
assert!(s.contains("⠋"), "first frame missing. got: {:?}", s);
assert!(s.contains("⠙"), "second frame missing. got: {:?}", s);
assert_eq!(s.matches('\r').count(), 2, "expected exactly 2 CR (one per frame). got: {:?}", s);
assert_eq!(s.matches("\x1b[K").count(), 2, "expected EL per frame. got: {:?}", s);
}
#[test]
fn spinner_is_noop_when_caps_disable_it() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
r.render(UiLine::Spinner {
frame: "⠋",
label: "Thinking".into(),
});
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(s.is_empty(), "no-spinner caps must produce no output. got: {:?}", s);
}
/// Spinner stays on screen until something else needs to write —
/// then `drop_transient` wipes it via `\r\x1b[K` so the next
/// real line starts at column 0 of a clean row.
#[test]
fn next_write_after_spinner_wipes_it_first() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_jediterm_ish());
r.render(UiLine::Spinner {
frame: "⠋",
label: "Thinking".into(),
});
r.render(UiLine::AssistantText("hello".into()));
r.flush();
let s = String::from_utf8(buf).unwrap();
// Spinner output + wipe + assistant text, in that order.
let spinner_pos = s.find("⠋").expect("spinner present");
let wipe_pos = s.find("\r\x1b[K").expect("wipe sequence present");
let text_pos = s.find("hello").expect("assistant text present");
assert!(
spinner_pos < wipe_pos && wipe_pos < text_pos,
"expected spinner → wipe → text ordering. got: {:?}",
s
);
}
#[test]
fn input_prompt_chevron_unicode_or_ascii_per_caps() {
// Test the chevron *output* path — InputPrompt only writes
// when interactive_terminal=true, so force it on for the
// chevron-rendering subject under test (the alternative path
// is covered by `input_prompt_suppressed_in_pipe_mode` below).
// Unicode caps → `❯ ` (U+276F + space, two display columns).
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_caps_and_interactive(
&mut buf,
caps_jediterm_ish(),
true,
);
r.render(UiLine::InputPrompt {
buf: "hi".into(),
cursor_byte: 2,
menu: None,
status: crate::render::StatusLine::default(),
attachments: Vec::new(),
});
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with("\u{276f} "), "unicode caps must use ❯ chevron. got: {:?}", s);
// Dumb caps → ASCII `> ` fallback.
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_caps_and_interactive(&mut buf, caps_dumb(), true);
r.render(UiLine::InputPrompt {
buf: "hi".into(),
cursor_byte: 2,
menu: None,
status: crate::render::StatusLine::default(),
attachments: Vec::new(),
});
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with("> "), "dumb caps must use ASCII chevron. got: {:?}", s);
}
/// Real-TTY force_plain (JediTerm / conhost / ATOMCODE_PLAIN=1):
/// kernel cooked-mode does its own echo of user input, so we must
/// NOT render UiLine::User — otherwise the user sees `❯ 你好`
/// twice in a row (the duplicate-line bug from the screenshot).
#[test]
fn user_echo_suppressed_on_interactive_terminal() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_caps_and_interactive(
&mut buf,
caps_jediterm_ish(),
true, // interactive — terminal will echo
);
r.render(UiLine::User("hello".into()));
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(
s.is_empty(),
"interactive force_plain must suppress UiLine::User to avoid \
duplicating the kernel's cooked-mode echo. got: {:?}",
s
);
}
/// Pipe / CI / dumb: kernel does NOT echo, so UiLine::User is
/// the only source of input visibility for log readers.
#[test]
fn user_echo_rendered_in_pipe_mode() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_caps_and_interactive(
&mut buf,
caps_dumb(),
false, // non-interactive (pipe)
);
r.render(UiLine::User("hello".into()));
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("hello"),
"pipe mode must render UiLine::User as the only input echo. got: {:?}",
s
);
}
/// Pipe mode has no human watching the screen, so InputPrompt's
/// `❯ ` prefix is noise. UiLine::User (which we DO render in
/// pipe mode, asserted above) handles input visibility.
#[test]
fn input_prompt_suppressed_in_pipe_mode() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_caps_and_interactive(
&mut buf,
caps_dumb(),
false, // non-interactive (pipe)
);
r.render(UiLine::InputPrompt {
buf: "".into(),
cursor_byte: 0,
menu: None,
status: crate::render::StatusLine::default(),
attachments: Vec::new(),
});
r.flush();
let s = String::from_utf8(buf).unwrap();
assert!(
s.is_empty(),
"pipe mode must suppress InputPrompt — there's no human to read it. got: {:?}",
s
);
}
/// `with_writer_and_caps` (without explicit `interactive` arg)
/// derives the flag from caps.tty: caps.tty=true → interactive,
/// caps.tty=false → pipe. Lets test fixtures stay terse for
/// non-User / non-InputPrompt scenarios.
#[test]
fn with_writer_and_caps_defaults_interactive_from_caps_tty() {
// caps.tty=true → interactive=true → User suppressed.
let mut tty_caps = caps_jediterm_ish();
tty_caps.tty = true;
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, tty_caps);
r.render(UiLine::User("x".into()));
r.flush();
assert!(
String::from_utf8(buf).unwrap().is_empty(),
"caps.tty=true should default to interactive (suppress User)"
);
// caps.tty=false (already in caps_dumb) → interactive=false → User rendered.
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
r.render(UiLine::User("x".into()));
r.flush();
assert!(
String::from_utf8(buf).unwrap().contains('x'),
"caps.tty=false should default to pipe (render User)"
);
}
#[test]
fn assistant_text_flushed_plainly() {
let mut buf = Vec::new();
let mut r = PlainRenderer::with_writer_and_caps(&mut buf, caps_dumb());
r.render(UiLine::AssistantText("hello".into()));
r.render(UiLine::AssistantLineBreak);
r.flush();
let s = String::from_utf8(buf).unwrap();
assert_eq!(s, "hello\n");
}
}