Skip to main content

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}