Skip to main content

pi/
tui.rs

1//! Terminal UI components using rich_rust.
2//!
3//! This module provides the interactive terminal interface for Pi,
4//! built on rich_rust for beautiful markup-based output.
5
6use std::io::{self, IsTerminal, Write};
7
8use rich_rust::prelude::*;
9use rich_rust::renderables::{Markdown, Syntax};
10use rich_rust::segment::Segment;
11
12/// Pi's console wrapper providing styled terminal output.
13pub struct PiConsole {
14    console: Console,
15    is_tty: bool,
16}
17
18impl PiConsole {
19    /// Create a new Pi console with auto-detected terminal capabilities.
20    pub fn new() -> Self {
21        Self::new_with_theme(None)
22    }
23
24    /// Create a new Pi console with an optional theme.
25    pub fn new_with_theme(_theme: Option<crate::theme::Theme>) -> Self {
26        let is_tty = io::stdout().is_terminal();
27        let console = Console::builder().markup(is_tty).emoji(is_tty).build();
28
29        Self { console, is_tty }
30    }
31
32    /// Create a console with forced color output (for testing).
33    pub fn with_color() -> Self {
34        Self {
35            console: Console::builder()
36                .markup(true)
37                .emoji(true)
38                .file(Box::new(io::sink()))
39                .build(),
40            is_tty: true,
41        }
42    }
43
44    /// Check if we're running in a terminal.
45    pub const fn is_terminal(&self) -> bool {
46        self.is_tty
47    }
48
49    /// Get the terminal width.
50    pub fn width(&self) -> usize {
51        self.console.width()
52    }
53
54    // -------------------------------------------------------------------------
55    // Text Output
56    // -------------------------------------------------------------------------
57
58    /// Print plain text without any styling.
59    pub fn print_plain(&self, text: &str) {
60        print!("{text}");
61        let _ = io::stdout().flush();
62    }
63
64    /// Print text with rich markup (if TTY).
65    pub fn print_markup(&self, markup: &str) {
66        if self.is_tty {
67            self.console.print(markup);
68        } else {
69            // Strip markup for non-TTY
70            print!("{}", strip_markup(markup));
71            let _ = io::stdout().flush();
72        }
73    }
74
75    /// Print a newline.
76    pub fn newline(&self) {
77        println!();
78    }
79
80    /// Render Markdown (TTY → styled output; non-TTY → raw Markdown).
81    pub fn render_markdown(&self, markdown: &str) {
82        self.render_markdown_with_indent(markdown, None);
83    }
84
85    /// Render Markdown with an optional code block indentation override.
86    pub fn render_markdown_with_indent(&self, markdown: &str, code_block_indent: Option<usize>) {
87        if self.is_tty {
88            let indent = code_block_indent.unwrap_or(0);
89            let mut segments = render_markdown_with_syntax(markdown, self.width(), indent);
90            let mut ends_with_newline = false;
91            for segment in segments.iter().rev() {
92                let text = segment.text.as_ref();
93                if text.is_empty() {
94                    continue;
95                }
96                ends_with_newline = text.ends_with('\n');
97                break;
98            }
99            if !ends_with_newline {
100                segments.push(Segment::plain("\n"));
101            }
102            self.console.print_segments(&segments);
103        } else {
104            print!("{markdown}");
105            if !markdown.ends_with('\n') {
106                println!();
107            }
108            let _ = io::stdout().flush();
109        }
110    }
111
112    // -------------------------------------------------------------------------
113    // Agent Event Rendering
114    // -------------------------------------------------------------------------
115
116    /// Render streaming text from the assistant.
117    pub fn render_text_delta(&self, text: &str) {
118        print!("{text}");
119        let _ = io::stdout().flush();
120    }
121
122    /// Render streaming thinking text (dimmed).
123    pub fn render_thinking_delta(&self, text: &str) {
124        if self.is_tty {
125            // Dim style for thinking
126            print!("\x1b[2m{text}\x1b[0m");
127        } else {
128            print!("{text}");
129        }
130        let _ = io::stdout().flush();
131    }
132
133    /// Render the start of a thinking block.
134    pub fn render_thinking_start(&self) {
135        if self.is_tty {
136            self.print_markup("\n[dim italic]Thinking...[/]\n");
137        }
138    }
139
140    /// Render the end of a thinking block.
141    pub fn render_thinking_end(&self) {
142        if self.is_tty {
143            self.print_markup("[/dim]\n");
144        }
145    }
146
147    /// Render tool execution start.
148    pub fn render_tool_start(&self, name: &str, _input: &str) {
149        if self.is_tty {
150            self.print_markup(&format!("\n[bold yellow][[Running {name}...]][/]\n"));
151        }
152    }
153
154    /// Render tool execution end.
155    pub fn render_tool_end(&self, name: &str, is_error: bool) {
156        if self.is_tty {
157            if is_error {
158                self.print_markup(&format!("[bold red][[{name} failed]][/]\n\n"));
159            } else {
160                self.print_markup(&format!("[bold green][[{name} done]][/]\n\n"));
161            }
162        }
163    }
164
165    /// Render an error message.
166    pub fn render_error(&self, error: &str) {
167        if self.is_tty {
168            self.print_markup(&format!("\n[bold red]Error:[/] {error}\n"));
169        } else {
170            eprintln!("\nError: {error}");
171        }
172    }
173
174    /// Render a warning message.
175    pub fn render_warning(&self, warning: &str) {
176        if self.is_tty {
177            self.print_markup(&format!("[bold yellow]Warning:[/] {warning}\n"));
178        } else {
179            eprintln!("Warning: {warning}");
180        }
181    }
182
183    /// Render a success message.
184    pub fn render_success(&self, message: &str) {
185        if self.is_tty {
186            self.print_markup(&format!("[bold green]{message}[/]\n"));
187        } else {
188            println!("{message}");
189        }
190    }
191
192    /// Render an info message.
193    pub fn render_info(&self, message: &str) {
194        if self.is_tty {
195            self.print_markup(&format!("[bold blue]{message}[/]\n"));
196        } else {
197            println!("{message}");
198        }
199    }
200
201    // -------------------------------------------------------------------------
202    // Structured Output
203    // -------------------------------------------------------------------------
204
205    /// Render a panel with a title.
206    pub fn render_panel(&self, content: &str, title: &str) {
207        if self.is_tty {
208            let panel = Panel::from_text(content)
209                .title(title)
210                .border_style(Style::parse("cyan").unwrap_or_default());
211            self.console.print_renderable(&panel);
212        } else {
213            println!("--- {title} ---");
214            println!("{content}");
215            println!("---");
216        }
217    }
218
219    /// Render a table.
220    pub fn render_table(&self, headers: &[&str], rows: &[Vec<&str>]) {
221        if self.is_tty {
222            let mut table = Table::new().header_style(Style::parse("bold").unwrap_or_default());
223            for header in headers {
224                table = table.with_column(Column::new(*header));
225            }
226            for row in rows {
227                table.add_row_cells(row.iter().copied());
228            }
229            self.console.print_renderable(&table);
230        } else {
231            // Simple text table for non-TTY
232            println!("{}", headers.join("\t"));
233            for row in rows {
234                println!("{}", row.join("\t"));
235            }
236        }
237    }
238
239    /// Render a horizontal rule.
240    pub fn render_rule(&self, title: Option<&str>) {
241        if self.is_tty {
242            let rule = title.map_or_else(Rule::new, Rule::with_title);
243            self.console.print_renderable(&rule);
244        } else if let Some(t) = title {
245            println!("--- {t} ---");
246        } else {
247            println!("---");
248        }
249    }
250
251    // -------------------------------------------------------------------------
252    // Usage/Status Display
253    // -------------------------------------------------------------------------
254
255    /// Render token usage statistics.
256    pub fn render_usage(&self, input_tokens: u32, output_tokens: u32, cost_usd: Option<f64>) {
257        if self.is_tty {
258            let cost_str = cost_usd
259                .map(|c| format!(" [dim](${c:.4})[/]"))
260                .unwrap_or_default();
261            self.print_markup(&format!(
262                "[dim]Tokens: {input_tokens} in / {output_tokens} out{cost_str}[/]\n"
263            ));
264        }
265    }
266
267    /// Render session info.
268    pub fn render_session_info(&self, session_path: &str, message_count: usize) {
269        if self.is_tty {
270            self.print_markup(&format!(
271                "[dim]Session: {session_path} ({message_count} messages)[/]\n"
272            ));
273        }
274    }
275
276    /// Render model info.
277    pub fn render_model_info(&self, model: &str, thinking_level: Option<&str>) {
278        if self.is_tty {
279            let thinking_str = thinking_level
280                .map(|t| format!(" [dim](thinking: {t})[/]"))
281                .unwrap_or_default();
282            self.print_markup(&format!("[dim]Model: {model}{thinking_str}[/]\n"));
283        }
284    }
285
286    // -------------------------------------------------------------------------
287    // Interactive Mode Helpers
288    // -------------------------------------------------------------------------
289
290    /// Render the input prompt.
291    pub fn render_prompt(&self) {
292        if self.is_tty {
293            self.print_markup("[bold cyan]>[/] ");
294        } else {
295            print!("> ");
296        }
297        let _ = io::stdout().flush();
298    }
299
300    /// Render a user message echo.
301    pub fn render_user_message(&self, message: &str) {
302        if self.is_tty {
303            self.print_markup(&format!("[bold]You:[/] {message}\n\n"));
304        } else {
305            println!("You: {message}\n");
306        }
307    }
308
309    /// Render assistant message start.
310    pub fn render_assistant_start(&self) {
311        if self.is_tty {
312            self.print_markup("[bold]Assistant:[/] ");
313        } else {
314            print!("Assistant: ");
315        }
316        let _ = io::stdout().flush();
317    }
318
319    /// Clear the current line (for progress updates).
320    pub fn clear_line(&self) {
321        if self.is_tty {
322            print!("\r\x1b[K");
323            let _ = io::stdout().flush();
324        }
325    }
326
327    /// Move cursor up N lines.
328    pub fn cursor_up(&self, n: usize) {
329        if self.is_tty && n > 0 {
330            print!("\x1b[{n}A");
331            let _ = io::stdout().flush();
332        }
333    }
334}
335
336impl Default for PiConsole {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342// Thread-safe console for use across async tasks
343impl Clone for PiConsole {
344    fn clone(&self) -> Self {
345        Self {
346            console: Console::builder()
347                .markup(self.is_tty)
348                .emoji(self.is_tty)
349                .build(),
350            is_tty: self.is_tty,
351        }
352    }
353}
354
355#[derive(Debug, Clone)]
356enum MarkdownChunk {
357    Text(String),
358    CodeBlock {
359        language: Option<String>,
360        code: String,
361    },
362}
363
364fn parse_fenced_code_language(info: &str) -> Option<String> {
365    let language_tag = info
366        .split_whitespace()
367        .next()
368        .unwrap_or_default()
369        .split(',')
370        .next()
371        .unwrap_or_default()
372        .trim();
373    if language_tag.is_empty() {
374        None
375    } else {
376        Some(language_tag.to_ascii_lowercase())
377    }
378}
379
380fn split_markdown_fenced_code_blocks(markdown: &str) -> Vec<MarkdownChunk> {
381    let mut chunks = Vec::new();
382
383    let mut text_buf = String::new();
384    let mut code_buf = String::new();
385    let mut in_code_block = false;
386    let mut fence_len = 0usize;
387    let mut fence_char = '\0';
388    let mut code_language: Option<String> = None;
389
390    for line in markdown.split_inclusive('\n') {
391        let trimmed_start = line.trim_start();
392        let trimmed_line = trimmed_start.trim_end_matches(['\r', '\n']);
393
394        let marker = trimmed_line.chars().next().unwrap_or('\0');
395        let is_potential_fence = marker == '`' || marker == '~';
396        let marker_count = if is_potential_fence {
397            trimmed_line.chars().take_while(|ch| *ch == marker).count()
398        } else {
399            0
400        };
401        let is_fence = marker_count >= 3;
402
403        if !in_code_block {
404            if is_fence {
405                fence_len = marker_count;
406                fence_char = marker;
407                let info = trimmed_line.get(fence_len..).unwrap_or_default();
408
409                // CommonMark: backtick fence info strings may not contain backticks.
410                // If it does, this is likely an inline code span at the start of a line, not a fence.
411                if marker == '`' && info.contains('`') {
412                    text_buf.push_str(line);
413                    continue;
414                }
415
416                if !text_buf.is_empty() {
417                    chunks.push(MarkdownChunk::Text(text_buf.clone()));
418                    text_buf.clear();
419                }
420
421                code_language = parse_fenced_code_language(info);
422                in_code_block = true;
423                code_buf.clear();
424                continue;
425            }
426
427            text_buf.push_str(line);
428            continue;
429        }
430
431        if is_fence
432            && marker == fence_char
433            && marker_count >= fence_len
434            && trimmed_line[marker_count..].trim().is_empty()
435        {
436            chunks.push(MarkdownChunk::CodeBlock {
437                language: code_language.take(),
438                code: code_buf.clone(),
439            });
440            code_buf.clear();
441            in_code_block = false;
442            fence_len = 0;
443            fence_char = '\0';
444            continue;
445        }
446
447        code_buf.push_str(line);
448    }
449
450    if in_code_block {
451        // Unterminated fence (common during streaming).
452        // Emit as a valid code block so it gets syntax highlighting while streaming,
453        // and doesn't destroy previous chunks.
454        chunks.push(MarkdownChunk::CodeBlock {
455            language: code_language.take(),
456            code: code_buf,
457        });
458    } else if !text_buf.is_empty() {
459        chunks.push(MarkdownChunk::Text(text_buf));
460    }
461
462    chunks
463}
464
465fn has_multiple_non_none_styles(segments: &[Segment<'_>]) -> bool {
466    use std::collections::HashSet;
467
468    let mut seen = HashSet::new();
469    for segment in segments {
470        let Some(style) = &segment.style else {
471            continue;
472        };
473        if segment.text.as_ref().trim().is_empty() {
474            continue;
475        }
476
477        seen.insert(style.clone());
478        if seen.len() > 1 {
479            return true;
480        }
481    }
482
483    false
484}
485
486fn render_syntax_line_by_line(
487    code: &str,
488    language: &str,
489    width: usize,
490) -> Option<Vec<Segment<'static>>> {
491    let mut rendered: Vec<Segment<'static>> = Vec::new();
492    for line in code.split_inclusive('\n') {
493        let syntax = Syntax::new(line, language);
494        let items = syntax.render(Some(width)).ok()?;
495        rendered.extend(items.into_iter().map(Segment::into_owned));
496    }
497    Some(rendered)
498}
499
500fn indent_code_block(code: &str, indent: usize) -> String {
501    if indent == 0 {
502        return code.to_string();
503    }
504    let prefix = " ".repeat(indent);
505    let mut out = String::with_capacity(code.len() + indent * code.lines().count().max(1));
506    for line in code.split_inclusive('\n') {
507        out.push_str(&prefix);
508        out.push_str(line);
509    }
510    out
511}
512
513fn render_markdown_with_syntax(
514    markdown: &str,
515    width: usize,
516    code_block_indent: usize,
517) -> Vec<Segment<'static>> {
518    if !markdown.contains("```") {
519        return Markdown::new(markdown)
520            .render(width)
521            .into_iter()
522            .map(Segment::into_owned)
523            .collect();
524    }
525
526    let chunks = split_markdown_fenced_code_blocks(markdown);
527    let mut segments: Vec<Segment<'static>> = Vec::new();
528
529    for chunk in chunks {
530        match chunk {
531            MarkdownChunk::Text(text) => {
532                if text.is_empty() {
533                    continue;
534                }
535                segments.extend(
536                    Markdown::new(text)
537                        .render(width)
538                        .into_iter()
539                        .map(Segment::into_owned),
540                );
541            }
542            MarkdownChunk::CodeBlock { language, mut code } => {
543                if !code.ends_with('\n') {
544                    code.push('\n');
545                }
546                if code_block_indent > 0 {
547                    code = indent_code_block(&code, code_block_indent);
548                }
549
550                let language = language.unwrap_or_else(|| "text".to_string());
551                let require_variation = matches!(language.as_str(), "typescript" | "ts" | "tsx");
552                let mut candidates: Vec<&str> = Vec::new();
553                match language.as_str() {
554                    // syntect's built-in set doesn't always include TypeScript; prefer `ts` if
555                    // available, otherwise fall back to JavaScript highlighting.
556                    "typescript" | "ts" | "tsx" => candidates.extend(["ts", "javascript"]),
557                    _ => candidates.push(language.as_str()),
558                }
559                candidates.push("text");
560
561                let mut rendered_items: Option<Vec<Segment<'static>>> = None;
562                for candidate in candidates {
563                    let syntax = Syntax::new(code.as_str(), candidate);
564                    if let Ok(items) = syntax.render(Some(width)) {
565                        if require_variation
566                            && candidate != "text"
567                            && !has_multiple_non_none_styles(&items)
568                        {
569                            if candidate == "javascript" {
570                                if let Some(line_items) =
571                                    render_syntax_line_by_line(code.as_str(), candidate, width)
572                                {
573                                    if has_multiple_non_none_styles(&line_items) {
574                                        rendered_items = Some(line_items);
575                                        break;
576                                    }
577                                }
578                            }
579                            continue;
580                        }
581                        rendered_items = Some(items.into_iter().map(Segment::into_owned).collect());
582                        break;
583                    }
584                }
585
586                if let Some(items) = rendered_items {
587                    segments.extend(items);
588                } else {
589                    segments.extend(
590                        Markdown::new(format!("```\n{code}```\n"))
591                            .render(width)
592                            .into_iter()
593                            .map(Segment::into_owned),
594                    );
595                }
596            }
597        }
598    }
599
600    segments
601}
602
603/// Strip rich markup tags from text.
604fn strip_markup(text: &str) -> String {
605    let mut result = String::with_capacity(text.len());
606    let mut buffer = String::new();
607    let mut in_tag = false;
608
609    for c in text.chars() {
610        if in_tag {
611            if c == ']' {
612                // End of potential tag
613                // Check heuristics:
614                // 1. Not pure digits (e.g. [0])
615                // 2. Contains only allowed characters
616                let is_pure_digits =
617                    !buffer.is_empty() && buffer.chars().all(|ch| ch.is_ascii_digit());
618                let contains_invalid_chars = buffer.chars().any(|ch| {
619                    !ch.is_ascii_alphanumeric()
620                        && !matches!(
621                            ch,
622                            ' ' | '/'
623                                | ','
624                                | '#'
625                                | '='
626                                | '.'
627                                | ':'
628                                | '-'
629                                | '_'
630                                | '?'
631                                | '&'
632                                | '%'
633                                | '+'
634                                | '~'
635                                | ';'
636                                | '*'
637                                | '\''
638                                | '('
639                                | ')'
640                        )
641                });
642
643                if is_pure_digits || contains_invalid_chars || buffer.is_empty() {
644                    // Not a tag, restore literal
645                    result.push('[');
646                    result.push_str(&buffer);
647                    result.push(']');
648                } else {
649                    // Valid tag, discard (strip it)
650                }
651                buffer.clear();
652                in_tag = false;
653            } else if c == '[' {
654                result.push('[');
655                if buffer.is_empty() {
656                    // Escaped bracket: `[[` becomes `[`
657                    in_tag = false;
658                } else {
659                    // Nested '[' means the previous '[' was literal.
660                    // Flush previous '[' and buffer, start new tag candidate.
661                    result.push_str(&buffer);
662                    buffer.clear();
663                    // Stay in_tag for this new '['
664                }
665            } else {
666                buffer.push(c);
667            }
668        } else if c == '[' {
669            in_tag = true;
670        } else {
671            result.push(c);
672        }
673    }
674
675    // Flush any open tag at end of string
676    if in_tag {
677        result.push('[');
678        result.push_str(&buffer);
679    }
680
681    result
682}
683
684/// Spinner styles for different operations.
685pub enum SpinnerStyle {
686    /// Default dots spinner for general operations.
687    Dots,
688    /// Line spinner for file operations.
689    Line,
690    /// Simple ASCII spinner for compatibility.
691    Simple,
692}
693
694impl SpinnerStyle {
695    /// Get the spinner frames for this style.
696    pub const fn frames(&self) -> &'static [&'static str] {
697        match self {
698            Self::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
699            Self::Line => &["⎺", "⎻", "⎼", "⎽", "⎼", "⎻"],
700            Self::Simple => &["|", "/", "-", "\\"],
701        }
702    }
703
704    /// Get the frame interval in milliseconds.
705    pub const fn interval_ms(&self) -> u64 {
706        match self {
707            Self::Dots => 80,
708            Self::Line | Self::Simple => 100,
709        }
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    use std::collections::HashSet;
718    use std::sync::{Arc, Mutex};
719
720    fn capture_markdown_segments(markdown: &str) -> Vec<Segment<'static>> {
721        capture_markdown_segments_with_indent(markdown, None)
722    }
723
724    fn capture_markdown_segments_with_indent(
725        markdown: &str,
726        code_block_indent: Option<usize>,
727    ) -> Vec<Segment<'static>> {
728        let console = PiConsole::with_color();
729        console.console.begin_capture();
730        console.render_markdown_with_indent(markdown, code_block_indent);
731        console.console.end_capture()
732    }
733
734    fn segments_text(segments: &[Segment<'static>]) -> String {
735        segments.iter().map(|s| s.text.as_ref()).collect()
736    }
737
738    fn unique_style_debug_for_tokens(
739        segments: &[Segment<'static>],
740        tokens: &[&str],
741    ) -> HashSet<String> {
742        segments
743            .iter()
744            .filter(|segment| {
745                let text = segment.text.as_ref();
746                tokens.iter().any(|token| text.contains(token))
747            })
748            .map(|segment| format!("{:?}", segment.style))
749            .collect()
750    }
751
752    #[derive(Clone)]
753    struct SharedBufferWriter {
754        buffer: Arc<Mutex<Vec<u8>>>,
755    }
756
757    impl io::Write for SharedBufferWriter {
758        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
759            self.buffer
760                .lock()
761                .expect("lock buffer")
762                .extend_from_slice(buf);
763            Ok(buf.len())
764        }
765
766        fn flush(&mut self) -> io::Result<()> {
767            Ok(())
768        }
769    }
770
771    #[test]
772    fn test_strip_markup() {
773        assert_eq!(strip_markup("[bold]Hello[/]"), "Hello");
774        assert_eq!(strip_markup("[red]A[/] [blue]B[/]"), "A B");
775        assert_eq!(strip_markup("No markup"), "No markup");
776        assert_eq!(strip_markup("[bold red on blue]Text[/]"), "Text");
777        assert_eq!(strip_markup("array[0]"), "array[0]");
778        assert_eq!(strip_markup("[#ff0000]Hex[/]"), "Hex");
779        assert_eq!(strip_markup("[link=https://example.com]Link[/]"), "Link");
780    }
781
782    #[test]
783    fn render_markdown_emits_ansi_when_tty() {
784        let buffer = Arc::new(Mutex::new(Vec::new()));
785        let writer = SharedBufferWriter {
786            buffer: Arc::clone(&buffer),
787        };
788        let console = Console::builder()
789            .markup(true)
790            .emoji(false)
791            .force_terminal(true)
792            .color_system(ColorSystem::TrueColor)
793            .file(Box::new(writer))
794            .build();
795
796        let pi_console = PiConsole {
797            console,
798            is_tty: true,
799        };
800
801        pi_console.render_markdown("# Title\n\n- Item 1\n- Item 2\n\n**bold**");
802
803        let output = String::from_utf8(
804            buffer
805                .lock()
806                .unwrap_or_else(std::sync::PoisonError::into_inner)
807                .clone(),
808        )
809        .expect("utf-8");
810
811        assert!(
812            output.contains("\u{1b}["),
813            "expected ANSI escape codes, got: {output:?}"
814        );
815        assert!(!output.contains("**bold**"));
816        assert!(output.contains("bold"));
817    }
818
819    #[test]
820    fn test_spinner_frames() {
821        let dots = SpinnerStyle::Dots;
822        assert_eq!(dots.frames().len(), 10);
823        assert_eq!(dots.interval_ms(), 80);
824
825        let simple = SpinnerStyle::Simple;
826        assert_eq!(simple.frames().len(), 4);
827    }
828
829    #[test]
830    fn test_console_creation() {
831        let console = PiConsole::with_color();
832        assert!(console.width() > 0);
833    }
834
835    #[test]
836    fn render_markdown_produces_styled_segments() {
837        let console = PiConsole::with_color();
838
839        console.console.begin_capture();
840        console.render_markdown("# Title\n\nThis is **bold**.\n\n- Item 1\n- Item 2");
841        let segments = console.console.end_capture();
842
843        let captured: String = segments.iter().map(|s| s.text.as_ref()).collect();
844        assert!(captured.contains("Title"));
845        assert!(captured.contains("bold"));
846        assert!(segments.iter().any(|s| s.style.is_some()));
847    }
848
849    #[test]
850    fn render_markdown_code_fence_uses_syntax_highlighting_when_language_present() {
851        let console = PiConsole::with_color();
852
853        console.console.begin_capture();
854        console.render_markdown("```rust\nfn main() {\n    println!(\"hi\");\n}\n```");
855        let segments = console.console.end_capture();
856
857        let code_styles = unique_style_debug_for_tokens(&segments, &["fn", "println"]);
858
859        assert!(
860            code_styles.len() > 1,
861            "expected multiple token styles from syntax highlighting, got {code_styles:?}"
862        );
863    }
864
865    #[test]
866    fn render_markdown_code_block_indent_applies_to_code_lines() {
867        let markdown = "Intro\n\n```rust\nfn main() {\n    println!(\"hi\");\n}\n```\n";
868        let default_segments = capture_markdown_segments(markdown);
869        let indented_segments = capture_markdown_segments_with_indent(markdown, Some(4));
870
871        let default_text = segments_text(&default_segments);
872        let indented_text = segments_text(&indented_segments);
873
874        let default_line = default_text
875            .lines()
876            .find(|line| line.contains("fn main"))
877            .expect("default code line");
878        let indented_line = indented_text
879            .lines()
880            .find(|line| line.contains("fn main"))
881            .expect("indented code line");
882
883        assert!(
884            indented_line.starts_with("    "),
885            "expected indented code line, got: {indented_line:?}"
886        );
887        assert_eq!(
888            default_line.trim(),
889            indented_line.trim(),
890            "indent should only affect whitespace, not content"
891        );
892    }
893
894    #[test]
895    fn parse_fenced_code_language_extracts_first_tag() {
896        assert_eq!(parse_fenced_code_language("rust"), Some("rust".to_string()));
897        assert_eq!(
898            parse_fenced_code_language(" RuSt "),
899            Some("rust".to_string())
900        );
901        assert_eq!(
902            parse_fenced_code_language("rust,ignore"),
903            Some("rust".to_string())
904        );
905        assert_eq!(parse_fenced_code_language(""), None);
906        assert_eq!(parse_fenced_code_language("   "), None);
907    }
908
909    #[test]
910    fn split_markdown_fenced_code_blocks_splits_text_and_code() {
911        let input = "Intro\n\n```rust\nfn main() {}\n```\n\nTail\n";
912        let chunks = split_markdown_fenced_code_blocks(input);
913
914        assert_eq!(chunks.len(), 3);
915        assert!(matches!(chunks[0], MarkdownChunk::Text(_)));
916        assert!(
917            matches!(
918                &chunks[1],
919                MarkdownChunk::CodeBlock { language, code }
920                    if language.as_deref() == Some("rust") && code.contains("fn main")
921            ),
922            "expected rust code block, got {chunks:?}"
923        );
924        assert!(matches!(chunks[2], MarkdownChunk::Text(_)));
925    }
926
927    #[test]
928    fn split_markdown_fenced_code_blocks_unterminated_fence_emits_as_code_block() {
929        let input = "Intro\n\n```rust\nfn main() {}\n";
930        let chunks = split_markdown_fenced_code_blocks(input);
931
932        assert_eq!(
933            chunks.len(),
934            2,
935            "expected a text chunk and an unterminated code block chunk"
936        );
937        assert!(matches!(chunks[0], MarkdownChunk::Text(ref t) if t.contains("Intro")));
938        let MarkdownChunk::CodeBlock { language, code } = &chunks[1] else {
939            unreachable!("expected code block, got {:?}", chunks[1]);
940        };
941        assert_eq!(language.as_deref(), Some("rust"));
942        assert!(code.contains("fn main"));
943    }
944
945    #[test]
946    fn render_markdown_strips_inline_markers_and_renders_headings_lists_links() {
947        let segments = capture_markdown_segments(
948            r"
949# H1
950## H2
951### H3
952#### H4
953##### H5
954###### H6
955
956This is **bold**, *italic*, ~~strike~~, `code`, and [link](https://example.com).
957
958- Bullet 1
9591. Numbered 1
960
961Nested: **bold and *italic*** and ~~**strike bold**~~.
962",
963        );
964
965        let captured = segments_text(&segments);
966        for needle in [
967            "H1",
968            "H2",
969            "H3",
970            "H4",
971            "H5",
972            "H6",
973            "bold",
974            "italic",
975            "strike",
976            "code",
977            "link",
978            "Bullet 1",
979            "Numbered 1",
980            "Nested",
981        ] {
982            assert!(
983                captured.contains(needle),
984                "expected output to contain {needle:?}, got: {captured:?}"
985            );
986        }
987
988        assert!(
989            !captured.contains("**"),
990            "expected bold markers to be stripped, got: {captured:?}"
991        );
992        assert!(
993            !captured.contains("~~"),
994            "expected strikethrough markers to be stripped, got: {captured:?}"
995        );
996        assert!(
997            !captured.contains('`'),
998            "expected inline code markers to be stripped, got: {captured:?}"
999        );
1000        assert!(
1001            !captured.contains("]("),
1002            "expected link markers to be stripped, got: {captured:?}"
1003        );
1004
1005        assert!(
1006            segments.iter().any(|s| s.style.is_some()),
1007            "expected styled segments, got: {segments:?}"
1008        );
1009    }
1010
1011    // ── strip_markup edge cases ──────────────────────────────────────
1012    #[test]
1013    fn strip_markup_nested_tags() {
1014        assert_eq!(strip_markup("[bold][red]text[/][/]"), "text");
1015    }
1016
1017    #[test]
1018    fn strip_markup_empty_tag() {
1019        // `[]` has empty buffer → not treated as tag, preserved
1020        assert_eq!(strip_markup("before[]after"), "before[]after");
1021    }
1022
1023    #[test]
1024    fn strip_markup_adjacent_tags() {
1025        assert_eq!(strip_markup("[bold]A[/][red]B[/]"), "AB");
1026    }
1027
1028    #[test]
1029    fn strip_markup_only_closing_tag() {
1030        assert_eq!(strip_markup("[/]"), "");
1031    }
1032
1033    #[test]
1034    fn strip_markup_unclosed_bracket_at_end() {
1035        assert_eq!(strip_markup("text[unclosed"), "text[unclosed");
1036    }
1037
1038    #[test]
1039    fn strip_markup_bracket_with_special_chars() {
1040        // Characters like ! or @ are not in the tag heuristic set → not a tag
1041        assert_eq!(strip_markup("[hello!]world"), "[hello!]world");
1042        assert_eq!(strip_markup("[hello@world]text"), "[hello@world]text");
1043    }
1044
1045    #[test]
1046    fn strip_markup_pure_digits_preserved() {
1047        assert_eq!(strip_markup("array[0]"), "array[0]");
1048        assert_eq!(strip_markup("arr[123]"), "arr[123]");
1049        assert_eq!(strip_markup("x[0][1][2]"), "x[0][1][2]");
1050    }
1051
1052    #[test]
1053    fn strip_markup_mixed_digit_alpha_is_tag() {
1054        // "dim" is not pure digits → treated as tag
1055        assert_eq!(strip_markup("[dim]faded[/]"), "faded");
1056    }
1057
1058    #[test]
1059    fn strip_markup_empty_input() {
1060        assert_eq!(strip_markup(""), "");
1061    }
1062
1063    #[test]
1064    fn strip_markup_no_brackets() {
1065        assert_eq!(
1066            strip_markup("plain text without brackets"),
1067            "plain text without brackets"
1068        );
1069    }
1070
1071    #[test]
1072    fn strip_markup_hash_color_tag() {
1073        assert_eq!(strip_markup("[#aabbcc]colored[/]"), "colored");
1074    }
1075
1076    #[test]
1077    fn strip_markup_tag_with_equals() {
1078        assert_eq!(strip_markup("[link=https://example.com]click[/]"), "click");
1079    }
1080
1081    #[test]
1082    fn strip_markup_multiple_lines() {
1083        let input = "[bold]line1[/]\n[red]line2[/]\n";
1084        assert_eq!(strip_markup(input), "line1\nline2\n");
1085    }
1086
1087    // ── split_markdown_fenced_code_blocks edge cases ───────────────────
1088    #[test]
1089    fn split_markdown_tilde_code_blocks() {
1090        let input = "text1\n~~~rust\ncode1\n~~~\ntext2\n";
1091        let chunks = split_markdown_fenced_code_blocks(input);
1092        assert_eq!(chunks.len(), 3, "expected 3 chunks: {chunks:?}");
1093        assert!(matches!(&chunks[0], MarkdownChunk::Text(_)));
1094        assert!(
1095            matches!(&chunks[1], MarkdownChunk::CodeBlock { language, .. } if language.as_deref() == Some("rust"))
1096        );
1097        assert!(matches!(&chunks[2], MarkdownChunk::Text(_)));
1098    }
1099
1100    #[test]
1101    fn split_markdown_multiple_code_blocks() {
1102        let input = "text1\n```rust\ncode1\n```\ntext2\n```python\ncode2\n```\ntext3\n";
1103        let chunks = split_markdown_fenced_code_blocks(input);
1104        assert_eq!(chunks.len(), 5, "expected 5 chunks: {chunks:?}");
1105        assert!(matches!(&chunks[0], MarkdownChunk::Text(_)));
1106        assert!(
1107            matches!(&chunks[1], MarkdownChunk::CodeBlock { language, .. } if language.as_deref() == Some("rust"))
1108        );
1109        assert!(matches!(&chunks[2], MarkdownChunk::Text(_)));
1110        assert!(
1111            matches!(&chunks[3], MarkdownChunk::CodeBlock { language, .. } if language.as_deref() == Some("python"))
1112        );
1113        assert!(matches!(&chunks[4], MarkdownChunk::Text(_)));
1114    }
1115
1116    #[test]
1117    fn split_markdown_code_block_no_language() {
1118        let input = "```\nplain code\n```\n";
1119        let chunks = split_markdown_fenced_code_blocks(input);
1120        assert_eq!(chunks.len(), 1);
1121        assert!(matches!(
1122            &chunks[0],
1123            MarkdownChunk::CodeBlock { language, code }
1124                if language.is_none() && code.contains("plain code")
1125        ));
1126    }
1127
1128    #[test]
1129    fn split_markdown_empty_code_block() {
1130        let input = "```rust\n```\n";
1131        let chunks = split_markdown_fenced_code_blocks(input);
1132        assert_eq!(chunks.len(), 1);
1133        assert!(matches!(
1134            &chunks[0],
1135            MarkdownChunk::CodeBlock { language, code }
1136                if language.as_deref() == Some("rust") && code.is_empty()
1137        ));
1138    }
1139
1140    #[test]
1141    fn split_markdown_four_backtick_fence() {
1142        // 4-backtick fence should work (>= 3 backticks)
1143        let input = "````rust\ncode\n````\n";
1144        let chunks = split_markdown_fenced_code_blocks(input);
1145        assert_eq!(chunks.len(), 1);
1146        assert!(matches!(&chunks[0], MarkdownChunk::CodeBlock { .. }));
1147    }
1148
1149    #[test]
1150    fn split_markdown_nested_fence_shorter_doesnt_close() {
1151        // Inner 3-backtick fence shouldn't close a 4-backtick fence
1152        let input = "````\nsome ```inner``` text\n````\n";
1153        let chunks = split_markdown_fenced_code_blocks(input);
1154        assert_eq!(chunks.len(), 1);
1155        assert!(matches!(
1156            &chunks[0],
1157            MarkdownChunk::CodeBlock { code, .. }
1158                if code.contains("```inner```")
1159        ));
1160    }
1161
1162    #[test]
1163    fn split_markdown_no_code_blocks() {
1164        let input = "Just plain markdown\n\n# Heading\n";
1165        let chunks = split_markdown_fenced_code_blocks(input);
1166        assert_eq!(chunks.len(), 1);
1167        assert!(matches!(&chunks[0], MarkdownChunk::Text(t) if t.contains("plain markdown")));
1168    }
1169
1170    #[test]
1171    fn split_markdown_code_block_at_start() {
1172        let input = "```js\nconsole.log('hi')\n```\ntext after";
1173        let chunks = split_markdown_fenced_code_blocks(input);
1174        assert_eq!(chunks.len(), 2);
1175        assert!(matches!(&chunks[0], MarkdownChunk::CodeBlock { .. }));
1176        assert!(matches!(&chunks[1], MarkdownChunk::Text(t) if t.contains("text after")));
1177    }
1178
1179    // ── has_multiple_non_none_styles ────────────────────────────────────
1180    #[test]
1181    fn has_multiple_styles_empty() {
1182        assert!(!has_multiple_non_none_styles(&[]));
1183    }
1184
1185    #[test]
1186    fn has_multiple_styles_all_none() {
1187        let segments = vec![Segment::plain("text1"), Segment::plain("text2")];
1188        assert!(!has_multiple_non_none_styles(&segments));
1189    }
1190
1191    #[test]
1192    fn has_multiple_styles_single_style() {
1193        let style = Style::parse("bold").unwrap();
1194        let segments = vec![
1195            Segment::styled("text1", style.clone()),
1196            Segment::styled("text2", style),
1197        ];
1198        assert!(!has_multiple_non_none_styles(&segments));
1199    }
1200
1201    #[test]
1202    fn has_multiple_styles_two_different() {
1203        let bold = Style::parse("bold").unwrap();
1204        let red = Style::parse("red").unwrap();
1205        let segments = vec![
1206            Segment::styled("text1", bold),
1207            Segment::styled("text2", red),
1208        ];
1209        assert!(has_multiple_non_none_styles(&segments));
1210    }
1211
1212    #[test]
1213    fn has_multiple_styles_ignores_whitespace_only() {
1214        let bold = Style::parse("bold").unwrap();
1215        let red = Style::parse("red").unwrap();
1216        let segments = vec![
1217            Segment::styled("text1", bold),
1218            Segment::styled("   ", red), // whitespace-only, should be ignored
1219        ];
1220        assert!(!has_multiple_non_none_styles(&segments));
1221    }
1222
1223    // ── SpinnerStyle ───────────────────────────────────────────────────
1224    #[test]
1225    fn spinner_line_frames_and_interval() {
1226        let line = SpinnerStyle::Line;
1227        assert_eq!(line.frames().len(), 6);
1228        assert_eq!(line.interval_ms(), 100);
1229    }
1230
1231    #[test]
1232    fn spinner_all_frames_non_empty() {
1233        for style in [SpinnerStyle::Dots, SpinnerStyle::Line, SpinnerStyle::Simple] {
1234            for frame in style.frames() {
1235                assert!(!frame.is_empty(), "empty frame in {:?}", style.frames());
1236            }
1237        }
1238    }
1239
1240    // ── parse_fenced_code_language additional ───────────────────────────
1241    #[test]
1242    fn parse_fenced_code_language_with_info_string() {
1243        // Info string like "rust,no_run" → language is "rust"
1244        assert_eq!(
1245            parse_fenced_code_language("rust,no_run"),
1246            Some("rust".to_string())
1247        );
1248    }
1249
1250    #[test]
1251    fn parse_fenced_code_language_with_space_and_attr() {
1252        // "python attrs" → language is "python"
1253        assert_eq!(
1254            parse_fenced_code_language("python {.highlight}"),
1255            Some("python".to_string())
1256        );
1257    }
1258
1259    #[test]
1260    fn render_markdown_code_fences_highlight_multiple_languages_and_fallback_unknown() {
1261        let segments = capture_markdown_segments(
1262            r#"
1263```rust
1264fn main() { println!("hi"); }
1265```
1266
1267```python
1268def foo():
1269    print("hi")
1270```
1271
1272```javascript
1273function foo() { console.log("hi"); }
1274```
1275
1276```typescript
1277interface Foo { x: number }
1278const foo: Foo = { x: 1 };
1279const greeting = "hi";
1280```
1281
1282```notalanguage
1283some_code_here();
1284```
1285"#,
1286        );
1287
1288        for (language, tokens) in [
1289            ("rust", vec!["fn", "println", "\"hi\""]),
1290            ("python", vec!["def", "print", "\"hi\""]),
1291            ("javascript", vec!["function", "console", "\"hi\""]),
1292            ("typescript", vec!["interface", "const", "\"hi\""]),
1293        ] {
1294            let styles = unique_style_debug_for_tokens(&segments, &tokens);
1295            assert!(
1296                styles.len() > 1,
1297                "expected multiple styles for {language} tokens {tokens:?}, got {styles:?}"
1298            );
1299        }
1300
1301        let captured = segments_text(&segments);
1302        assert!(
1303            captured.contains("some_code_here"),
1304            "expected unknown language fence to still render code, got: {captured:?}"
1305        );
1306    }
1307}