xcodeai/io/terminal.rs
1// src/io/terminal.rs
2//
3// TerminalIO — concrete AgentIO implementation for interactive terminals.
4//
5// This is the "current behaviour, behind the AgentIO trait". All the
6// eprintln! calls that previously lived directly in CoderAgent::run() have
7// been moved here. The styling is unchanged — same `console` crate, same
8// colours, same format.
9//
10// Markdown rendering:
11// When running in a real TTY and `no_markdown` is false, the agent's final
12// response text (passed through `show_status`) is rendered with `termimad`
13// so that **bold**, *italic*, `code`, bullet lists, headings, etc. are
14// displayed with ANSI styling instead of raw markdown syntax.
15//
16// Pass `--no-markdown` on the CLI (or set `no_markdown: true` when
17// constructing TerminalIO directly) to disable this and show plain text.
18//
19// Reading confirmation answers still uses tokio::task::spawn_blocking so we
20// don't block the async executor while waiting for the user to type.
21
22use crate::io::AgentIO;
23use anyhow::Result;
24use async_trait::async_trait;
25
26/// Terminal-based I/O: writes status/tool output to stderr, reads from stdin.
27///
28/// # Fields
29///
30/// * `no_markdown` – when `true`, the agent's textual output is written
31/// verbatim (no ANSI markdown rendering). When `false` (the default) AND
32/// stderr is a real TTY, `termimad` is used to render markdown syntax.
33///
34/// This is the implementation used in both the `xcodeai run` subcommand and
35/// the interactive REPL.
36#[derive(Default)]
37pub struct TerminalIO {
38 /// Disable markdown rendering even when stderr is a TTY.
39 /// Controlled by the `--no-markdown` CLI flag.
40 pub no_markdown: bool,
41}
42
43impl TerminalIO {
44 /// Create a new TerminalIO with the given markdown setting.
45 ///
46 /// # Arguments
47 /// * `no_markdown` – pass `true` to disable markdown rendering.
48 #[allow(dead_code)]
49 pub fn new(no_markdown: bool) -> Self {
50 Self { no_markdown }
51 }
52}
53
54// ── Markdown rendering helper ────────────────────────────────────────────────
55
56/// Render `text` using `termimad` if we are on a real TTY and `no_markdown`
57/// is false; otherwise return the text unchanged.
58///
59/// `termimad` understands CommonMark-style markdown:
60/// - `**bold**` / `*italic*`
61/// - `` `inline code` `` and fenced code blocks
62/// - `# headings` at various levels
63/// - `- bullet` lists and `1. numbered` lists
64/// - `> blockquotes`
65/// - `---` horizontal rules
66///
67/// The rendered output contains ANSI escape codes that are interpreted by
68/// the terminal emulator to apply colour and bold/italic styling.
69///
70/// We check `console::Term::stderr().is_term()` so that piped output (e.g.
71/// `xcodeai run "task" 2>log.txt`) never contains stray ANSI codes.
72fn render_markdown(text: &str, no_markdown: bool) -> String {
73 // Condition 1: caller explicitly requested plain text.
74 if no_markdown {
75 return text.to_owned();
76 }
77 // Condition 2: stderr is not a real TTY — ANSI codes would be noise.
78 if !console::Term::stderr().is_term() {
79 return text.to_owned();
80 }
81 // Use termimad's default skin. MadSkin::default() picks a skin that
82 // works on both light and dark terminal backgrounds by using bold/italic
83 // ANSI attributes rather than colour-specific themes.
84 let skin = termimad::MadSkin::default();
85 // `skin.term_text(text)` returns a `FmtText` that implements `Display`.
86 // `format!` drives the Display impl, which writes the ANSI-decorated
87 // lines into a String.
88 format!("{}", skin.term_text(text))
89}
90
91// ── AgentIO implementation ───────────────────────────────────────────────────
92
93#[async_trait]
94impl AgentIO for TerminalIO {
95 // ── Status banner / final response ────────────────────────────────────────
96
97 /// Print a status line or the agent's final response.
98 ///
99 /// When markdown rendering is enabled (TTY + `!no_markdown`), the text is
100 /// run through `termimad` so that markdown syntax is displayed with ANSI
101 /// styling. Otherwise the text is printed as-is.
102 ///
103 /// Example output (plain):
104 /// " ▶ auto-continuing…"
105 /// " ◆ checkpoint (25 iterations) — verifying task progress…"
106 ///
107 /// Example output (rendered):
108 /// The string "**bold** and `code`" appears with bold ANSI styling.
109 async fn show_status(&self, msg: &str) -> Result<()> {
110 let rendered = render_markdown(msg, self.no_markdown);
111 eprintln!("{}", rendered);
112 Ok(())
113 }
114
115 // ── Tool call progress ────────────────────────────────────────────────────
116
117 /// Print a one-line "→ tool_name ( args_preview" line.
118 ///
119 /// Example output:
120 /// " → bash ( command: cargo test"
121 async fn show_tool_call(&self, tool_name: &str, args_preview: &str) -> Result<()> {
122 eprintln!(
123 " {} {} {} {}",
124 console::style("→").cyan().dim(),
125 console::style(tool_name).cyan(),
126 console::style("(").dim(),
127 console::style(args_preview).dim(),
128 );
129 Ok(())
130 }
131
132 // ── Tool result preview ───────────────────────────────────────────────────
133
134 /// Print the first line of a tool result.
135 ///
136 /// Success: " ← first line of output" (dim)
137 /// Error: " ← error: first line" (red)
138 async fn show_tool_result(&self, preview: &str, is_error: bool) -> Result<()> {
139 if is_error {
140 eprintln!(
141 " {} {}",
142 console::style("← error:").red().dim(),
143 console::style(preview).red().dim(),
144 );
145 } else {
146 eprintln!(
147 " {} {}",
148 console::style("←").dim(),
149 console::style(preview).dim(),
150 );
151 }
152 Ok(())
153 }
154
155 // ── Error / warning ───────────────────────────────────────────────────────
156
157 /// Print a warning / error line, e.g. " ! Reached auto-continue limit".
158 async fn write_error(&self, msg: &str) -> Result<()> {
159 eprintln!("{}", msg);
160 Ok(())
161 }
162
163 // ── Destructive confirmation ───────────────────────────────────────────────
164
165 /// Prompt the user before a destructive tool call.
166 ///
167 /// Prints a yellow warning to stderr, reads one line from stdin
168 /// via `tokio::task::spawn_blocking` (so the async executor is not blocked),
169 /// and returns `true` only if the user typed 'y' or 'Y'.
170 async fn confirm_destructive(&self, tool_name: &str, args_preview: &str) -> Result<bool> {
171 use std::io::Write;
172
173 // Print the prompt to stderr — same stream as all tool output.
174 eprint!(
175 " {} {} {} {} {} ",
176 console::style("⚠").yellow().bold(),
177 console::style(tool_name).yellow(),
178 console::style("(").dim(),
179 console::style(args_preview).yellow(),
180 console::style("[y/N]:").dim(),
181 );
182 let _ = std::io::stderr().flush();
183
184 // Read one line from stdin in a blocking thread.
185 // `spawn_blocking` moves the work off the async executor so we don't
186 // starve other tasks while the user is thinking.
187 let answer = tokio::task::spawn_blocking(|| {
188 let mut line = String::new();
189 std::io::stdin().read_line(&mut line).unwrap_or(0);
190 line.trim().to_lowercase()
191 })
192 .await
193 .unwrap_or_default();
194
195 Ok(answer == "y")
196 }
197}
198
199// ── Unit tests ───────────────────────────────────────────────────────────────
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 /// When `no_markdown` is true, text is returned unchanged regardless of
206 /// whether we're in a TTY or not.
207 #[test]
208 fn test_render_markdown_no_markdown_flag() {
209 let input = "**bold** and *italic* and `code`";
210 // no_markdown=true → identity
211 let output = render_markdown(input, true);
212 assert_eq!(
213 output, input,
214 "should return text unchanged when no_markdown=true"
215 );
216 }
217
218 /// The `render_markdown` function must never panic on empty input.
219 #[test]
220 fn test_render_markdown_empty_string() {
221 // Should not panic — just return empty or whitespace
222 let output = render_markdown("", true);
223 assert_eq!(output, "");
224 }
225
226 /// Verify TerminalIO::default() has no_markdown = false.
227 #[test]
228 fn test_terminal_io_default_no_markdown_false() {
229 let io = TerminalIO::default();
230 assert!(!io.no_markdown, "default should have markdown enabled");
231 }
232
233 /// Verify TerminalIO::new(true) stores the flag correctly.
234 #[test]
235 fn test_terminal_io_new_no_markdown_true() {
236 let io = TerminalIO::new(true);
237 assert!(io.no_markdown);
238 }
239
240 /// When not in a TTY (which is always the case in test harness),
241 /// render_markdown with no_markdown=false should return text unchanged
242 /// (because `console::Term::stderr().is_term()` is false in tests).
243 #[test]
244 fn test_render_markdown_non_tty_returns_plain() {
245 // In the cargo test harness stderr is not a TTY, so even with
246 // no_markdown=false the function should return the original text.
247 let input = "# Heading\n**bold** text";
248 let output = render_markdown(input, false);
249 // In CI / test harness (non-TTY), we get the plain text back.
250 // This test verifies the non-TTY code path doesn't corrupt the text.
251 assert_eq!(output, input);
252 }
253}