1use crate::config::loader::SyntaxHighlightingConfig;
2use crate::ui::markdown::{MarkdownLine, MarkdownSegment, render_markdown_to_lines};
3use crate::ui::theme;
4use crate::ui::tui::{
5 RatatuiHandle, RatatuiMessageKind, RatatuiSegment, RatatuiTextStyle,
6 convert_style as convert_to_ratatui_style, theme_from_styles,
7};
8use crate::utils::transcript;
9use ansi_to_tui::IntoText;
10use anstream::{AutoStream, ColorChoice};
11use anstyle::{Reset, Style};
12use anstyle_query::{clicolor, clicolor_force, no_color, term_supports_color};
13use anyhow::{Result, anyhow};
14use ratatui::style::{Modifier as RatatuiModifier, Style as RatatuiStyle};
15use std::io::{self, Write};
16
17#[derive(Clone, Copy)]
19pub enum MessageStyle {
20 Info,
21 Error,
22 Output,
23 Response,
24 Tool,
25 User,
26 Reasoning,
27}
28
29impl MessageStyle {
30 pub fn style(self) -> Style {
31 let styles = theme::active_styles();
32 match self {
33 Self::Info => styles.info,
34 Self::Error => styles.error,
35 Self::Output => styles.output,
36 Self::Response => styles.response,
37 Self::Tool => styles.tool,
38 Self::User => styles.user,
39 Self::Reasoning => styles.reasoning,
40 }
41 }
42
43 pub fn indent(self) -> &'static str {
44 match self {
45 Self::Response | Self::Tool | Self::Reasoning => " ",
46 _ => "",
47 }
48 }
49}
50
51pub struct AnsiRenderer {
53 writer: AutoStream<io::Stdout>,
54 buffer: String,
55 color: bool,
56 sink: Option<RatatuiSink>,
57 last_line_was_empty: bool,
58 highlight_config: SyntaxHighlightingConfig,
59}
60
61impl AnsiRenderer {
62 pub fn stdout() -> Self {
64 let color =
65 clicolor_force() || (!no_color() && clicolor().unwrap_or_else(term_supports_color));
66 let choice = if color {
67 ColorChoice::Auto
68 } else {
69 ColorChoice::Never
70 };
71 Self {
72 writer: AutoStream::new(std::io::stdout(), choice),
73 buffer: String::new(),
74 color,
75 sink: None,
76 last_line_was_empty: false,
77 highlight_config: SyntaxHighlightingConfig::default(),
78 }
79 }
80
81 pub fn with_ratatui(handle: RatatuiHandle, highlight_config: SyntaxHighlightingConfig) -> Self {
83 let mut renderer = Self::stdout();
84 renderer.highlight_config = highlight_config;
85 renderer.sink = Some(RatatuiSink::new(handle));
86 renderer.last_line_was_empty = false;
87 renderer
88 }
89
90 pub fn set_highlight_config(&mut self, config: SyntaxHighlightingConfig) {
92 self.highlight_config = config;
93 }
94
95 pub fn was_previous_line_empty(&self) -> bool {
97 self.last_line_was_empty
98 }
99
100 fn message_kind(style: MessageStyle) -> RatatuiMessageKind {
101 match style {
102 MessageStyle::Info => RatatuiMessageKind::Info,
103 MessageStyle::Error => RatatuiMessageKind::Error,
104 MessageStyle::Output => RatatuiMessageKind::Pty,
105 MessageStyle::Response => RatatuiMessageKind::Agent,
106 MessageStyle::Tool => RatatuiMessageKind::Tool,
107 MessageStyle::User => RatatuiMessageKind::User,
108 MessageStyle::Reasoning => RatatuiMessageKind::Policy,
109 }
110 }
111
112 pub fn supports_streaming_markdown(&self) -> bool {
113 self.sink.is_some()
114 }
115
116 pub fn push(&mut self, text: &str) {
118 self.buffer.push_str(text);
119 }
120
121 pub fn flush(&mut self, style: MessageStyle) -> Result<()> {
123 if let Some(sink) = &mut self.sink {
124 let indent = style.indent();
125 let line = self.buffer.clone();
126 self.last_line_was_empty = line.is_empty() && indent.is_empty();
128 sink.write_line(style.style(), indent, &line, Self::message_kind(style))?;
129 self.buffer.clear();
130 return Ok(());
131 }
132 let style = style.style();
133 if self.color {
134 writeln!(self.writer, "{style}{}{Reset}", self.buffer)?;
135 } else {
136 writeln!(self.writer, "{}", self.buffer)?;
137 }
138 self.writer.flush()?;
139 transcript::append(&self.buffer);
140 self.last_line_was_empty = self.buffer.is_empty();
142 self.buffer.clear();
143 Ok(())
144 }
145
146 pub fn line(&mut self, style: MessageStyle, text: &str) -> Result<()> {
148 if matches!(style, MessageStyle::Response) {
149 return self.render_markdown(style, text);
150 }
151 let indent = style.indent();
152
153 if let Some(sink) = &mut self.sink {
154 sink.write_multiline(style.style(), indent, text, Self::message_kind(style))?;
155 return Ok(());
156 }
157
158 if text.contains('\n') {
159 let trailing_newline = text.ends_with('\n');
160 for line in text.lines() {
161 self.buffer.clear();
162 if !indent.is_empty() && !line.is_empty() {
163 self.buffer.push_str(indent);
164 }
165 self.buffer.push_str(line);
166 self.flush(style)?;
167 }
168 if trailing_newline {
169 self.buffer.clear();
170 if !indent.is_empty() {
171 self.buffer.push_str(indent);
172 }
173 self.flush(style)?;
174 }
175 Ok(())
176 } else {
177 self.buffer.clear();
178 if !indent.is_empty() && !text.is_empty() {
179 self.buffer.push_str(indent);
180 }
181 self.buffer.push_str(text);
182 self.flush(style)
183 }
184 }
185
186 pub fn inline_with_style(&mut self, style: MessageStyle, text: &str) -> Result<()> {
188 if let Some(sink) = &mut self.sink {
189 sink.write_inline(style.style(), text, Self::message_kind(style));
190 return Ok(());
191 }
192 let ansi_style = style.style();
193 if self.color {
194 write!(self.writer, "{ansi_style}{}{Reset}", text)?;
195 } else {
196 write!(self.writer, "{}", text)?;
197 }
198 self.writer.flush()?;
199 Ok(())
200 }
201
202 pub fn line_with_style(&mut self, style: Style, text: &str) -> Result<()> {
204 if let Some(sink) = &mut self.sink {
205 sink.write_multiline(style, "", text, RatatuiMessageKind::Info)?;
206 return Ok(());
207 }
208 if self.color {
209 writeln!(self.writer, "{style}{}{Reset}", text)?;
210 } else {
211 writeln!(self.writer, "{}", text)?;
212 }
213 self.writer.flush()?;
214 transcript::append(text);
215 Ok(())
216 }
217
218 pub fn line_if_not_empty(&mut self, style: MessageStyle) -> Result<()> {
220 if !self.was_previous_line_empty() {
221 self.line(style, "")
222 } else {
223 Ok(())
224 }
225 }
226
227 pub fn raw_line(&mut self, text: &str) -> Result<()> {
229 writeln!(self.writer, "{}", text)?;
230 self.writer.flush()?;
231 transcript::append(text);
232 Ok(())
233 }
234
235 fn render_markdown(&mut self, style: MessageStyle, text: &str) -> Result<()> {
236 let styles = theme::active_styles();
237 let base_style = style.style();
238 let indent = style.indent();
239 let highlight_cfg = if self.highlight_config.enabled {
240 Some(&self.highlight_config)
241 } else {
242 None
243 };
244 let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
245 if lines.is_empty() {
246 lines.push(MarkdownLine::default());
247 }
248 for line in lines {
249 self.write_markdown_line(style, indent, line)?;
250 }
251 Ok(())
252 }
253
254 pub fn stream_markdown_response(
255 &mut self,
256 text: &str,
257 previous_line_count: usize,
258 ) -> Result<usize> {
259 let styles = theme::active_styles();
260 let style = MessageStyle::Response;
261 let base_style = style.style();
262 let indent = style.indent();
263 let highlight_cfg = if self.highlight_config.enabled {
264 Some(&self.highlight_config)
265 } else {
266 None
267 };
268 let mut lines = render_markdown_to_lines(text, base_style, &styles, highlight_cfg);
269 if lines.is_empty() {
270 lines.push(MarkdownLine::default());
271 }
272
273 if let Some(sink) = &mut self.sink {
274 let mut plain_lines = Vec::with_capacity(lines.len());
275 let mut prepared = Vec::with_capacity(lines.len());
276 for mut line in lines {
277 if !indent.is_empty() && !line.segments.is_empty() {
278 line.segments
279 .insert(0, MarkdownSegment::new(base_style, indent));
280 }
281 plain_lines.push(
282 line.segments
283 .iter()
284 .map(|segment| segment.text.clone())
285 .collect::<String>(),
286 );
287 prepared.push(line.segments);
288 }
289 sink.replace_lines(
290 previous_line_count,
291 &prepared,
292 &plain_lines,
293 Self::message_kind(style),
294 );
295 self.last_line_was_empty = prepared
296 .last()
297 .map(|segments| segments.is_empty())
298 .unwrap_or(true);
299 return Ok(prepared.len());
300 }
301
302 Err(anyhow!("stream_markdown_response requires a ratatui sink"))
303 }
304
305 fn write_markdown_line(
306 &mut self,
307 style: MessageStyle,
308 indent: &str,
309 mut line: MarkdownLine,
310 ) -> Result<()> {
311 if !indent.is_empty() && !line.segments.is_empty() {
312 line.segments
313 .insert(0, MarkdownSegment::new(style.style(), indent));
314 }
315
316 if let Some(sink) = &mut self.sink {
317 sink.write_segments(&line.segments, Self::message_kind(style))?;
318 self.last_line_was_empty = line.is_empty();
319 return Ok(());
320 }
321
322 let mut plain = String::new();
323 if self.color {
324 for segment in &line.segments {
325 write!(
326 self.writer,
327 "{style}{}{Reset}",
328 segment.text,
329 style = segment.style
330 )?;
331 plain.push_str(&segment.text);
332 }
333 writeln!(self.writer)?;
334 } else {
335 for segment in &line.segments {
336 write!(self.writer, "{}", segment.text)?;
337 plain.push_str(&segment.text);
338 }
339 writeln!(self.writer)?;
340 }
341 self.writer.flush()?;
342 transcript::append(&plain);
343 self.last_line_was_empty = plain.trim().is_empty();
344 Ok(())
345 }
346}
347
348struct RatatuiSink {
349 handle: RatatuiHandle,
350}
351
352impl RatatuiSink {
353 fn new(handle: RatatuiHandle) -> Self {
354 Self { handle }
355 }
356
357 fn resolve_fallback_style(&self, style: Style) -> RatatuiTextStyle {
358 let mut text_style = convert_to_ratatui_style(style);
359 if text_style.color.is_none() {
360 let theme = theme_from_styles(&theme::active_styles());
361 text_style = text_style.merge_color(theme.foreground);
362 }
363 text_style
364 }
365
366 fn merge_with_fallback(
367 &self,
368 fallback: &RatatuiTextStyle,
369 span_style: RatatuiStyle,
370 ) -> RatatuiTextStyle {
371 let mut converted = RatatuiTextStyle {
372 color: span_style.fg,
373 bold: span_style.add_modifier.contains(RatatuiModifier::BOLD),
374 italic: span_style.add_modifier.contains(RatatuiModifier::ITALIC),
375 };
376
377 if converted.color.is_none() {
378 converted.color = fallback.color;
379 }
380 converted.bold |= fallback.bold;
381 converted.italic |= fallback.italic;
382 converted
383 }
384
385 fn convert_ansi_lines(
386 &self,
387 text: &str,
388 fallback: &RatatuiTextStyle,
389 ) -> (Vec<Vec<RatatuiSegment>>, Vec<String>) {
390 let parsed = text
391 .into_text()
392 .unwrap_or_else(|_| ratatui::text::Text::from(text));
393 let lines = parsed.lines;
394 if lines.is_empty() {
395 return (vec![Vec::new()], vec![String::new()]);
396 }
397
398 let mut converted_lines = Vec::with_capacity(lines.len());
399 let mut plain_lines = Vec::with_capacity(lines.len());
400
401 for line in lines {
402 let mut segments = Vec::with_capacity(line.spans.len());
403 let mut plain = String::new();
404
405 for span in line.spans {
406 let content = span.content.into_owned();
407 if content.is_empty() {
408 continue;
409 }
410 let style = self.merge_with_fallback(fallback, span.style);
411 plain.push_str(&content);
412 segments.push(RatatuiSegment {
413 text: content,
414 style,
415 });
416 }
417
418 converted_lines.push(segments);
419 plain_lines.push(plain);
420 }
421
422 if converted_lines.is_empty() {
423 converted_lines.push(Vec::new());
424 plain_lines.push(String::new());
425 }
426
427 (converted_lines, plain_lines)
428 }
429
430 fn style_to_segment(&self, style: Style, text: &str) -> RatatuiSegment {
431 let text_style = self.resolve_fallback_style(style);
432 RatatuiSegment {
433 text: text.to_string(),
434 style: text_style,
435 }
436 }
437
438 fn write_multiline(
439 &mut self,
440 style: Style,
441 indent: &str,
442 text: &str,
443 kind: RatatuiMessageKind,
444 ) -> Result<()> {
445 if text.is_empty() {
446 self.handle.append_line(kind, Vec::new());
447 crate::utils::transcript::append("");
448 return Ok(());
449 }
450
451 let fallback = self.resolve_fallback_style(style);
452 let (converted_lines, plain_lines) = self.convert_ansi_lines(text, &fallback);
453
454 for (mut segments, mut plain) in converted_lines.into_iter().zip(plain_lines.into_iter()) {
455 if !indent.is_empty() && !plain.is_empty() {
456 segments.insert(
457 0,
458 RatatuiSegment {
459 text: indent.to_string(),
460 style: fallback.clone(),
461 },
462 );
463 plain.insert_str(0, indent);
464 }
465
466 if segments.is_empty() {
467 self.handle.append_line(kind, Vec::new());
468 } else {
469 self.handle.append_line(kind, segments);
470 }
471 crate::utils::transcript::append(&plain);
472 }
473
474 Ok(())
475 }
476
477 fn write_line(
478 &mut self,
479 style: Style,
480 indent: &str,
481 text: &str,
482 kind: RatatuiMessageKind,
483 ) -> Result<()> {
484 self.write_multiline(style, indent, text, kind)
485 }
486
487 fn write_inline(&mut self, style: Style, text: &str, kind: RatatuiMessageKind) {
488 if text.is_empty() {
489 return;
490 }
491 let fallback = self.resolve_fallback_style(style);
492 let (converted_lines, _) = self.convert_ansi_lines(text, &fallback);
493 let line_count = converted_lines.len();
494
495 for (index, mut segments) in converted_lines.into_iter().enumerate() {
496 let has_next = index + 1 < line_count;
497 if has_next {
498 if let Some(last) = segments.last_mut() {
499 last.text.push('\n');
500 } else {
501 self.handle.inline(
502 kind,
503 RatatuiSegment {
504 text: "\n".to_string(),
505 style: fallback.clone(),
506 },
507 );
508 continue;
509 }
510 }
511
512 for segment in segments {
513 self.handle.inline(kind, segment);
514 }
515 }
516 }
517
518 fn write_segments(
519 &mut self,
520 segments: &[MarkdownSegment],
521 kind: RatatuiMessageKind,
522 ) -> Result<()> {
523 let converted = self.convert_segments(segments);
524 let plain = segments
525 .iter()
526 .map(|segment| segment.text.clone())
527 .collect::<String>();
528 self.handle.append_line(kind, converted);
529 crate::utils::transcript::append(&plain);
530 Ok(())
531 }
532
533 fn convert_segments(&self, segments: &[MarkdownSegment]) -> Vec<RatatuiSegment> {
534 if segments.is_empty() {
535 return Vec::new();
536 }
537
538 let mut converted = Vec::with_capacity(segments.len());
539 for segment in segments {
540 if segment.text.is_empty() {
541 continue;
542 }
543 converted.push(self.style_to_segment(segment.style, &segment.text));
544 }
545 converted
546 }
547
548 fn replace_lines(
549 &mut self,
550 count: usize,
551 lines: &[Vec<MarkdownSegment>],
552 plain: &[String],
553 kind: RatatuiMessageKind,
554 ) {
555 let mut converted = Vec::with_capacity(lines.len());
556 for segments in lines {
557 converted.push(self.convert_segments(segments));
558 }
559 self.handle.replace_last(count, kind, converted);
560 crate::utils::transcript::replace_last(count, plain);
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn test_styles_construct() {
570 let info = MessageStyle::Info.style();
571 assert_eq!(info, MessageStyle::Info.style());
572 let resp = MessageStyle::Response.style();
573 assert_eq!(resp, MessageStyle::Response.style());
574 let tool = MessageStyle::Tool.style();
575 assert_eq!(tool, MessageStyle::Tool.style());
576 let reasoning = MessageStyle::Reasoning.style();
577 assert_eq!(reasoning, MessageStyle::Reasoning.style());
578 }
579
580 #[test]
581 fn test_renderer_buffer() {
582 let mut r = AnsiRenderer::stdout();
583 r.push("hello");
584 assert_eq!(r.buffer, "hello");
585 }
586
587 #[test]
588 fn ansi_to_tui_retains_trailing_newline() {
589 let parsed = "hello\n".into_text().expect("failed to parse ansi text");
590 assert_eq!(parsed.lines.len(), 2);
591 assert!(parsed.lines[1].spans.is_empty());
592 }
593}