1use crate::config::loader::SyntaxHighlightingConfig;
2use crate::ui::markdown::{MarkdownLine, MarkdownSegment, render_markdown_to_lines};
3use crate::ui::theme;
4use crate::ui::tui::{
5 InlineHandle, InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
6 InlineSegment, InlineTextStyle, SecurePromptConfig, convert_style as convert_to_inline_style,
7 theme_from_styles,
8};
9use crate::utils::transcript;
10use anstream::{AutoStream, ColorChoice};
11use anstyle::{Ansi256Color, AnsiColor, Color as AnsiColorEnum, Reset, RgbColor, Style};
12use anstyle_query::{clicolor, clicolor_force, no_color, term_supports_color};
13use anyhow::{Result, anyhow};
14use ratatui::style::{Color as RatColor, Modifier as RatModifier, Style as RatatuiStyle};
15use std::io::{self, Write};
16use tui_markdown::from_str as parse_markdown_text;
17
18#[derive(Clone, Copy)]
20pub enum MessageStyle {
21 Info,
22 Error,
23 Output,
24 Response,
25 Tool,
26 ToolDetail,
27 Status,
28 McpStatus,
29 User,
30 Reasoning,
31}
32
33impl MessageStyle {
34 pub fn style(self) -> Style {
35 let styles = theme::active_styles();
36 match self {
37 Self::Info => styles.info,
38 Self::Error => styles.error,
39 Self::Output => styles.output,
40 Self::Response => styles.response,
41 Self::Tool => styles.tool,
42 Self::ToolDetail => styles.tool_detail,
43 Self::Status => styles.status,
44 Self::McpStatus => styles.mcp,
45 Self::User => styles.user,
46 Self::Reasoning => styles.reasoning,
47 }
48 }
49
50 pub fn indent(self) -> &'static str {
51 match self {
52 Self::Response | Self::Tool | Self::Reasoning => " ",
53 Self::ToolDetail => " ",
54 _ => "",
55 }
56 }
57}
58
59pub struct AnsiRenderer {
61 writer: AutoStream<io::Stdout>,
62 buffer: String,
63 color: bool,
64 sink: Option<InlineSink>,
65 last_line_was_empty: bool,
66 highlight_config: SyntaxHighlightingConfig,
67}
68
69impl AnsiRenderer {
70 pub fn stdout() -> Self {
72 let color =
73 clicolor_force() || (!no_color() && clicolor().unwrap_or_else(term_supports_color));
74 let choice = if color {
75 ColorChoice::Auto
76 } else {
77 ColorChoice::Never
78 };
79 Self {
80 writer: AutoStream::new(std::io::stdout(), choice),
81 buffer: String::new(),
82 color,
83 sink: None,
84 last_line_was_empty: false,
85 highlight_config: SyntaxHighlightingConfig::default(),
86 }
87 }
88
89 pub fn with_inline_ui(
91 handle: InlineHandle,
92 highlight_config: SyntaxHighlightingConfig,
93 ) -> Self {
94 let mut renderer = Self::stdout();
95 renderer.highlight_config = highlight_config;
96 renderer.sink = Some(InlineSink::new(handle));
97 renderer.last_line_was_empty = false;
98 renderer
99 }
100
101 pub fn set_highlight_config(&mut self, config: SyntaxHighlightingConfig) {
103 self.highlight_config = config;
104 }
105
106 pub fn was_previous_line_empty(&self) -> bool {
108 self.last_line_was_empty
109 }
110
111 fn message_kind(style: MessageStyle) -> InlineMessageKind {
112 match style {
113 MessageStyle::Info => InlineMessageKind::Info,
114 MessageStyle::Error => InlineMessageKind::Error,
115 MessageStyle::Output => InlineMessageKind::Pty,
116 MessageStyle::Response => InlineMessageKind::Agent,
117 MessageStyle::Tool | MessageStyle::ToolDetail => InlineMessageKind::Tool,
118 MessageStyle::Status | MessageStyle::McpStatus => InlineMessageKind::Info,
119 MessageStyle::User => InlineMessageKind::User,
120 MessageStyle::Reasoning => InlineMessageKind::Policy,
121 }
122 }
123
124 pub fn supports_streaming_markdown(&self) -> bool {
125 self.sink.is_some()
126 }
127
128 pub fn prefers_untruncated_output(&self) -> bool {
133 self.sink.is_some()
134 }
135
136 pub fn supports_inline_ui(&self) -> bool {
137 self.sink.is_some()
138 }
139
140 pub fn show_list_modal(
141 &mut self,
142 title: &str,
143 lines: Vec<String>,
144 items: Vec<InlineListItem>,
145 selected: Option<InlineListSelection>,
146 search: Option<InlineListSearchConfig>,
147 ) {
148 if let Some(sink) = &self.sink {
149 sink.show_list_modal(title.to_string(), lines, items, selected, search);
150 }
151 }
152
153 pub fn show_secure_prompt_modal(
154 &mut self,
155 title: &str,
156 lines: Vec<String>,
157 prompt_label: String,
158 ) {
159 if let Some(sink) = &self.sink {
160 sink.show_secure_prompt_modal(title.to_string(), lines, prompt_label);
161 }
162 }
163
164 pub fn close_modal(&mut self) {
165 if let Some(sink) = &self.sink {
166 sink.close_modal();
167 }
168 }
169
170 pub fn push(&mut self, text: &str) {
172 self.buffer.push_str(text);
173 }
174
175 pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
177 if let Some(sink) = &mut self.sink {
178 let indent = style.indent();
179 let line = self.buffer.clone();
180 self.last_line_was_empty = line.is_empty() && indent.is_empty();
182 sink.write_line(style.style(), indent, &line, Self::message_kind(style))?;
183 self.buffer.clear();
184 return Ok(());
185 }
186 let style = style.style();
187 if self.color {
188 writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
189 } else {
190 writeln!(self.writer, "{}", self.buffer)?;
191 }
192 self.writer.flush()?;
193 transcript::append(&self.buffer);
194 self.last_line_was_empty = self.buffer.is_empty();
196 self.buffer.clear();
197 Ok(())
198 }
199
200 pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
202 if matches!(style, MessageStyle::Response) {
203 return self.render_markdown(style, text);
204 }
205 let indent = style.indent();
206
207 if let Some(sink) = &mut self.sink {
208 sink.write_multiline(style.style(), indent, text, Self::message_kind(style))?;
209 return Ok(());
210 }
211
212 if text.contains('\n') {
213 let trailing_newline = text.ends_with('\n');
214 for line in text.lines() {
215 self.buffer.clear();
216 if !indent.is_empty() && !line.is_empty() {
217 self.buffer.push_str(indent);
218 }
219 self.buffer.push_str(line);
220 self.flush(style)?;
221 }
222 if trailing_newline {
223 self.buffer.clear();
224 if !indent.is_empty() {
225 self.buffer.push_str(indent);
226 }
227 self.flush(style)?;
228 }
229 Ok(())
230 } else {
231 self.buffer.clear();
232 if !indent.is_empty() && !text.is_empty() {
233 self.buffer.push_str(indent);
234 }
235 self.buffer.push_str(text);
236 self.flush(style)
237 }
238 }
239
240 pub fn inline_with_style(&mut self, style: MessageStyle, text: &str) -> Result<()> {
242 if let Some(sink) = &mut self.sink {
243 sink.write_inline(style.style(), text, Self::message_kind(style));
244 return Ok(());
245 }
246 let ansi_style = style.style();
247 if self.color {
248 write!(self.writer, "{ansi_style}{}{Reset}", text)?;
249 } else {
250 write!(self.writer, "{}", text)?;
251 }
252 self.writer.flush()?;
253 Ok(())
254 }
255
256 pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
258 if let Some(sink) = &mut self.sink {
259 sink.write_multiline(style, "", text, InlineMessageKind::Info)?;
260 return Ok(());
261 }
262 if self.color {
263 writeln!(self.writer, "{style}{}{Reset}", text)?;
264 } else {
265 writeln!(self.writer, "{}", text)?;
266 }
267 self.writer.flush()?;
268 transcript::append(text);
269 Ok(())
270 }
271
272 pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
274 if !self.was_previous_line_empty() {
275 self.line(style, "")
276 } else {
277 Ok(())
278 }
279 }
280
281 pub fn raw_line(&mut self, text: &str) -> Result<()> {
283 writeln!(self.writer, "{}", text)?;
284 self.writer.flush()?;
285 transcript::append(text);
286 Ok(())
287 }
288
289 fn render_markdown(&mut self, style: MessageStyle, text: &str) -> Result<()> {
290 let styles = theme::active_styles();
291 let base_style = style.style();
292 let indent = style.indent();
293 if let Some(sink) = &mut self.sink {
294 let last_empty =
295 sink.write_markdown(text, indent, base_style, Self::message_kind(style))?;
296 self.last_line_was_empty = last_empty;
297 return Ok(());
298 }
299 let highlight_cfg = if self.highlight_config.enabled {
300 Some(&self.highlight_config)
301 } else {
302 None
303 };
304 let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
305 if lines.is_empty() {
306 lines.push(MarkdownLine::default());
307 }
308 for line in lines {
309 self.write_markdown_line(style, indent, line)?;
310 }
311 Ok(())
312 }
313
314 pub fn stream_markdown_response(
315 &mut self,
316 text: &str,
317 previous_line_count: usize,
318 ) -> Result<usize> {
319 let styles = theme::active_styles();
320 let style = MessageStyle::Response;
321 let base_style = style.style();
322 let indent = style.indent();
323 if let Some(sink) = &mut self.sink {
324 let (prepared, plain_lines, last_empty) =
325 sink.prepare_markdown_lines(text, indent, base_style);
326 let line_count = prepared.len();
327 sink.replace_inline_lines(
328 previous_line_count,
329 prepared,
330 &plain_lines,
331 Self::message_kind(style),
332 );
333 self.last_line_was_empty = last_empty;
334 return Ok(line_count);
335 }
336
337 let highlight_cfg = if self.highlight_config.enabled {
338 Some(&self.highlight_config)
339 } else {
340 None
341 };
342 let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
343 if lines.is_empty() {
344 lines.push(MarkdownLine::default());
345 }
346
347 Err(anyhow!("stream_markdown_response requires an inline sink"))
348 }
349
350 fn write_markdown_line(
351 &mut self,
352 style: MessageStyle,
353 indent: &str,
354 mut line: MarkdownLine,
355 ) -> Result<()> {
356 if !indent.is_empty() && !line.segments.is_empty() {
357 line.segments
358 .insert(0, MarkdownSegment::new(style.style(), indent));
359 }
360
361 if let Some(sink) = &mut self.sink {
362 sink.write_segments(&line.segments, Self::message_kind(style))?;
363 self.last_line_was_empty = line.is_empty();
364 return Ok(());
365 }
366
367 let mut plain = String::new();
368 if self.color {
369 for segment in &line.segments {
370 write!(
371 self.writer,
372 "{style}{}{Reset}",
373 segment.text,
374 style = segment.style
375 )?;
376 plain.push_str(&segment.text);
377 }
378 writeln!(self.writer)?;
379 } else {
380 for segment in &line.segments {
381 write!(self.writer, "{}", segment.text)?;
382 plain.push_str(&segment.text);
383 }
384 writeln!(self.writer)?;
385 }
386 self.writer.flush()?;
387 transcript::append(&plain);
388 self.last_line_was_empty = plain.trim().is_empty();
389 Ok(())
390 }
391}
392
393struct InlineSink {
394 handle: InlineHandle,
395}
396
397impl InlineSink {
398 fn ansi_from_ratatui_color(color: RatColor) -> Option<AnsiColorEnum> {
399 match color {
400 RatColor::Reset => None,
401 RatColor::Black => Some(AnsiColorEnum::Ansi(AnsiColor::Black)),
402 RatColor::Red => Some(AnsiColorEnum::Ansi(AnsiColor::Red)),
403 RatColor::Green => Some(AnsiColorEnum::Ansi(AnsiColor::Green)),
404 RatColor::Yellow => Some(AnsiColorEnum::Ansi(AnsiColor::Yellow)),
405 RatColor::Blue => Some(AnsiColorEnum::Ansi(AnsiColor::Blue)),
406 RatColor::Magenta => Some(AnsiColorEnum::Ansi(AnsiColor::Magenta)),
407 RatColor::Cyan => Some(AnsiColorEnum::Ansi(AnsiColor::Cyan)),
408 RatColor::Gray => Some(AnsiColorEnum::Ansi(AnsiColor::White)),
409 RatColor::DarkGray => Some(AnsiColorEnum::Ansi(AnsiColor::BrightBlack)),
410 RatColor::LightRed => Some(AnsiColorEnum::Ansi(AnsiColor::BrightRed)),
411 RatColor::LightGreen => Some(AnsiColorEnum::Ansi(AnsiColor::BrightGreen)),
412 RatColor::LightYellow => Some(AnsiColorEnum::Ansi(AnsiColor::BrightYellow)),
413 RatColor::LightBlue => Some(AnsiColorEnum::Ansi(AnsiColor::BrightBlue)),
414 RatColor::LightMagenta => Some(AnsiColorEnum::Ansi(AnsiColor::BrightMagenta)),
415 RatColor::LightCyan => Some(AnsiColorEnum::Ansi(AnsiColor::BrightCyan)),
416 RatColor::White => Some(AnsiColorEnum::Ansi(AnsiColor::BrightWhite)),
417 RatColor::Rgb(r, g, b) => Some(AnsiColorEnum::Rgb(RgbColor(r, g, b))),
418 RatColor::Indexed(value) => Some(AnsiColorEnum::Ansi256(Ansi256Color(value))),
419 }
420 }
421
422 fn inline_style_from_ratatui(
423 &self,
424 style: RatatuiStyle,
425 fallback: &InlineTextStyle,
426 ) -> InlineTextStyle {
427 let mut resolved = fallback.clone();
428 if let Some(color) = style.fg.and_then(Self::ansi_from_ratatui_color) {
429 resolved.color = Some(color);
430 }
431
432 let added = style.add_modifier;
433 let removed = style.sub_modifier;
434
435 if added.contains(RatModifier::BOLD) {
436 resolved.bold = true;
437 } else if removed.contains(RatModifier::BOLD) {
438 resolved.bold = false;
439 }
440
441 if added.contains(RatModifier::ITALIC) {
442 resolved.italic = true;
443 } else if removed.contains(RatModifier::ITALIC) {
444 resolved.italic = false;
445 }
446
447 resolved
448 }
449
450 fn prepare_markdown_lines(
451 &self,
452 text: &str,
453 indent: &str,
454 base_style: Style,
455 ) -> (Vec<Vec<InlineSegment>>, Vec<String>, bool) {
456 let fallback = self.resolve_fallback_style(base_style);
457 let parsed = parse_markdown_text(text);
458 let mut prepared = Vec::new();
459 let mut plain = Vec::new();
460
461 for line in parsed.lines.into_iter() {
462 let mut segments = Vec::new();
463 let mut plain_line = String::new();
464 let line_style = RatatuiStyle::default()
465 .patch(parsed.style)
466 .patch(line.style);
467
468 for span in line.spans.into_iter() {
469 let content = span.content.into_owned();
470 if content.is_empty() {
471 continue;
472 }
473 let span_style = line_style.patch(span.style);
474 let inline_style = self.inline_style_from_ratatui(span_style, &fallback);
475 plain_line.push_str(&content);
476 segments.push(InlineSegment {
477 text: content,
478 style: inline_style,
479 });
480 }
481
482 if !indent.is_empty() && !plain_line.is_empty() {
483 segments.insert(
484 0,
485 InlineSegment {
486 text: indent.to_string(),
487 style: fallback.clone(),
488 },
489 );
490 plain_line.insert_str(0, indent);
491 }
492
493 prepared.push(segments);
494 plain.push(plain_line);
495 }
496
497 if prepared.is_empty() {
498 prepared.push(Vec::new());
499 plain.push(String::new());
500 }
501
502 let last_empty = plain
503 .last()
504 .map(|line| line.trim().is_empty())
505 .unwrap_or(true);
506
507 (prepared, plain, last_empty)
508 }
509
510 fn write_markdown(
511 &mut self,
512 text: &str,
513 indent: &str,
514 base_style: Style,
515 kind: InlineMessageKind,
516 ) -> Result<bool> {
517 let (prepared, plain, last_empty) = self.prepare_markdown_lines(text, indent, base_style);
518 for (segments, line) in prepared.into_iter().zip(plain.iter()) {
519 if segments.is_empty() {
520 self.handle.append_line(kind, Vec::new());
521 } else {
522 self.handle.append_line(kind, segments);
523 }
524 crate::utils::transcript::append(line);
525 }
526 Ok(last_empty)
527 }
528
529 fn replace_inline_lines(
530 &mut self,
531 count: usize,
532 lines: Vec<Vec<InlineSegment>>,
533 plain: &[String],
534 kind: InlineMessageKind,
535 ) {
536 self.handle.replace_last(count, kind, lines);
537 crate::utils::transcript::replace_last(count, plain);
538 }
539
540 fn new(handle: InlineHandle) -> Self {
541 Self { handle }
542 }
543
544 fn show_list_modal(
545 &self,
546 title: String,
547 lines: Vec<String>,
548 items: Vec<InlineListItem>,
549 selected: Option<InlineListSelection>,
550 search: Option<InlineListSearchConfig>,
551 ) {
552 self.handle
553 .show_list_modal(title, lines, items, selected, search);
554 }
555
556 fn show_secure_prompt_modal(&self, title: String, lines: Vec<String>, prompt_label: String) {
557 self.handle.show_modal(
558 title,
559 lines,
560 Some(SecurePromptConfig {
561 label: prompt_label,
562 }),
563 );
564 }
565
566 fn close_modal(&self) {
567 self.handle.close_modal();
568 }
569
570 fn resolve_fallback_style(&self, style: Style) -> InlineTextStyle {
571 let mut text_style = convert_to_inline_style(style);
572 if text_style.color.is_none() {
573 let theme = theme_from_styles(&theme::active_styles());
574 text_style = text_style.merge_color(theme.foreground);
575 }
576 text_style
577 }
578
579 fn style_to_segment(&self, style: Style, text: &str) -> InlineSegment {
580 let text_style = self.resolve_fallback_style(style);
581 InlineSegment {
582 text: text.to_string(),
583 style: text_style,
584 }
585 }
586
587 fn convert_plain_lines(
588 &self,
589 text: &str,
590 fallback: &InlineTextStyle,
591 ) -> (Vec<Vec<InlineSegment>>, Vec<String>) {
592 if text.is_empty() {
593 return (vec![Vec::new()], vec![String::new()]);
594 }
595
596 let mut converted_lines = Vec::new();
597 let mut plain_lines = Vec::new();
598
599 for line in text.split('\n') {
600 let mut segments = Vec::new();
601 if !line.is_empty() {
602 segments.push(InlineSegment {
603 text: line.to_string(),
604 style: fallback.clone(),
605 });
606 }
607 converted_lines.push(segments);
608 plain_lines.push(line.to_string());
609 }
610
611 if text.ends_with('\n') {
612 converted_lines.push(Vec::new());
613 plain_lines.push(String::new());
614 }
615
616 if converted_lines.is_empty() {
617 converted_lines.push(Vec::new());
618 plain_lines.push(String::new());
619 }
620
621 (converted_lines, plain_lines)
622 }
623
624 fn write_multiline(
625 &mut self,
626 style: Style,
627 indent: &str,
628 text: &str,
629 kind: InlineMessageKind,
630 ) -> Result<()> {
631 if text.is_empty() {
632 self.handle.append_line(kind, Vec::new());
633 crate::utils::transcript::append("");
634 return Ok(());
635 }
636
637 let fallback = self.resolve_fallback_style(style);
638 let (converted_lines, plain_lines) = self.convert_plain_lines(text, &fallback);
639
640 for (mut segments, mut plain) in converted_lines.into_iter().zip(plain_lines.into_iter()) {
641 if !indent.is_empty() && !plain.is_empty() {
642 segments.insert(
643 0,
644 InlineSegment {
645 text: indent.to_string(),
646 style: fallback.clone(),
647 },
648 );
649 plain.insert_str(0, indent);
650 }
651
652 if segments.is_empty() {
653 self.handle.append_line(kind, Vec::new());
654 } else {
655 self.handle.append_line(kind, segments);
656 }
657 crate::utils::transcript::append(&plain);
658 }
659
660 Ok(())
661 }
662
663 fn write_line(
664 &mut self,
665 style: Style,
666 indent: &str,
667 text: &str,
668 kind: InlineMessageKind,
669 ) -> Result<()> {
670 self.write_multiline(style, indent, text, kind)
671 }
672
673 fn write_inline(&mut self, style: Style, text: &str, kind: InlineMessageKind) {
674 if text.is_empty() {
675 return;
676 }
677 let fallback = self.resolve_fallback_style(style);
678 let (converted_lines, _) = self.convert_plain_lines(text, &fallback);
679 let line_count = converted_lines.len();
680
681 for (index, segments) in converted_lines.into_iter().enumerate() {
682 let has_next = index + 1 < line_count;
683 if segments.is_empty() {
684 if has_next {
685 self.handle.inline(
686 kind,
687 InlineSegment {
688 text: "\n".to_string(),
689 style: fallback.clone(),
690 },
691 );
692 }
693 continue;
694 }
695
696 for mut segment in segments {
697 if has_next {
698 segment.text.push('\n');
699 }
700 self.handle.inline(kind, segment);
701 }
702 }
703 }
704
705 fn write_segments(
706 &mut self,
707 segments: &[MarkdownSegment],
708 kind: InlineMessageKind,
709 ) -> Result<()> {
710 let converted = self.convert_segments(segments);
711 let plain = segments
712 .iter()
713 .map(|segment| segment.text.clone())
714 .collect::<String>();
715 self.handle.append_line(kind, converted);
716 crate::utils::transcript::append(&plain);
717 Ok(())
718 }
719
720 fn convert_segments(&self, segments: &[MarkdownSegment]) -> Vec<InlineSegment> {
721 if segments.is_empty() {
722 return Vec::new();
723 }
724
725 let mut converted = Vec::with_capacity(segments.len());
726 for segment in segments {
727 if segment.text.is_empty() {
728 continue;
729 }
730 converted.push(self.style_to_segment(segment.style, &segment.text));
731 }
732 converted
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn test_styles_construct() {
742 let info = MessageStyle::Info.style();
743 assert_eq!(info, MessageStyle::Info.style());
744 let resp = MessageStyle::Response.style();
745 assert_eq!(resp, MessageStyle::Response.style());
746 let tool = MessageStyle::Tool.style();
747 assert_eq!(tool, MessageStyle::Tool.style());
748 let reasoning = MessageStyle::Reasoning.style();
749 assert_eq!(reasoning, MessageStyle::Reasoning.style());
750 }
751
752 #[test]
753 fn test_renderer_buffer() {
754 let mut r = AnsiRenderer::stdout();
755 r.push("hello");
756 assert_eq!(r.buffer, "hello");
757 }
758}