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