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
83pub 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 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 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 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 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 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 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 pub fn capabilities(&self) -> &AnsiCapabilities {
240 &self.capabilities
241 }
242
243 pub fn should_use_unicode_formatting(&self) -> bool {
245 self.capabilities.should_use_unicode_boxes()
246 }
247
248 pub fn supports_256_colors(&self) -> bool {
250 self.capabilities.supports_256_colors()
251 }
252
253 pub fn supports_true_color(&self) -> bool {
255 self.capabilities.supports_true_color()
256 }
257
258 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 pub fn push(&mut self, text: &str) {
301 self.buffer.push_str(text);
302 }
303
304 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 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 self.last_line_was_empty = self.buffer.is_empty();
333 self.buffer.clear();
334 Ok(())
335 }
336
337 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 renderer
1786 .line(MessageStyle::Tool, "line 1\nline 2\n")
1787 .unwrap();
1788
1789 }
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}