Skip to main content

vtcode_core/utils/
ansi.rs

1use crate::config::loader::SyntaxHighlightingConfig;
2use crate::ui::markdown::{
3    MarkdownLine, MarkdownSegment, RenderMarkdownOptions, render_markdown_to_lines_with_options,
4};
5use crate::ui::theme;
6use crate::ui::tui::{
7    InlineHandle, InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
8    InlineSegment, InlineTextStyle, SecurePromptConfig, convert_style as convert_to_inline_style,
9};
10use crate::utils::ansi_capabilities::AnsiCapabilities;
11pub use crate::utils::message_style::MessageStyle;
12use crate::utils::transcript;
13#[cfg(feature = "tui")]
14use ansi_to_tui::IntoText;
15use anstream::{AutoStream, ColorChoice};
16use anstyle::{Ansi256Color, AnsiColor, Color as AnsiColorEnum, Effects, Reset, RgbColor, Style};
17use anyhow::{Result, anyhow};
18#[cfg(feature = "tui")]
19use ratatui::style::{Color as RatColor, Modifier as RatModifier, Style as RatatuiStyle};
20use std::io::{self, Write};
21use std::sync::{Arc, Mutex, OnceLock};
22use url::Url;
23use vtcode_commons::color_policy::{self, ColorOutputPolicySource};
24use vtcode_commons::diff_paths::looks_like_diff_content;
25use vtcode_commons::{parse_editor_target, resolve_editor_path};
26
27static FILE_OPENER: OnceLock<Mutex<vtcode_config::FileOpener>> = OnceLock::new();
28
29pub fn apply_file_opener_config(file_opener: vtcode_config::FileOpener) {
30    let cell = FILE_OPENER.get_or_init(|| Mutex::new(vtcode_config::FileOpener::None));
31    if let Ok(mut guard) = cell.lock() {
32        *guard = file_opener;
33    }
34}
35
36fn current_file_opener() -> vtcode_config::FileOpener {
37    FILE_OPENER
38        .get()
39        .and_then(|cell| cell.lock().ok().map(|guard| *guard))
40        .unwrap_or(vtcode_config::FileOpener::None)
41}
42
43fn make_clickable_target(target: &str) -> Option<String> {
44    let trimmed = target.trim();
45    if trimmed.is_empty() {
46        return None;
47    }
48    if is_remote_link_target(trimmed) {
49        return Some(trimmed.to_string());
50    }
51
52    let opener = current_file_opener();
53    let scheme = opener.scheme()?;
54    let target = parse_editor_target(trimmed)?;
55    let cwd = std::env::current_dir().ok()?;
56    let file_url = Url::from_file_path(resolve_editor_path(target.path(), &cwd)).ok()?;
57    let suffix = target.location_suffix().unwrap_or("");
58    Some(format!(
59        "{scheme}://file{}{}",
60        file_url.as_str().trim_start_matches("file://"),
61        suffix
62    ))
63}
64
65fn should_strip_inline_local_link_underline(target: &str) -> bool {
66    let trimmed = target.trim();
67    if trimmed.is_empty() {
68        return false;
69    }
70    if is_remote_link_target(trimmed) {
71        return false;
72    }
73    match Url::parse(trimmed) {
74        Ok(url) => url.scheme() == "file",
75        Err(_) => true,
76    }
77}
78
79fn is_remote_link_target(target: &str) -> bool {
80    target.starts_with("http://") || target.starts_with("https://")
81}
82
83/// Renderer with deferred output buffering
84pub struct AnsiRenderer {
85    writer: AutoStream<io::Stdout>,
86    buffer: String,
87    color: bool,
88    sink: Option<InlineSink>,
89    last_line_was_empty: bool,
90    highlight_config: SyntaxHighlightingConfig,
91    capabilities: AnsiCapabilities,
92    reasoning_visible: bool,
93    screen_reader_mode: bool,
94    show_diagnostics_in_transcript: bool,
95}
96
97impl AnsiRenderer {
98    /// Create a new renderer for stdout
99    pub fn stdout() -> Self {
100        let mut capabilities = AnsiCapabilities::detect();
101        let policy = color_policy::current_color_output_policy();
102
103        if !policy.enabled {
104            capabilities.no_color = true;
105            capabilities.force_color = false;
106        } else if matches!(
107            policy.source,
108            ColorOutputPolicySource::CliColorAlways | ColorOutputPolicySource::ConfigOverride
109        ) {
110            capabilities.no_color = false;
111            capabilities.force_color = true;
112        }
113
114        let color = capabilities.supports_color();
115        let choice = if !color {
116            ColorChoice::Never
117        } else if matches!(
118            policy.source,
119            ColorOutputPolicySource::CliColorAlways | ColorOutputPolicySource::ConfigOverride
120        ) {
121            ColorChoice::Always
122        } else {
123            ColorChoice::Auto
124        };
125        Self {
126            writer: AutoStream::new(io::stdout(), choice),
127            buffer: String::with_capacity(1024),
128            color,
129            sink: None,
130            last_line_was_empty: false,
131            highlight_config: SyntaxHighlightingConfig::default(),
132            capabilities,
133            reasoning_visible: true,
134            screen_reader_mode: false,
135            show_diagnostics_in_transcript: false,
136        }
137    }
138
139    /// Create a renderer that forwards output to the inline UI session handle
140    pub fn with_inline_ui(
141        handle: InlineHandle,
142        highlight_config: SyntaxHighlightingConfig,
143    ) -> Self {
144        let mut renderer = Self::stdout();
145        renderer.highlight_config = highlight_config.clone();
146        renderer.sink = Some(InlineSink::new(handle, highlight_config));
147        renderer.last_line_was_empty = false;
148        renderer
149    }
150
151    /// Override the syntax highlighting configuration.
152    pub fn set_highlight_config(&mut self, config: SyntaxHighlightingConfig) {
153        if let Some(sink) = &mut self.sink {
154            sink.set_highlight_config(config.clone());
155        }
156        self.highlight_config = config;
157    }
158
159    /// Check if the last line rendered was empty
160    pub fn was_previous_line_empty(&self) -> bool {
161        self.last_line_was_empty
162    }
163
164    fn message_kind(style: MessageStyle) -> InlineMessageKind {
165        style.message_kind()
166    }
167
168    pub fn supports_streaming_markdown(&self) -> bool {
169        self.sink.is_some()
170    }
171
172    /// Determine whether the renderer is connected to the inline UI.
173    ///
174    /// Inline rendering uses the terminal session scrollback, so tool output should
175    /// avoid truncation that would otherwise be applied in compact CLI mode.
176    pub fn prefers_untruncated_output(&self) -> bool {
177        self.sink.is_some()
178    }
179
180    pub fn supports_inline_ui(&self) -> bool {
181        self.sink.is_some()
182    }
183
184    pub fn set_reasoning_visible(&mut self, visible: bool) {
185        self.reasoning_visible = visible;
186    }
187
188    pub fn reasoning_visible(&self) -> bool {
189        self.reasoning_visible
190    }
191
192    pub fn set_screen_reader_mode(&mut self, enabled: bool) {
193        self.screen_reader_mode = enabled;
194    }
195
196    pub fn set_show_diagnostics_in_transcript(&mut self, enabled: bool) {
197        self.show_diagnostics_in_transcript = if cfg!(debug_assertions) {
198            enabled
199        } else {
200            false
201        };
202    }
203
204    /// Set the maximum width for markdown tables. When set, tables wider than
205    /// this will have their columns proportionally scaled and cell text wrapped.
206    pub fn set_table_max_width(&mut self, max_width: Option<usize>) {
207        if let Some(sink) = &mut self.sink {
208            sink.table_max_width = max_width;
209        }
210    }
211
212    fn should_render_style(&self, style: MessageStyle) -> bool {
213        self.reasoning_visible || !matches!(style, MessageStyle::Reasoning)
214    }
215
216    fn is_diagnostic_error_style(style: MessageStyle) -> bool {
217        matches!(style, MessageStyle::Error | MessageStyle::ToolError)
218    }
219
220    fn log_transcript_error(text: &str, style: MessageStyle, suppressed_in_tui: bool) {
221        tracing::error!(
222            target: "vtcode_transcript",
223            style = ?style,
224            suppressed_in_tui,
225            message = %text,
226            "diagnostic error output"
227        );
228    }
229
230    fn indent_for_style(&self, style: MessageStyle) -> &'static str {
231        if self.screen_reader_mode && matches!(style, MessageStyle::Reasoning) {
232            "  [reasoning] "
233        } else {
234            style.indent()
235        }
236    }
237
238    /// Get the terminal's detected ANSI capabilities
239    pub fn capabilities(&self) -> &AnsiCapabilities {
240        &self.capabilities
241    }
242
243    /// Check if unicode should be used for formatting (tables, boxes, etc.)
244    pub fn should_use_unicode_formatting(&self) -> bool {
245        self.capabilities.should_use_unicode_boxes()
246    }
247
248    /// Check if 256-color output is supported
249    pub fn supports_256_colors(&self) -> bool {
250        self.capabilities.supports_256_colors()
251    }
252
253    /// Check if true color (24-bit) output is supported
254    pub fn supports_true_color(&self) -> bool {
255        self.capabilities.supports_true_color()
256    }
257
258    /// Check if should use unicode characters based on terminal capabilities
259    pub fn should_use_unicode(&self) -> bool {
260        self.capabilities.unicode_support
261    }
262
263    pub fn show_list_modal(
264        &mut self,
265        title: &str,
266        lines: Vec<String>,
267        items: Vec<InlineListItem>,
268        selected: Option<InlineListSelection>,
269        search: Option<InlineListSearchConfig>,
270    ) {
271        if let Some(sink) = &self.sink {
272            sink.show_list_modal(title.into(), lines, items, selected, search);
273        }
274    }
275
276    pub fn show_secure_prompt_modal(
277        &mut self,
278        title: &str,
279        lines: Vec<String>,
280        prompt_label: String,
281    ) {
282        if let Some(sink) = &self.sink {
283            sink.show_secure_prompt_modal(title.into(), lines, prompt_label);
284        }
285    }
286
287    pub fn close_modal(&mut self) {
288        if let Some(sink) = &self.sink {
289            sink.close_modal();
290        }
291    }
292
293    pub fn clear_screen(&mut self) {
294        if let Some(sink) = &self.sink {
295            sink.handle.clear_screen();
296        }
297    }
298
299    /// Push text into the buffer
300    pub fn push(&mut self, text: &str) {
301        self.buffer.push_str(text);
302    }
303
304    /// Flush the buffer with the given style
305    pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
306        if !self.should_render_style(style) {
307            self.buffer.clear();
308            return Ok(());
309        }
310        let indent = self.indent_for_style(style);
311        if let Some(sink) = &mut self.sink {
312            // Track if this line is empty
313            self.last_line_was_empty = self.buffer.is_empty() && indent.is_empty();
314            sink.write_line(
315                style.style(),
316                indent,
317                &self.buffer,
318                Self::message_kind(style),
319            )?;
320            self.buffer.clear();
321            return Ok(());
322        }
323        let style = style.style();
324        if self.color {
325            writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
326        } else {
327            writeln!(self.writer, "{}", self.buffer)?;
328        }
329        self.writer.flush()?;
330        transcript::append(&self.buffer);
331        // Track if this line is empty
332        self.last_line_was_empty = self.buffer.is_empty();
333        self.buffer.clear();
334        Ok(())
335    }
336
337    /// Convenience for writing a single line
338    pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
339        if !self.should_render_style(style) {
340            return Ok(());
341        }
342        let suppress_transcript = Self::is_diagnostic_error_style(style)
343            && self.sink.is_some()
344            && !self.show_diagnostics_in_transcript;
345        if Self::is_diagnostic_error_style(style) {
346            Self::log_transcript_error(text, style, self.sink.is_some());
347        }
348        if matches!(style, MessageStyle::Response | MessageStyle::Reasoning) {
349            return self.render_markdown(style, text);
350        }
351        if matches!(style, MessageStyle::Output | MessageStyle::ToolOutput) {
352            let stripped = crate::utils::ansi_parser::strip_ansi(text);
353            self.buffer.clear();
354            if looks_like_diff(&stripped) {
355                self.buffer.push_str("```diff\n");
356            } else {
357                self.buffer.push_str("```\n");
358            }
359            self.buffer.push_str(&stripped);
360            self.buffer.push_str("\n```");
361            let fenced = std::mem::take(&mut self.buffer);
362            return self.render_markdown(style, &fenced);
363        }
364        if matches!(style, MessageStyle::ToolDetail) {
365            if contains_markdown_fence(text) {
366                let stripped = crate::utils::ansi_parser::strip_ansi(text);
367                return self.render_markdown(style, &stripped);
368            }
369            if looks_like_diff(text) {
370                let stripped = crate::utils::ansi_parser::strip_ansi(text);
371                self.buffer.clear();
372                self.buffer.push_str("```diff\n");
373                self.buffer.push_str(&stripped);
374                self.buffer.push_str("\n```");
375                let fenced = std::mem::take(&mut self.buffer);
376                return self.render_markdown(style, &fenced);
377            }
378        }
379        let indent = style.indent();
380        let dont_split = matches!(style, MessageStyle::Tool | MessageStyle::ToolDetail);
381
382        if let Some(sink) = &mut self.sink {
383            sink.write_multiline_with_transcript(
384                style.style(),
385                indent,
386                text,
387                Self::message_kind(style),
388                !suppress_transcript,
389            )?;
390            return Ok(());
391        }
392
393        if text.contains('\n') && !dont_split {
394            for line in text.lines() {
395                self.buffer.clear();
396                if !indent.is_empty() && !line.is_empty() {
397                    self.buffer.push_str(indent);
398                }
399                self.buffer.push_str(line);
400                self.flush(style)?;
401            }
402            Ok(())
403        } else {
404            self.buffer.clear();
405            if !indent.is_empty() && !text.is_empty() {
406                self.buffer.push_str(indent);
407            }
408            self.buffer.push_str(text);
409            self.flush(style)
410        }
411    }
412
413    /// Write a continuation line that joins an existing Pty block.
414    ///
415    /// Sends the text as `InlineMessageKind::Pty` through
416    /// `write_multiline_with_transcript` so the TUI reflow renders it
417    /// with the same 2-space block prefix and styling as the PTY output.
418    pub fn pty_continuation_line(&mut self, text: &str) -> Result<()> {
419        let style = MessageStyle::ToolOutput;
420        let indent = style.indent();
421        let kind = Self::message_kind(style);
422        if let Some(sink) = &mut self.sink {
423            sink.write_multiline_with_transcript(style.style(), indent, text, kind, true)?;
424            return Ok(());
425        }
426        self.buffer.clear();
427        if !indent.is_empty() && !text.is_empty() {
428            self.buffer.push_str(indent);
429        }
430        self.buffer.push_str(text);
431        self.flush(style)
432    }
433
434    /// Write a URL as a full, clickable line using OSC 8 hyperlinks.
435    ///
436    /// The URL is rendered on its own line so that terminal emulators can
437    /// detect and activate it for click-to-open behaviour.
438    pub fn hyperlink_line(&mut self, style: MessageStyle, url: &str) -> Result<()> {
439        if !self.should_render_style(style) {
440            return Ok(());
441        }
442        let indent = style.indent();
443        if let Some(sink) = &mut self.sink {
444            let linked = format!(
445                "{}{}{}",
446                vtcode_commons::ansi_codes::hyperlink_open(url),
447                url,
448                vtcode_commons::ansi_codes::hyperlink_close(),
449            );
450            sink.write_multiline_with_transcript(
451                style.style(),
452                indent,
453                &linked,
454                Self::message_kind(style),
455                true,
456            )?;
457            self.last_line_was_empty = false;
458            return Ok(());
459        }
460        self.buffer.clear();
461        if !indent.is_empty() {
462            self.buffer.push_str(indent);
463        }
464        self.buffer
465            .push_str(&vtcode_commons::ansi_codes::hyperlink_open(url));
466        self.buffer.push_str(url);
467        self.buffer
468            .push_str(&vtcode_commons::ansi_codes::hyperlink_close());
469        let ansi_style = style.style();
470        if self.color {
471            writeln!(self.writer, "{ansi_style}{}{Reset}", self.buffer)?;
472        } else {
473            writeln!(self.writer, "{}", self.buffer)?;
474        }
475        self.writer.flush()?;
476        transcript::append(url);
477        self.last_line_was_empty = false;
478        self.buffer.clear();
479        Ok(())
480    }
481
482    /// Append a large pasted user message as a placeholder in inline UI.
483    pub fn append_paste_placeholder(&mut self, message: &str, line_count: usize) -> Result<()> {
484        if let Some(sink) = &self.sink {
485            sink.handle.append_pasted_message(
486                InlineMessageKind::User,
487                message.to_string(),
488                line_count,
489            );
490            transcript::append(message);
491            self.last_line_was_empty = message.trim().is_empty();
492            return Ok(());
493        }
494        self.line(MessageStyle::User, message)
495    }
496
497    /// Write styled text without a trailing newline
498    pub fn inline_with_style(&mut self, style: MessageStyle, text: &str) -> Result<()> {
499        if !self.should_render_style(style) {
500            return Ok(());
501        }
502        if let Some(sink) = &mut self.sink {
503            sink.write_inline(style.style(), text, Self::message_kind(style));
504            return Ok(());
505        }
506        let ansi_style = style.style();
507        if self.color {
508            write!(self.writer, "{ansi_style}{}{Reset}", text)?;
509        } else {
510            write!(self.writer, "{}", text)?;
511        }
512        self.writer.flush()?;
513        Ok(())
514    }
515
516    /// Write a line with an explicit style
517    pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
518        self.line_with_override_style(MessageStyle::Info, style, text)
519    }
520
521    /// Write a line with a custom style while preserving the logical message kind.
522    pub fn line_with_override_style(
523        &mut self,
524        fallback: MessageStyle,
525        style: Style,
526        text: &str,
527    ) -> Result<()> {
528        if !self.should_render_style(fallback) {
529            return Ok(());
530        }
531        let suppress_transcript = Self::is_diagnostic_error_style(fallback)
532            && self.sink.is_some()
533            && !self.show_diagnostics_in_transcript;
534        if Self::is_diagnostic_error_style(fallback) {
535            Self::log_transcript_error(text, fallback, self.sink.is_some());
536        }
537        let kind = Self::message_kind(fallback);
538        let indent = self.indent_for_style(fallback);
539        if let Some(sink) = &mut self.sink {
540            sink.write_multiline_with_transcript(style, indent, text, kind, !suppress_transcript)?;
541            self.last_line_was_empty = text.trim().is_empty();
542            return Ok(());
543        }
544        let mut combined;
545        let display = if !indent.is_empty() && !text.is_empty() {
546            combined = String::with_capacity(indent.len() + text.len());
547            combined.push_str(indent);
548            combined.push_str(text);
549            combined.as_str()
550        } else {
551            text
552        };
553        if self.color {
554            writeln!(self.writer, "{style}{}{Reset}", display)?;
555        } else {
556            writeln!(self.writer, "{}", display)?;
557        }
558        self.writer.flush()?;
559        transcript::append(display);
560        self.last_line_was_empty = text.trim().is_empty();
561        Ok(())
562    }
563
564    /// Write an empty line only if the previous line was not empty
565    pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
566        if !self.was_previous_line_empty() {
567            self.line(style, "")
568        } else {
569            Ok(())
570        }
571    }
572
573    /// Write a raw line without styling
574    pub fn raw_line(&mut self, text: &str) -> Result<()> {
575        writeln!(self.writer, "{}", text)?;
576        self.writer.flush()?;
577        transcript::append(text);
578        Ok(())
579    }
580
581    /// Render markdown content with proper syntax highlighting and indentation normalization.
582    /// Use this for tool output that contains markdown code blocks.
583    pub fn render_markdown_output(&mut self, style: MessageStyle, text: &str) -> Result<()> {
584        self.render_markdown(style, text)
585    }
586
587    fn render_markdown(&mut self, style: MessageStyle, text: &str) -> Result<()> {
588        if !self.should_render_style(style) {
589            return Ok(());
590        }
591        let styles = theme::active_styles();
592        let base_style = style.style();
593        let indent = self.indent_for_style(style);
594        let preserve_code_indentation = matches!(
595            style,
596            MessageStyle::Output
597                | MessageStyle::ToolOutput
598                | MessageStyle::ToolDetail
599                | MessageStyle::Response
600                | MessageStyle::Reasoning
601        );
602
603        // Strip ANSI codes from agent response to prevent interference with markdown rendering
604        let text_storage;
605        let text = if matches!(style, MessageStyle::Response) {
606            text_storage = crate::utils::ansi_parser::strip_ansi(text);
607            &text_storage
608        } else {
609            text
610        };
611
612        if let Some(sink) = &mut self.sink {
613            // Read terminal width fresh so tables adapt to resizes.
614            if let Ok((w, _)) = crossterm::terminal::size() {
615                sink.table_max_width = Some(w as usize);
616            }
617            let last_empty = sink.write_markdown(
618                text,
619                indent,
620                base_style,
621                Self::message_kind(style),
622                preserve_code_indentation,
623            )?;
624            self.last_line_was_empty = last_empty;
625            return Ok(());
626        }
627        let highlight_cfg = if self.highlight_config.enabled {
628            Some(&self.highlight_config)
629        } else {
630            None
631        };
632        let mut lines = render_markdown_to_lines_with_options(
633            text,
634            base_style,
635            &styles,
636            highlight_cfg,
637            RenderMarkdownOptions {
638                preserve_code_indentation,
639                disable_code_block_table_reparse: false,
640                table_max_width: None,
641            },
642        );
643        if lines.is_empty() {
644            lines.push(MarkdownLine::default());
645        }
646
647        // Pre-allocate buffer for markdown output if rendering many lines
648        if lines.len() > 10 {
649            self.buffer.reserve(lines.len() * 80);
650        }
651
652        for line in lines {
653            self.write_markdown_line(style, indent, line)?;
654        }
655        Ok(())
656    }
657
658    pub fn render_token_delta(&mut self, delta: &str) -> Result<()> {
659        self.inline_with_style(MessageStyle::Response, delta)
660    }
661
662    pub fn render_reasoning_delta(&mut self, delta: &str) -> Result<()> {
663        self.inline_with_style(MessageStyle::Reasoning, delta)
664    }
665
666    pub fn stream_markdown_response(
667        &mut self,
668        text: &str,
669        previous_line_count: usize,
670    ) -> Result<usize> {
671        // Strip ANSI codes from agent response to prevent interference with markdown rendering
672        let text = crate::utils::ansi_parser::strip_ansi(text);
673        let text = &text;
674
675        let styles = theme::active_styles();
676        let style = MessageStyle::Response;
677        let base_style = style.style();
678        let indent = style.indent();
679        if let Some(sink) = &mut self.sink {
680            // Read terminal width fresh so tables adapt to resizes.
681            if let Ok((w, _)) = crossterm::terminal::size() {
682                sink.table_max_width = Some(w as usize);
683            }
684            let (prepared, plain_lines, last_empty) =
685                sink.prepare_markdown_lines(text, indent, base_style, true, true);
686            let line_count = prepared.len();
687            sink.replace_inline_lines(
688                previous_line_count,
689                prepared,
690                &plain_lines,
691                Self::message_kind(style),
692            );
693            self.last_line_was_empty = last_empty;
694            return Ok(line_count);
695        }
696
697        let highlight_cfg = if self.highlight_config.enabled {
698            Some(&self.highlight_config)
699        } else {
700            None
701        };
702        let mut lines = render_markdown_to_lines_with_options(
703            text,
704            base_style,
705            &styles,
706            highlight_cfg,
707            RenderMarkdownOptions::default(),
708        );
709        if lines.is_empty() {
710            lines.push(MarkdownLine::default());
711        }
712
713        Err(anyhow!("stream_markdown_response requires an inline sink"))
714    }
715
716    pub fn render_reasoning_stream(
717        &mut self,
718        lines: &[String],
719        previous_line_count: &mut usize,
720    ) -> Result<()> {
721        if !self.reasoning_visible {
722            *previous_line_count = 0;
723            return Ok(());
724        }
725        if lines.is_empty() {
726            return Ok(());
727        }
728
729        let style = MessageStyle::Reasoning;
730        let indent = self.indent_for_style(style);
731        let kind = Self::message_kind(style);
732        let base_style = style.style();
733
734        if let Some(sink) = &mut self.sink {
735            let fallback = sink.resolve_fallback_style(base_style);
736            let fallback_arc = Arc::new(fallback.clone());
737            let mut prepared: Vec<Vec<InlineSegment>> = Vec::with_capacity(lines.len());
738            let mut plain_lines: Vec<String> = Vec::with_capacity(lines.len());
739
740            for (line_idx, line) in lines.iter().enumerate() {
741                let (converted, plain) = sink.convert_plain_lines(line, &fallback);
742                for (segment_idx, (mut segments, mut plain_line)) in
743                    converted.into_iter().zip(plain.into_iter()).enumerate()
744                {
745                    // Add "Thinking:" prefix to the very first line only
746                    if *previous_line_count == 0
747                        && line_idx == 0
748                        && segment_idx == 0
749                        && !plain_line.trim().is_empty()
750                    {
751                        segments.insert(
752                            0,
753                            InlineSegment {
754                                text: "Thinking: ".to_owned(),
755                                style: Arc::clone(&fallback_arc),
756                            },
757                        );
758                        plain_line.insert_str(0, "Thinking: ");
759                    }
760
761                    if !indent.is_empty() && !plain_line.is_empty() {
762                        segments.insert(
763                            0,
764                            InlineSegment {
765                                text: indent.to_owned(),
766                                style: Arc::clone(&fallback_arc),
767                            },
768                        );
769                        plain_line.insert_str(0, indent);
770                    }
771                    prepared.push(segments);
772                    plain_lines.push(plain_line);
773                }
774            }
775
776            if *previous_line_count == 0 {
777                for (segments, plain_line) in prepared.iter().zip(plain_lines.iter()) {
778                    if segments.is_empty() {
779                        sink.handle.append_line(kind, Vec::new());
780                    } else {
781                        sink.handle.append_line(kind, segments.clone());
782                    }
783                    transcript::append(plain_line);
784                }
785            } else {
786                sink.replace_inline_lines(
787                    *previous_line_count,
788                    prepared.clone(),
789                    &plain_lines,
790                    kind,
791                );
792            }
793
794            *previous_line_count = plain_lines.len();
795            self.last_line_was_empty = plain_lines
796                .last()
797                .map(|line| line.trim().is_empty())
798                .unwrap_or(true);
799
800            return Ok(());
801        }
802
803        if *previous_line_count == 0 {
804            for (idx, line) in lines.iter().enumerate() {
805                if idx == 0 && !line.trim().is_empty() {
806                    // Prepend "Thinking:" to first line
807                    self.buffer.clear();
808                    self.buffer.push_str("Thinking: ");
809                    self.buffer.push_str(line);
810                    let prefixed = std::mem::take(&mut self.buffer);
811                    self.line(style, &prefixed)?;
812                } else {
813                    self.line(style, line)?;
814                }
815            }
816        } else if let Some(last) = lines.last() {
817            self.line(style, last)?;
818        }
819
820        *previous_line_count = lines.len();
821        Ok(())
822    }
823
824    fn write_markdown_line(
825        &mut self,
826        style: MessageStyle,
827        indent: &str,
828        mut line: MarkdownLine,
829    ) -> Result<()> {
830        if !indent.is_empty() && !line.segments.is_empty() {
831            line.segments.insert(
832                0,
833                MarkdownSegment {
834                    style: style.style(),
835                    text: indent.to_string(),
836                    link_target: None,
837                },
838            );
839        }
840
841        if let Some(sink) = &mut self.sink {
842            sink.write_segments(&line.segments, Self::message_kind(style))?;
843            self.last_line_was_empty = line.is_empty();
844            return Ok(());
845        }
846
847        let mut plain = String::new();
848        if self.color {
849            for segment in &line.segments {
850                let clickable_target = segment
851                    .link_target
852                    .as_deref()
853                    .and_then(make_clickable_target);
854                if let Some(target) = clickable_target.as_deref() {
855                    write!(self.writer, "\u{1b}]8;;{target}\u{1b}\\")?;
856                }
857                write!(
858                    self.writer,
859                    "{style}{}{Reset}",
860                    segment.text,
861                    style = segment.style
862                )?;
863                if clickable_target.is_some() {
864                    write!(self.writer, "\u{1b}]8;;\u{1b}\\")?;
865                }
866                plain.push_str(&segment.text);
867            }
868            writeln!(self.writer)?;
869        } else {
870            for segment in &line.segments {
871                let clickable_target = segment
872                    .link_target
873                    .as_deref()
874                    .and_then(make_clickable_target);
875                if let Some(target) = clickable_target.as_deref() {
876                    write!(self.writer, "\u{1b}]8;;{target}\u{1b}\\")?;
877                }
878                write!(self.writer, "{}", segment.text)?;
879                if clickable_target.is_some() {
880                    write!(self.writer, "\u{1b}]8;;\u{1b}\\")?;
881                }
882                plain.push_str(&segment.text);
883            }
884            writeln!(self.writer)?;
885        }
886        self.writer.flush()?;
887        transcript::append(&plain);
888        self.last_line_was_empty = plain.trim().is_empty();
889        Ok(())
890    }
891}
892
893fn contains_markdown_fence(text: &str) -> bool {
894    text.contains("```") || text.contains("~~~")
895}
896
897fn looks_like_diff(text: &str) -> bool {
898    looks_like_diff_content(text)
899}
900
901const INLINE_JSON_COLLAPSE_BYTES: usize = 50_000;
902const INLINE_JSON_COLLAPSE_LINES: usize = 200;
903
904struct LargeJsonPayload<'a> {
905    text: &'a str,
906    line_count: usize,
907}
908
909struct InlineSink {
910    handle: InlineHandle,
911    highlight_config: SyntaxHighlightingConfig,
912    table_max_width: Option<usize>,
913}
914
915impl InlineSink {
916    fn should_record_transcript(kind: InlineMessageKind) -> bool {
917        kind != InlineMessageKind::Pty
918    }
919
920    fn count_lines(text: &str) -> usize {
921        if text.is_empty() {
922            0
923        } else {
924            text.as_bytes().iter().filter(|&&b| b == b'\n').count() + 1
925        }
926    }
927
928    fn unwrap_single_fenced_block(text: &str) -> Option<&str> {
929        let trimmed = text.trim_end();
930        if !trimmed.starts_with("```") || !trimmed.ends_with("```") {
931            return None;
932        }
933
934        let first_newline = trimmed.find('\n')?;
935        let last_fence = trimmed.rfind("\n```")?;
936        if last_fence <= first_newline {
937            return None;
938        }
939
940        Some(&trimmed[first_newline + 1..last_fence])
941    }
942
943    fn detect_large_json_payload<'a>(
944        kind: InlineMessageKind,
945        text: &'a str,
946    ) -> Option<LargeJsonPayload<'a>> {
947        if !matches!(kind, InlineMessageKind::Tool | InlineMessageKind::Pty) {
948            return None;
949        }
950
951        let candidate = Self::unwrap_single_fenced_block(text).unwrap_or(text);
952        let trimmed = candidate.trim();
953        if trimmed.is_empty() {
954            return None;
955        }
956
957        if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
958            return None;
959        }
960        if !(trimmed.ends_with('}') || trimmed.ends_with(']')) {
961            return None;
962        }
963
964        let line_count = Self::count_lines(candidate);
965        if candidate.len() < INLINE_JSON_COLLAPSE_BYTES && line_count < INLINE_JSON_COLLAPSE_LINES {
966            return None;
967        }
968
969        Some(LargeJsonPayload {
970            text: candidate,
971            line_count,
972        })
973    }
974
975    fn indent_multiline(text: &str, indent: &str) -> String {
976        if indent.is_empty() {
977            return text.to_string();
978        }
979
980        let mut out = String::with_capacity(text.len() + indent.len() * 4);
981        for (idx, line) in text.split('\n').enumerate() {
982            if idx > 0 {
983                out.push('\n');
984            }
985            out.push_str(indent);
986            out.push_str(line);
987        }
988        out
989    }
990
991    fn emit_large_json_payload(
992        &mut self,
993        payload: LargeJsonPayload<'_>,
994        indent: &str,
995        kind: InlineMessageKind,
996        record_transcript: bool,
997    ) -> Result<()> {
998        let full_text = if !indent.is_empty() {
999            Self::indent_multiline(payload.text, indent)
1000        } else {
1001            payload.text.to_string()
1002        };
1003        if record_transcript {
1004            transcript::append(&full_text);
1005        }
1006        self.handle
1007            .append_pasted_message(kind, full_text, payload.line_count);
1008        Ok(())
1009    }
1010    #[cfg(feature = "tui")]
1011    fn ansi_from_ratatui_color(color: RatColor) -> Option<AnsiColorEnum> {
1012        match color {
1013            RatColor::Reset => None,
1014            RatColor::Black => Some(AnsiColorEnum::Ansi(AnsiColor::Black)),
1015            RatColor::Red => Some(AnsiColorEnum::Ansi(AnsiColor::Red)),
1016            RatColor::Green => Some(AnsiColorEnum::Ansi(AnsiColor::Green)),
1017            RatColor::Yellow => Some(AnsiColorEnum::Ansi(AnsiColor::Yellow)),
1018            RatColor::Blue => Some(AnsiColorEnum::Ansi(AnsiColor::Blue)),
1019            RatColor::Magenta => Some(AnsiColorEnum::Ansi(AnsiColor::Magenta)),
1020            RatColor::Cyan => Some(AnsiColorEnum::Ansi(AnsiColor::Cyan)),
1021            RatColor::Gray => Some(AnsiColorEnum::Rgb(RgbColor(0x88, 0x88, 0x88))),
1022            RatColor::DarkGray => Some(AnsiColorEnum::Rgb(RgbColor(0x66, 0x66, 0x66))),
1023            RatColor::LightRed => Some(AnsiColorEnum::Ansi(AnsiColor::Red)),
1024            RatColor::LightGreen => Some(AnsiColorEnum::Ansi(AnsiColor::Green)),
1025            RatColor::LightYellow => Some(AnsiColorEnum::Ansi(AnsiColor::Yellow)),
1026            RatColor::LightBlue => Some(AnsiColorEnum::Ansi(AnsiColor::Blue)),
1027            RatColor::LightMagenta => Some(AnsiColorEnum::Ansi(AnsiColor::Magenta)),
1028            RatColor::LightCyan => Some(AnsiColorEnum::Ansi(AnsiColor::Cyan)),
1029            RatColor::White => Some(AnsiColorEnum::Ansi(AnsiColor::White)),
1030            RatColor::Rgb(r, g, b) => Some(AnsiColorEnum::Rgb(RgbColor(r, g, b))),
1031            RatColor::Indexed(value) => Some(AnsiColorEnum::Ansi256(Ansi256Color(value))),
1032        }
1033    }
1034
1035    #[cfg(feature = "tui")]
1036    fn inline_style_from_ratatui(
1037        &self,
1038        style: RatatuiStyle,
1039        fallback: &InlineTextStyle,
1040    ) -> InlineTextStyle {
1041        let mut resolved = fallback.clone();
1042        // Keep transcript segments theme-dynamic by default. Only persist a
1043        // foreground color when ANSI parsing produced a color different from the
1044        // logical fallback for this message kind.
1045        resolved.color = None;
1046        if let Some(color) = style.fg.and_then(Self::ansi_from_ratatui_color)
1047            && Some(color) != fallback.color
1048        {
1049            resolved.color = Some(color);
1050        }
1051
1052        let added = style.add_modifier;
1053
1054        if added.contains(RatModifier::BOLD) {
1055            resolved.effects |= Effects::BOLD;
1056        }
1057
1058        if added.contains(RatModifier::ITALIC) {
1059            resolved.effects |= Effects::ITALIC;
1060        }
1061
1062        resolved
1063    }
1064
1065    fn prepare_markdown_lines(
1066        &self,
1067        text: &str,
1068        indent: &str,
1069        base_style: Style,
1070        preserve_blank_lines: bool,
1071        preserve_code_indentation: bool,
1072    ) -> (Vec<Vec<InlineSegment>>, Vec<String>, bool) {
1073        let fallback = self.resolve_fallback_style(base_style);
1074        let fallback_arc = Arc::new(fallback.clone());
1075        let theme_styles = theme::active_styles();
1076        let highlight_cfg = self
1077            .highlight_config
1078            .enabled
1079            .then_some(&self.highlight_config);
1080        let mut rendered = render_markdown_to_lines_with_options(
1081            text,
1082            base_style,
1083            &theme_styles,
1084            highlight_cfg,
1085            RenderMarkdownOptions {
1086                preserve_code_indentation,
1087                disable_code_block_table_reparse: false,
1088                table_max_width: self.table_max_width,
1089            },
1090        );
1091        if preserve_blank_lines {
1092            let mut cleaned = Vec::with_capacity(rendered.len());
1093            let mut last_blank = false;
1094            for line in rendered {
1095                let is_blank = line.is_empty();
1096                if is_blank {
1097                    if last_blank {
1098                        continue;
1099                    }
1100                    last_blank = true;
1101                } else {
1102                    last_blank = false;
1103                }
1104                cleaned.push(line);
1105            }
1106            rendered = cleaned;
1107        } else {
1108            // TUI space is constrained; drop blank lines to keep transcripts compact.
1109            rendered.retain(|line| !line.is_empty());
1110        }
1111        if rendered.is_empty() {
1112            rendered.push(MarkdownLine::default());
1113        }
1114
1115        let mut prepared = Vec::with_capacity(rendered.len());
1116        let mut plain = Vec::with_capacity(rendered.len());
1117
1118        for line in rendered {
1119            // Pre-allocate segments and plain text with estimated capacity
1120            let mut segments = Vec::with_capacity(line.segments.len());
1121            let mut plain_line = String::with_capacity(120);
1122
1123            let has_content = line
1124                .segments
1125                .iter()
1126                .any(|segment| !segment.text.trim().is_empty());
1127
1128            if !indent.is_empty() && has_content {
1129                segments.push(InlineSegment {
1130                    text: indent.to_string(),
1131                    style: Arc::clone(&fallback_arc),
1132                });
1133                plain_line.push_str(indent);
1134            }
1135
1136            for segment in line.segments {
1137                if segment.text.is_empty() {
1138                    continue;
1139                }
1140                let mut converted = convert_to_inline_style(segment.style);
1141                // Plain file-like markdown tokens are styled as underlined during markdown parsing.
1142                // In inline UI, actual clickability is decided later from resolved transcript links.
1143                // Strip local-link underlines here to avoid showing non-clickable path text as links.
1144                if segment
1145                    .link_target
1146                    .as_deref()
1147                    .is_some_and(should_strip_inline_local_link_underline)
1148                {
1149                    converted.effects = converted.effects.remove(Effects::UNDERLINE);
1150                }
1151                let mut inline_style = fallback.clone();
1152                inline_style.color = None;
1153                if let Some(color) = converted.color
1154                    && Some(color) != fallback.color
1155                {
1156                    inline_style.color = Some(color);
1157                }
1158                if let Some(bg) = converted.bg_color {
1159                    inline_style.bg_color = Some(bg);
1160                }
1161                inline_style.effects = converted.effects | fallback.effects;
1162                plain_line.push_str(&segment.text);
1163                segments.push(InlineSegment {
1164                    text: segment.text,
1165                    style: Arc::new(inline_style),
1166                });
1167            }
1168
1169            prepared.push(segments);
1170            plain.push(plain_line);
1171        }
1172
1173        if prepared.is_empty() {
1174            prepared.push(Vec::new());
1175            plain.push(String::new());
1176        }
1177
1178        let last_empty = plain
1179            .last()
1180            .map(|line| line.trim().is_empty())
1181            .unwrap_or(true);
1182
1183        (prepared, plain, last_empty)
1184    }
1185
1186    fn write_markdown(
1187        &mut self,
1188        text: &str,
1189        indent: &str,
1190        base_style: Style,
1191        kind: InlineMessageKind,
1192        preserve_code_indentation: bool,
1193    ) -> Result<bool> {
1194        let record_transcript = Self::should_record_transcript(kind);
1195        if let Some(payload) = Self::detect_large_json_payload(kind, text) {
1196            self.emit_large_json_payload(payload, indent, kind, record_transcript)?;
1197            return Ok(false);
1198        }
1199        let (prepared, plain, last_empty) =
1200            self.prepare_markdown_lines(text, indent, base_style, true, preserve_code_indentation);
1201        for (segments, line) in prepared.into_iter().zip(plain.iter()) {
1202            if segments.is_empty() {
1203                self.handle.append_line(kind, Vec::new());
1204            } else {
1205                self.handle.append_line(kind, segments);
1206            }
1207            if record_transcript {
1208                transcript::append(line);
1209            }
1210        }
1211        Ok(last_empty)
1212    }
1213
1214    fn replace_inline_lines(
1215        &mut self,
1216        count: usize,
1217        lines: Vec<Vec<InlineSegment>>,
1218        plain: &[String],
1219        kind: InlineMessageKind,
1220    ) {
1221        self.handle.replace_last(count, kind, lines);
1222        if Self::should_record_transcript(kind) {
1223            transcript::replace_last(count, plain);
1224        }
1225    }
1226
1227    fn new(handle: InlineHandle, highlight_config: SyntaxHighlightingConfig) -> Self {
1228        Self {
1229            handle,
1230            highlight_config,
1231            table_max_width: None,
1232        }
1233    }
1234
1235    fn set_highlight_config(&mut self, highlight_config: SyntaxHighlightingConfig) {
1236        self.highlight_config = highlight_config;
1237    }
1238
1239    fn show_list_modal(
1240        &self,
1241        title: String,
1242        lines: Vec<String>,
1243        items: Vec<InlineListItem>,
1244        selected: Option<InlineListSelection>,
1245        search: Option<InlineListSearchConfig>,
1246    ) {
1247        self.handle
1248            .show_list_modal(title, lines, items, selected, search);
1249    }
1250
1251    fn show_secure_prompt_modal(&self, title: String, lines: Vec<String>, prompt_label: String) {
1252        self.handle.show_modal(
1253            title,
1254            lines,
1255            Some(SecurePromptConfig {
1256                label: prompt_label,
1257                placeholder: None,
1258                mask_input: true,
1259            }),
1260        );
1261    }
1262
1263    fn close_modal(&self) {
1264        self.handle.close_modal();
1265    }
1266
1267    #[expect(dead_code)]
1268    fn clear_screen(&self) {
1269        self.handle.clear_screen();
1270    }
1271
1272    fn resolve_fallback_style(&self, style: Style) -> InlineTextStyle {
1273        let mut text_style = convert_to_inline_style(style);
1274        if text_style.color.is_none() {
1275            let active = theme::active_styles();
1276            text_style = text_style.merge_color(Some(active.foreground));
1277        }
1278        text_style
1279    }
1280
1281    fn style_to_segment(&self, style: Style, text: &str) -> InlineSegment {
1282        let text_style = self.resolve_fallback_style(style);
1283        InlineSegment {
1284            text: text.to_string(),
1285            style: Arc::new(text_style),
1286        }
1287    }
1288
1289    fn convert_plain_lines(
1290        &self,
1291        text: &str,
1292        fallback: &InlineTextStyle,
1293    ) -> (Vec<Vec<InlineSegment>>, Vec<String>) {
1294        let fallback_arc = Arc::new(fallback.clone());
1295        if text.is_empty() {
1296            return (vec![Vec::new()], vec![String::new()]);
1297        }
1298
1299        let had_trailing_newline = text.ends_with('\n');
1300        let line_count_estimate = Self::count_lines(text).max(1);
1301
1302        #[cfg(feature = "tui")]
1303        if let Ok(parsed) = text.as_bytes().into_text() {
1304            let mut converted_lines =
1305                Vec::with_capacity(parsed.lines.len().max(line_count_estimate));
1306            let mut plain_lines = Vec::with_capacity(parsed.lines.len().max(line_count_estimate));
1307            let base_style = RatatuiStyle::default().patch(parsed.style);
1308
1309            for line in &parsed.lines {
1310                // Pre-allocate segments based on typical span count (3-5 spans per line)
1311                let mut segments = Vec::with_capacity(line.spans.len());
1312                let mut plain_line = String::with_capacity(80);
1313                let line_style = base_style.patch(line.style);
1314
1315                for span in &line.spans {
1316                    // Use as_ref() to avoid unnecessary clone - Cow is already optimized
1317                    let content: &str = &span.content;
1318                    if content.is_empty() {
1319                        continue;
1320                    }
1321
1322                    let span_style = line_style.patch(span.style);
1323                    let inline_style = self.inline_style_from_ratatui(span_style, fallback);
1324                    plain_line.push_str(content);
1325                    segments.push(InlineSegment {
1326                        text: content.to_string(),
1327                        style: Arc::new(inline_style),
1328                    });
1329                }
1330
1331                converted_lines.push(segments);
1332                plain_lines.push(plain_line);
1333            }
1334
1335            let needs_placeholder_line = if converted_lines.is_empty() {
1336                true
1337            } else {
1338                had_trailing_newline && plain_lines.last().is_none_or(|line| !line.is_empty())
1339            };
1340            if needs_placeholder_line {
1341                converted_lines.push(Vec::new());
1342                plain_lines.push(String::new());
1343            }
1344
1345            return (converted_lines, plain_lines);
1346        }
1347
1348        // Fallback: Process as plain text without ANSI parsing
1349        let line_count_estimate = Self::count_lines(text).max(1);
1350        let mut converted_lines = Vec::with_capacity(line_count_estimate);
1351        let mut plain_lines = Vec::with_capacity(line_count_estimate);
1352
1353        for line in text.split('\n') {
1354            let mut segments = Vec::with_capacity(1);
1355            if !line.is_empty() {
1356                let owned = line.to_string();
1357                segments.push(InlineSegment {
1358                    text: owned.clone(),
1359                    style: Arc::clone(&fallback_arc),
1360                });
1361                converted_lines.push(segments);
1362                plain_lines.push(owned);
1363            } else {
1364                converted_lines.push(segments);
1365                plain_lines.push(String::new());
1366            }
1367        }
1368
1369        if had_trailing_newline {
1370            converted_lines.push(Vec::new());
1371            plain_lines.push(String::new());
1372        }
1373
1374        if converted_lines.is_empty() {
1375            converted_lines.push(Vec::new());
1376            plain_lines.push(String::new());
1377        }
1378
1379        (converted_lines, plain_lines)
1380    }
1381
1382    fn write_multiline(
1383        &mut self,
1384        style: Style,
1385        indent: &str,
1386        text: &str,
1387        kind: InlineMessageKind,
1388    ) -> Result<()> {
1389        self.write_multiline_with_transcript(
1390            style,
1391            indent,
1392            text,
1393            kind,
1394            Self::should_record_transcript(kind),
1395        )
1396    }
1397
1398    fn write_multiline_with_transcript(
1399        &mut self,
1400        style: Style,
1401        indent: &str,
1402        text: &str,
1403        kind: InlineMessageKind,
1404        record_transcript: bool,
1405    ) -> Result<()> {
1406        let text_storage;
1407        let text = if kind == InlineMessageKind::Agent {
1408            text_storage = crate::utils::ansi_parser::strip_ansi(text);
1409            &text_storage
1410        } else {
1411            text
1412        };
1413        let record_transcript = record_transcript && Self::should_record_transcript(kind);
1414
1415        if text.is_empty() {
1416            self.handle.append_line(kind, Vec::new());
1417            return Ok(());
1418        }
1419
1420        if let Some(payload) = Self::detect_large_json_payload(kind, text) {
1421            self.emit_large_json_payload(payload, indent, kind, record_transcript)?;
1422            return Ok(());
1423        }
1424
1425        let fallback = self.resolve_fallback_style(style);
1426        let fallback_arc = Arc::new(fallback.clone());
1427        let (converted_lines, plain_lines) = self.convert_plain_lines(text, &fallback);
1428
1429        // Combine multiple lines into a single append for User and Tool to avoid
1430        // creating a separate inline entry for each line. This prevents the
1431        // UI from showing a separate line per original line of tool output.
1432        if kind == InlineMessageKind::User || kind == InlineMessageKind::Tool {
1433            let total_plain_len: usize = plain_lines.iter().map(|p| p.len()).sum();
1434            let mut combined_segments = Vec::with_capacity(converted_lines.len());
1435            let mut combined_plain = String::with_capacity(total_plain_len);
1436
1437            for (mut segments, plain) in converted_lines.into_iter().zip(plain_lines.into_iter()) {
1438                if !combined_segments.is_empty() {
1439                    combined_segments.push(InlineSegment {
1440                        text: "\n".to_owned(),
1441                        style: Arc::clone(&fallback_arc),
1442                    });
1443                    combined_plain.push('\n');
1444                }
1445
1446                if !indent.is_empty() && !plain.is_empty() {
1447                    segments.insert(
1448                        0,
1449                        InlineSegment {
1450                            text: indent.to_string(),
1451                            style: Arc::clone(&fallback_arc),
1452                        },
1453                    );
1454                    combined_plain.insert_str(0, indent);
1455                } else if !indent.is_empty() && plain.is_empty() {
1456                    segments.insert(
1457                        0,
1458                        InlineSegment {
1459                            text: indent.to_string(),
1460                            style: Arc::clone(&fallback_arc),
1461                        },
1462                    );
1463                }
1464
1465                combined_segments.extend(segments);
1466                combined_plain.push_str(&plain);
1467            }
1468
1469            self.handle.append_line(kind, combined_segments);
1470            if record_transcript {
1471                transcript::append(&combined_plain);
1472            }
1473        } else {
1474            let fallback_arc_opt = if !indent.is_empty() {
1475                Some(Arc::new(fallback.clone()))
1476            } else {
1477                None
1478            };
1479            for (mut segments, mut plain) in
1480                converted_lines.into_iter().zip(plain_lines.into_iter())
1481            {
1482                if let Some(ref style_arc) = fallback_arc_opt
1483                    && !plain.is_empty()
1484                {
1485                    segments.insert(
1486                        0,
1487                        InlineSegment {
1488                            text: indent.to_string(),
1489                            style: Arc::clone(style_arc),
1490                        },
1491                    );
1492                    plain.insert_str(0, indent);
1493                }
1494
1495                if segments.is_empty() {
1496                    self.handle.append_line(kind, Vec::new());
1497                } else {
1498                    self.handle.append_line(kind, segments);
1499                }
1500                if record_transcript {
1501                    transcript::append(&plain);
1502                }
1503            }
1504        }
1505
1506        Ok(())
1507    }
1508
1509    fn write_line(
1510        &mut self,
1511        style: Style,
1512        indent: &str,
1513        text: &str,
1514        kind: InlineMessageKind,
1515    ) -> Result<()> {
1516        self.write_multiline(style, indent, text, kind)
1517    }
1518
1519    fn write_inline(&mut self, style: Style, text: &str, kind: InlineMessageKind) {
1520        if text.is_empty() {
1521            return;
1522        }
1523        let fallback = self.resolve_fallback_style(style);
1524        let fallback_arc = Arc::new(fallback.clone());
1525        let (converted_lines, _) = self.convert_plain_lines(text, &fallback);
1526        let line_count = converted_lines.len();
1527
1528        for (index, segments) in converted_lines.into_iter().enumerate() {
1529            let has_next = index + 1 < line_count;
1530            if segments.is_empty() {
1531                if has_next {
1532                    self.handle.inline(
1533                        kind,
1534                        InlineSegment {
1535                            text: "\n".to_owned(),
1536                            style: Arc::clone(&fallback_arc),
1537                        },
1538                    );
1539                }
1540                continue;
1541            }
1542
1543            for mut segment in segments {
1544                if has_next {
1545                    segment.text.push('\n');
1546                }
1547                self.handle.inline(kind, segment);
1548            }
1549        }
1550    }
1551
1552    fn write_segments(
1553        &mut self,
1554        segments: &[MarkdownSegment],
1555        kind: InlineMessageKind,
1556    ) -> Result<()> {
1557        let converted = self.convert_segments(segments);
1558        let plain = segments
1559            .iter()
1560            .map(|segment| segment.text.clone())
1561            .collect::<String>();
1562        self.handle.append_line(kind, converted);
1563        if Self::should_record_transcript(kind) {
1564            transcript::append(&plain);
1565        }
1566        Ok(())
1567    }
1568
1569    fn convert_segments(&self, segments: &[MarkdownSegment]) -> Vec<InlineSegment> {
1570        if segments.is_empty() {
1571            return Vec::new();
1572        }
1573
1574        let mut converted = Vec::with_capacity(segments.len());
1575        for segment in segments {
1576            if segment.text.is_empty() {
1577                continue;
1578            }
1579            converted.push(self.style_to_segment(segment.style, &segment.text));
1580        }
1581        converted
1582    }
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587    use super::*;
1588    use std::sync::{LazyLock, Mutex};
1589
1590    static FILE_OPENER_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
1591
1592    fn lock_file_opener_test_guard() -> std::sync::MutexGuard<'static, ()> {
1593        match FILE_OPENER_TEST_LOCK.lock() {
1594            Ok(guard) => guard,
1595            Err(poisoned) => poisoned.into_inner(),
1596        }
1597    }
1598
1599    #[test]
1600    fn test_styles_construct() {
1601        let info = MessageStyle::Info.style();
1602        assert_eq!(info, MessageStyle::Info.style());
1603        let resp = MessageStyle::Response.style();
1604        assert_eq!(resp, MessageStyle::Response.style());
1605        let tool = MessageStyle::Tool.style();
1606        assert_eq!(tool, MessageStyle::Tool.style());
1607        let reasoning = MessageStyle::Reasoning.style();
1608        assert_eq!(reasoning, MessageStyle::Reasoning.style());
1609    }
1610
1611    #[test]
1612    fn test_renderer_buffer() {
1613        let mut r = AnsiRenderer::stdout();
1614        r.push("hello");
1615        assert_eq!(r.buffer, "hello");
1616    }
1617
1618    #[test]
1619    fn convert_plain_lines_preserves_ansi_styles() {
1620        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
1621        let sink = InlineSink::new(
1622            InlineHandle::new_for_tests(sender),
1623            SyntaxHighlightingConfig::default(),
1624        );
1625        let fallback = InlineTextStyle {
1626            color: Some(AnsiColorEnum::Ansi(AnsiColor::Green)),
1627            bg_color: None,
1628            effects: Effects::new(),
1629        };
1630
1631        let (converted, plain) =
1632            sink.convert_plain_lines("\u{1b}[31mred\u{1b}[0m plain", &fallback);
1633
1634        assert_eq!(plain, vec!["red plain".to_owned()]);
1635        assert_eq!(converted.len(), 1);
1636        let segments = &converted[0];
1637        assert_eq!(segments.len(), 2);
1638        assert_eq!(segments[0].text, "red");
1639        assert_eq!(
1640            segments[0].style.color,
1641            Some(AnsiColorEnum::Ansi(AnsiColor::Red))
1642        );
1643        assert_eq!(segments[1].text, " plain");
1644        assert_eq!(segments[1].style.color, None);
1645    }
1646
1647    #[test]
1648    fn convert_plain_lines_retains_trailing_newline() {
1649        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
1650        let sink = InlineSink::new(
1651            InlineHandle::new_for_tests(sender),
1652            SyntaxHighlightingConfig::default(),
1653        );
1654        let fallback = InlineTextStyle::default();
1655
1656        let (converted, plain) = sink.convert_plain_lines("hello\n", &fallback);
1657
1658        assert_eq!(plain, vec!["hello".to_owned(), String::new()]);
1659        assert_eq!(converted.len(), 2);
1660        assert!(!converted[0].is_empty());
1661        assert!(converted[1].is_empty());
1662    }
1663
1664    #[test]
1665    fn write_multiline_combines_tool_lines() {
1666        use crate::ui::InlineCommand;
1667        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1668        let mut sink = InlineSink::new(
1669            InlineHandle::new_for_tests(sender),
1670            SyntaxHighlightingConfig::default(),
1671        );
1672        let style = InlineTextStyle::default();
1673        // Use Tool kind to verify that multiple lines are combined into a single AppendLine command
1674        let kind = InlineMessageKind::Tool;
1675        let text = "one\ntwo\nthree";
1676        sink.write_multiline(style.to_ansi_style(None), "", text, kind)
1677            .unwrap();
1678
1679        // We should receive exactly one AppendLine command
1680        let mut count = 0;
1681        while let Ok(command) = receiver.try_recv() {
1682            if let InlineCommand::AppendLine { .. } = command {
1683                count += 1;
1684            }
1685        }
1686        assert_eq!(count, 1);
1687    }
1688
1689    #[test]
1690    fn prepare_markdown_lines_uses_syntax_highlighting_config() {
1691        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
1692        let config = SyntaxHighlightingConfig {
1693            enabled: true,
1694            enabled_languages: vec!["rust".to_string()],
1695            ..Default::default()
1696        };
1697        let sink = InlineSink::new(InlineHandle::new_for_tests(sender), config);
1698        let base_style = MessageStyle::Response.style();
1699        let markdown = "```rust\nlet value = 1;\n```";
1700
1701        let (prepared, plain, _) =
1702            sink.prepare_markdown_lines(markdown, "", base_style, true, false);
1703
1704        let (segments, plain_line) = prepared
1705            .iter()
1706            .zip(plain.iter())
1707            .find(|(_, line)| line.contains("let value = 1;"))
1708            .expect("code line exists");
1709
1710        assert!(
1711            segments.len() > 2,
1712            "expected highlighted segments, got {}, line: {}",
1713            segments.len(),
1714            plain_line
1715        );
1716    }
1717
1718    #[test]
1719    fn prepare_markdown_lines_strips_local_path_underlines() {
1720        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
1721        let sink = InlineSink::new(
1722            InlineHandle::new_for_tests(sender),
1723            SyntaxHighlightingConfig::default(),
1724        );
1725        let base_style = MessageStyle::Response.style();
1726        let markdown = "See README.md for details.";
1727
1728        let (prepared, _, _) = sink.prepare_markdown_lines(markdown, "", base_style, true, false);
1729        let readme_segment = prepared
1730            .iter()
1731            .flat_map(|line| line.iter())
1732            .find(|segment| segment.text.contains("README.md"))
1733            .expect("README segment should be present");
1734
1735        assert!(
1736            !readme_segment.style.effects.contains(Effects::UNDERLINE),
1737            "local file-like path text should not keep markdown underline in inline UI"
1738        );
1739    }
1740
1741    #[test]
1742    fn prepare_markdown_lines_keeps_https_link_underlines() {
1743        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
1744        let sink = InlineSink::new(
1745            InlineHandle::new_for_tests(sender),
1746            SyntaxHighlightingConfig::default(),
1747        );
1748        let base_style = MessageStyle::Response.style();
1749        let markdown = "[docs](https://example.com)";
1750
1751        let (prepared, _, _) = sink.prepare_markdown_lines(markdown, "", base_style, true, false);
1752        let docs_segment = prepared
1753            .iter()
1754            .flat_map(|line| line.iter())
1755            .find(|segment| segment.text.contains("docs"))
1756            .expect("docs segment should be present");
1757
1758        assert!(
1759            docs_segment.style.effects.contains(Effects::UNDERLINE),
1760            "https markdown links should keep underline styling"
1761        );
1762    }
1763
1764    #[test]
1765    fn line_function_no_trailing_empty_line() {
1766        use crate::utils::ansi_capabilities::AnsiCapabilities;
1767        use anstream::{AutoStream, ColorChoice};
1768
1769        // Create a renderer that doesn't output to stdout
1770        let choice = ColorChoice::Never;
1771        let mut renderer = AnsiRenderer {
1772            writer: AutoStream::new(io::stdout(), choice),
1773            buffer: String::new(),
1774            color: false,
1775            sink: None,
1776            last_line_was_empty: false,
1777            highlight_config: SyntaxHighlightingConfig::default(),
1778            capabilities: AnsiCapabilities::detect(),
1779            reasoning_visible: true,
1780            screen_reader_mode: false,
1781            show_diagnostics_in_transcript: false,
1782        };
1783
1784        // This should not create an extra empty line after "line 2"
1785        renderer
1786            .line(MessageStyle::Tool, "line 1\nline 2\n")
1787            .unwrap();
1788
1789        // Previously, this would have added an extra empty line due to the trailing \n
1790        // With our fix, it should only process the actual content lines
1791    }
1792
1793    #[test]
1794    fn inline_ui_shows_error_lines_without_recording_transcript_when_disabled() {
1795        use crate::ui::InlineCommand;
1796        use crate::utils::transcript;
1797
1798        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1799        let mut renderer =
1800            AnsiRenderer::with_inline_ui(InlineHandle::new_for_tests(sender), Default::default());
1801        renderer.set_show_diagnostics_in_transcript(false);
1802        transcript::clear();
1803
1804        renderer
1805            .line(MessageStyle::Error, "fatal: hidden transcript failure")
1806            .unwrap();
1807
1808        let mut saw_append = false;
1809        while let Ok(command) = receiver.try_recv() {
1810            if matches!(command, InlineCommand::AppendLine { .. }) {
1811                saw_append = true;
1812            }
1813        }
1814        assert!(
1815            saw_append,
1816            "error output should still be visible in inline UI"
1817        );
1818        assert!(
1819            !transcript::snapshot()
1820                .iter()
1821                .any(|line| line.contains("fatal: hidden transcript failure")),
1822            "error output should not be recorded in transcript when disabled"
1823        );
1824    }
1825
1826    #[test]
1827    fn inline_ui_shows_error_lines_when_enabled() {
1828        use crate::ui::InlineCommand;
1829
1830        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1831        let mut renderer =
1832            AnsiRenderer::with_inline_ui(InlineHandle::new_for_tests(sender), Default::default());
1833        renderer.set_show_diagnostics_in_transcript(true);
1834        renderer
1835            .line(MessageStyle::Error, "fatal: visible in transcript")
1836            .unwrap();
1837
1838        let mut saw_append = false;
1839        while let Ok(command) = receiver.try_recv() {
1840            if matches!(command, InlineCommand::AppendLine { .. }) {
1841                saw_append = true;
1842            }
1843        }
1844        assert!(saw_append, "error output should be appended when enabled");
1845    }
1846
1847    #[test]
1848    fn inline_ui_collapses_large_json_tool_output() {
1849        use crate::ui::InlineCommand;
1850        use std::fmt::Write as _;
1851
1852        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1853        let mut renderer =
1854            AnsiRenderer::with_inline_ui(InlineHandle::new_for_tests(sender), Default::default());
1855
1856        let mut json = String::from("{\n");
1857        let line_total = INLINE_JSON_COLLAPSE_LINES + 5;
1858        for idx in 0..line_total {
1859            let _ = writeln!(&mut json, "  \"key{idx}\": \"value{idx}\",");
1860        }
1861        json.push_str("  \"end\": true\n}");
1862
1863        renderer.line(MessageStyle::ToolOutput, &json).unwrap();
1864
1865        let mut saw_pasted = false;
1866        let mut saw_append_line = false;
1867        while let Ok(command) = receiver.try_recv() {
1868            match command {
1869                InlineCommand::AppendPastedMessage {
1870                    kind,
1871                    text,
1872                    line_count,
1873                    ..
1874                } => {
1875                    saw_pasted = true;
1876                    assert_eq!(kind, InlineMessageKind::Pty);
1877                    assert!(text.contains("\"end\": true"));
1878                    assert!(line_count >= INLINE_JSON_COLLAPSE_LINES);
1879                }
1880                InlineCommand::AppendLine { .. } => {
1881                    saw_append_line = true;
1882                }
1883                _ => {}
1884            }
1885        }
1886
1887        assert!(saw_pasted, "expected large json to use AppendPastedMessage");
1888        assert!(!saw_append_line, "unexpected AppendLine for large json");
1889    }
1890
1891    #[test]
1892    fn clickable_targets_resolve_relative_paths_against_current_directory() {
1893        let _guard = lock_file_opener_test_guard();
1894        let original = current_file_opener();
1895        apply_file_opener_config(vtcode_config::FileOpener::Vscode);
1896
1897        let cwd = std::env::current_dir().expect("current dir");
1898        let expected =
1899            Url::from_file_path(cwd.join("vtcode-core/src/utils/ansi.rs")).expect("file url");
1900        let clickable =
1901            make_clickable_target("./vtcode-core/src/utils/ansi.rs:42").expect("clickable target");
1902
1903        assert_eq!(
1904            clickable,
1905            format!(
1906                "vscode://file{}:42",
1907                expected.as_str().trim_start_matches("file://")
1908            )
1909        );
1910
1911        apply_file_opener_config(original);
1912    }
1913
1914    #[test]
1915    fn clickable_targets_translate_hash_locations_to_editor_suffixes() {
1916        let _guard = lock_file_opener_test_guard();
1917        let original = current_file_opener();
1918        apply_file_opener_config(vtcode_config::FileOpener::Vscode);
1919
1920        let clickable = make_clickable_target("/tmp/example.rs#L12C3").expect("clickable target");
1921
1922        assert_eq!(clickable, "vscode://file/tmp/example.rs:12:3");
1923
1924        apply_file_opener_config(original);
1925    }
1926
1927    #[test]
1928    fn clickable_targets_decode_percent_encoded_bare_paths() {
1929        let _guard = lock_file_opener_test_guard();
1930        let original = current_file_opener();
1931        apply_file_opener_config(vtcode_config::FileOpener::Vscode);
1932
1933        let clickable = make_clickable_target("/tmp/Example%20Folder/R%C3%A9sum%C3%A9.md:12")
1934            .expect("clickable target");
1935
1936        assert_eq!(
1937            clickable,
1938            "vscode://file/tmp/Example%20Folder/R%C3%A9sum%C3%A9.md:12"
1939        );
1940
1941        apply_file_opener_config(original);
1942    }
1943}