render.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. use std::fmt::Write as FmtWrite;
  2. use std::io::{self, Write};
  3. use std::thread;
  4. use std::time::Duration;
  5. use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
  6. use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
  7. use crossterm::terminal::{Clear, ClearType};
  8. use crossterm::{execute, queue};
  9. use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
  10. use syntect::easy::HighlightLines;
  11. use syntect::highlighting::{Theme, ThemeSet};
  12. use syntect::parsing::SyntaxSet;
  13. use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
  14. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  15. pub struct ColorTheme {
  16. enabled: bool,
  17. heading: Color,
  18. emphasis: Color,
  19. strong: Color,
  20. inline_code: Color,
  21. link: Color,
  22. quote: Color,
  23. info: Color,
  24. warning: Color,
  25. success: Color,
  26. error: Color,
  27. spinner_active: Color,
  28. spinner_done: Color,
  29. spinner_failed: Color,
  30. }
  31. impl Default for ColorTheme {
  32. fn default() -> Self {
  33. Self {
  34. enabled: true,
  35. heading: Color::Blue,
  36. emphasis: Color::Blue,
  37. strong: Color::Yellow,
  38. inline_code: Color::Green,
  39. link: Color::Blue,
  40. quote: Color::DarkGrey,
  41. info: Color::Blue,
  42. warning: Color::Yellow,
  43. success: Color::Green,
  44. error: Color::Red,
  45. spinner_active: Color::Blue,
  46. spinner_done: Color::Green,
  47. spinner_failed: Color::Red,
  48. }
  49. }
  50. }
  51. impl ColorTheme {
  52. #[must_use]
  53. pub fn without_color() -> Self {
  54. Self {
  55. enabled: false,
  56. ..Self::default()
  57. }
  58. }
  59. #[must_use]
  60. pub fn enabled(&self) -> bool {
  61. self.enabled
  62. }
  63. }
  64. #[derive(Debug, Default, Clone, PartialEq, Eq)]
  65. pub struct Spinner {
  66. frame_index: usize,
  67. }
  68. impl Spinner {
  69. const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  70. #[must_use]
  71. pub fn new() -> Self {
  72. Self::default()
  73. }
  74. pub fn tick(
  75. &mut self,
  76. label: &str,
  77. theme: &ColorTheme,
  78. out: &mut impl Write,
  79. ) -> io::Result<()> {
  80. let frame = Self::FRAMES[self.frame_index % Self::FRAMES.len()];
  81. self.frame_index += 1;
  82. queue!(
  83. out,
  84. SavePosition,
  85. MoveToColumn(0),
  86. Clear(ClearType::CurrentLine)
  87. )?;
  88. if theme.enabled() {
  89. queue!(
  90. out,
  91. SetForegroundColor(theme.spinner_active),
  92. Print(format!("{frame} {label}")),
  93. ResetColor,
  94. RestorePosition
  95. )?;
  96. } else {
  97. queue!(out, Print(format!("{frame} {label}")), RestorePosition)?;
  98. }
  99. out.flush()
  100. }
  101. pub fn finish(
  102. &mut self,
  103. label: &str,
  104. theme: &ColorTheme,
  105. out: &mut impl Write,
  106. ) -> io::Result<()> {
  107. self.frame_index = 0;
  108. execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
  109. if theme.enabled() {
  110. execute!(
  111. out,
  112. SetForegroundColor(theme.spinner_done),
  113. Print(format!("✔ {label}\n")),
  114. ResetColor
  115. )?;
  116. } else {
  117. execute!(out, Print(format!("✔ {label}\n")))?;
  118. }
  119. out.flush()
  120. }
  121. pub fn fail(
  122. &mut self,
  123. label: &str,
  124. theme: &ColorTheme,
  125. out: &mut impl Write,
  126. ) -> io::Result<()> {
  127. self.frame_index = 0;
  128. execute!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
  129. if theme.enabled() {
  130. execute!(
  131. out,
  132. SetForegroundColor(theme.spinner_failed),
  133. Print(format!("✘ {label}\n")),
  134. ResetColor
  135. )?;
  136. } else {
  137. execute!(out, Print(format!("✘ {label}\n")))?;
  138. }
  139. out.flush()
  140. }
  141. }
  142. #[derive(Debug, Default, Clone, PartialEq, Eq)]
  143. struct RenderState {
  144. emphasis: usize,
  145. strong: usize,
  146. quote: usize,
  147. list: usize,
  148. }
  149. impl RenderState {
  150. fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
  151. if !theme.enabled() {
  152. return text.to_string();
  153. }
  154. if self.strong > 0 {
  155. format!("{}", text.bold().with(theme.strong))
  156. } else if self.emphasis > 0 {
  157. format!("{}", text.italic().with(theme.emphasis))
  158. } else if self.quote > 0 {
  159. format!("{}", text.with(theme.quote))
  160. } else {
  161. text.to_string()
  162. }
  163. }
  164. }
  165. #[derive(Debug)]
  166. pub struct TerminalRenderer {
  167. syntax_set: SyntaxSet,
  168. syntax_theme: Theme,
  169. color_theme: ColorTheme,
  170. }
  171. impl Default for TerminalRenderer {
  172. fn default() -> Self {
  173. let syntax_set = SyntaxSet::load_defaults_newlines();
  174. let syntax_theme = ThemeSet::load_defaults()
  175. .themes
  176. .remove("base16-ocean.dark")
  177. .unwrap_or_default();
  178. Self {
  179. syntax_set,
  180. syntax_theme,
  181. color_theme: ColorTheme::default(),
  182. }
  183. }
  184. }
  185. impl TerminalRenderer {
  186. #[must_use]
  187. pub fn new() -> Self {
  188. Self::default()
  189. }
  190. #[must_use]
  191. pub fn with_color(enabled: bool) -> Self {
  192. if enabled {
  193. Self::new()
  194. } else {
  195. Self {
  196. color_theme: ColorTheme::without_color(),
  197. ..Self::default()
  198. }
  199. }
  200. }
  201. #[must_use]
  202. pub fn color_theme(&self) -> &ColorTheme {
  203. &self.color_theme
  204. }
  205. fn paint(&self, text: impl AsRef<str>, color: Color) -> String {
  206. let text = text.as_ref();
  207. if self.color_theme.enabled() {
  208. format!("{}", text.with(color))
  209. } else {
  210. text.to_string()
  211. }
  212. }
  213. fn paint_bold(&self, text: impl AsRef<str>, color: Color) -> String {
  214. let text = text.as_ref();
  215. if self.color_theme.enabled() {
  216. format!("{}", text.bold().with(color))
  217. } else {
  218. text.to_string()
  219. }
  220. }
  221. fn paint_underlined(&self, text: impl AsRef<str>, color: Color) -> String {
  222. let text = text.as_ref();
  223. if self.color_theme.enabled() {
  224. format!("{}", text.underlined().with(color))
  225. } else {
  226. text.to_string()
  227. }
  228. }
  229. #[must_use]
  230. pub fn info(&self, text: impl AsRef<str>) -> String {
  231. self.paint(text, self.color_theme.info)
  232. }
  233. #[must_use]
  234. pub fn warning(&self, text: impl AsRef<str>) -> String {
  235. self.paint(text, self.color_theme.warning)
  236. }
  237. #[must_use]
  238. pub fn success(&self, text: impl AsRef<str>) -> String {
  239. self.paint(text, self.color_theme.success)
  240. }
  241. #[must_use]
  242. pub fn error(&self, text: impl AsRef<str>) -> String {
  243. self.paint(text, self.color_theme.error)
  244. }
  245. #[must_use]
  246. pub fn render_markdown(&self, markdown: &str) -> String {
  247. let mut output = String::new();
  248. let mut state = RenderState::default();
  249. let mut code_language = String::new();
  250. let mut code_buffer = String::new();
  251. let mut in_code_block = false;
  252. for event in Parser::new_ext(markdown, Options::all()) {
  253. self.render_event(
  254. event,
  255. &mut state,
  256. &mut output,
  257. &mut code_buffer,
  258. &mut code_language,
  259. &mut in_code_block,
  260. );
  261. }
  262. output.trim_end().to_string()
  263. }
  264. fn render_event(
  265. &self,
  266. event: Event<'_>,
  267. state: &mut RenderState,
  268. output: &mut String,
  269. code_buffer: &mut String,
  270. code_language: &mut String,
  271. in_code_block: &mut bool,
  272. ) {
  273. match event {
  274. Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
  275. Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
  276. Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
  277. Event::End(TagEnd::BlockQuote(..) | TagEnd::Item)
  278. | Event::SoftBreak
  279. | Event::HardBreak => output.push('\n'),
  280. Event::Start(Tag::List(_)) => state.list += 1,
  281. Event::End(TagEnd::List(..)) => {
  282. state.list = state.list.saturating_sub(1);
  283. output.push('\n');
  284. }
  285. Event::Start(Tag::Item) => Self::start_item(state, output),
  286. Event::Start(Tag::CodeBlock(kind)) => {
  287. *in_code_block = true;
  288. *code_language = match kind {
  289. CodeBlockKind::Indented => String::from("text"),
  290. CodeBlockKind::Fenced(lang) => lang.to_string(),
  291. };
  292. code_buffer.clear();
  293. self.start_code_block(code_language, output);
  294. }
  295. Event::End(TagEnd::CodeBlock) => {
  296. self.finish_code_block(code_buffer, code_language, output);
  297. *in_code_block = false;
  298. code_language.clear();
  299. code_buffer.clear();
  300. }
  301. Event::Start(Tag::Emphasis) => state.emphasis += 1,
  302. Event::End(TagEnd::Emphasis) => state.emphasis = state.emphasis.saturating_sub(1),
  303. Event::Start(Tag::Strong) => state.strong += 1,
  304. Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1),
  305. Event::Code(code) => {
  306. let _ = write!(
  307. output,
  308. "{}",
  309. self.paint(format!("`{code}`"), self.color_theme.inline_code)
  310. );
  311. }
  312. Event::Rule => output.push_str("---\n"),
  313. Event::Text(text) => {
  314. self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
  315. }
  316. Event::Html(html) | Event::InlineHtml(html) => output.push_str(&html),
  317. Event::FootnoteReference(reference) => {
  318. let _ = write!(output, "[{reference}]");
  319. }
  320. Event::TaskListMarker(done) => output.push_str(if done { "[x] " } else { "[ ] " }),
  321. Event::InlineMath(math) | Event::DisplayMath(math) => output.push_str(&math),
  322. Event::Start(Tag::Link { dest_url, .. }) => {
  323. let _ = write!(
  324. output,
  325. "{}",
  326. self.paint_underlined(format!("[{dest_url}]"), self.color_theme.link)
  327. );
  328. }
  329. Event::Start(Tag::Image { dest_url, .. }) => {
  330. let _ = write!(
  331. output,
  332. "{}",
  333. self.paint(format!("[image:{dest_url}]"), self.color_theme.link)
  334. );
  335. }
  336. Event::Start(
  337. Tag::Paragraph
  338. | Tag::Table(..)
  339. | Tag::TableHead
  340. | Tag::TableRow
  341. | Tag::TableCell
  342. | Tag::MetadataBlock(..)
  343. | _,
  344. )
  345. | Event::End(
  346. TagEnd::Link
  347. | TagEnd::Image
  348. | TagEnd::Table
  349. | TagEnd::TableHead
  350. | TagEnd::TableRow
  351. | TagEnd::TableCell
  352. | TagEnd::MetadataBlock(..)
  353. | _,
  354. ) => {}
  355. }
  356. }
  357. fn start_heading(&self, level: u8, output: &mut String) {
  358. output.push('\n');
  359. let prefix = match level {
  360. 1 => "# ",
  361. 2 => "## ",
  362. 3 => "### ",
  363. _ => "#### ",
  364. };
  365. let _ = write!(
  366. output,
  367. "{}",
  368. self.paint_bold(prefix, self.color_theme.heading)
  369. );
  370. }
  371. fn start_quote(&self, state: &mut RenderState, output: &mut String) {
  372. state.quote += 1;
  373. let _ = write!(output, "{}", self.paint("│ ", self.color_theme.quote));
  374. }
  375. fn start_item(state: &RenderState, output: &mut String) {
  376. output.push_str(&" ".repeat(state.list.saturating_sub(1)));
  377. output.push_str("• ");
  378. }
  379. fn start_code_block(&self, code_language: &str, output: &mut String) {
  380. if !code_language.is_empty() {
  381. let _ = writeln!(
  382. output,
  383. "{}",
  384. self.paint(format!("╭─ {code_language}"), self.color_theme.heading)
  385. );
  386. }
  387. }
  388. fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
  389. output.push_str(&self.highlight_code(code_buffer, code_language));
  390. if !code_language.is_empty() {
  391. let _ = write!(output, "{}", self.paint("╰─", self.color_theme.heading));
  392. }
  393. output.push_str("\n\n");
  394. }
  395. fn push_text(
  396. &self,
  397. text: &str,
  398. state: &RenderState,
  399. output: &mut String,
  400. code_buffer: &mut String,
  401. in_code_block: bool,
  402. ) {
  403. if in_code_block {
  404. code_buffer.push_str(text);
  405. } else {
  406. output.push_str(&state.style_text(text, &self.color_theme));
  407. }
  408. }
  409. #[must_use]
  410. pub fn highlight_code(&self, code: &str, language: &str) -> String {
  411. if !self.color_theme.enabled() {
  412. return code.to_string();
  413. }
  414. let syntax = self
  415. .syntax_set
  416. .find_syntax_by_token(language)
  417. .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
  418. let mut syntax_highlighter = HighlightLines::new(syntax, &self.syntax_theme);
  419. let mut colored_output = String::new();
  420. for line in LinesWithEndings::from(code) {
  421. match syntax_highlighter.highlight_line(line, &self.syntax_set) {
  422. Ok(ranges) => {
  423. colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
  424. }
  425. Err(_) => colored_output.push_str(line),
  426. }
  427. }
  428. colored_output
  429. }
  430. pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
  431. let rendered_markdown = self.render_markdown(markdown);
  432. for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
  433. write!(out, "{chunk}")?;
  434. out.flush()?;
  435. thread::sleep(Duration::from_millis(8));
  436. }
  437. writeln!(out)
  438. }
  439. #[must_use]
  440. pub fn token_usage_summary(&self, input_tokens: u64, output_tokens: u64) -> String {
  441. format!(
  442. "{} {} input / {} output",
  443. self.info("Token usage:"),
  444. input_tokens,
  445. output_tokens
  446. )
  447. }
  448. }
  449. #[cfg(test)]
  450. mod tests {
  451. use super::{Spinner, TerminalRenderer};
  452. fn strip_ansi(input: &str) -> String {
  453. let mut output = String::new();
  454. let mut chars = input.chars().peekable();
  455. while let Some(ch) = chars.next() {
  456. if ch == '\u{1b}' {
  457. if chars.peek() == Some(&'[') {
  458. chars.next();
  459. for next in chars.by_ref() {
  460. if next.is_ascii_alphabetic() {
  461. break;
  462. }
  463. }
  464. }
  465. } else {
  466. output.push(ch);
  467. }
  468. }
  469. output
  470. }
  471. #[test]
  472. fn renders_markdown_with_styling_and_lists() {
  473. let terminal_renderer = TerminalRenderer::new();
  474. let markdown_output = terminal_renderer
  475. .render_markdown("# Heading\n\nThis is **bold** and *italic*.\n\n- item\n\n`code`");
  476. assert!(markdown_output.contains("Heading"));
  477. assert!(markdown_output.contains("• item"));
  478. assert!(markdown_output.contains("code"));
  479. assert!(markdown_output.contains('\u{1b}'));
  480. }
  481. #[test]
  482. fn highlights_fenced_code_blocks() {
  483. let terminal_renderer = TerminalRenderer::new();
  484. let markdown_output =
  485. terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```");
  486. let plain_text = strip_ansi(&markdown_output);
  487. assert!(plain_text.contains("╭─ rust"));
  488. assert!(plain_text.contains("fn hi"));
  489. assert!(markdown_output.contains('\u{1b}'));
  490. }
  491. #[test]
  492. fn spinner_advances_frames() {
  493. let terminal_renderer = TerminalRenderer::new();
  494. let mut spinner = Spinner::new();
  495. let mut out = Vec::new();
  496. spinner
  497. .tick("Working", terminal_renderer.color_theme(), &mut out)
  498. .expect("tick succeeds");
  499. spinner
  500. .tick("Working", terminal_renderer.color_theme(), &mut out)
  501. .expect("tick succeeds");
  502. let output = String::from_utf8_lossy(&out);
  503. assert!(output.contains("Working"));
  504. }
  505. #[test]
  506. fn renderer_can_disable_color_output() {
  507. let terminal_renderer = TerminalRenderer::with_color(false);
  508. let markdown_output = terminal_renderer.render_markdown(
  509. "# Heading\n\nThis is **bold** and `code`.\n\n```rust\nfn hi() {}\n```",
  510. );
  511. assert!(!markdown_output.contains('\u{1b}'));
  512. assert!(markdown_output.contains("Heading"));
  513. assert!(markdown_output.contains("fn hi() {}"));
  514. }
  515. #[test]
  516. fn token_usage_summary_uses_plain_text_without_color() {
  517. let terminal_renderer = TerminalRenderer::with_color(false);
  518. assert_eq!(
  519. terminal_renderer.token_usage_summary(12, 34),
  520. "Token usage: 12 input / 34 output"
  521. );
  522. }
  523. }