| 1 | //! Streaming markdown wrapper around the [`crate::render`] module. |
| 2 | //! |
| 3 | //! The block/inline rendering logic now lives in [`crate::render`] (a port of |
| 4 | //! the fx coding agent's `presentation/` layer). This module keeps the |
| 5 | //! [`MarkdownStream`] interface the TUI already depends on — feeding chunks |
| 6 | //! character-by-character and rendering partial in-progress lines — but |
| 7 | //! delegates the per-line styling to [`crate::render`]. Code fences buffer |
| 8 | //! their lines and render as fx's boxed panel (`┌ lang ─┐ … └──┘`) once the |
| 9 | //! closing fence lands; while streaming, lines show as dim `│ ` rows exactly |
| 10 | //! like fx's fallback. |
| 11 | |
| 12 | use std::io::Write |
| 13 | |
| 14 | use crate::render; |
| 15 | use crate::theme::{RESET |
| 16 | |
| 17 | pub struct MarkdownStream |
| 18 | out: W |
| 19 | cols: usize |
| 20 | /// Inside a fenced code block: language tag and buffered body lines. |
| 21 | in_code: bool |
| 22 | code_lang: String |
| 23 | code_lines: Vec |
| 24 | line: String |
| 25 | theme: Theme |
| 26 | } |
| 27 | |
| 28 | impl<W: Write |
| 29 | pub fn new(out |
| 30 | MarkdownStream { |
| 31 | out, |
| 32 | cols: cols.max |
| 33 | in_code: false, |
| 34 | code_lang: String |
| 35 | code_lines: Vec |
| 36 | line: String |
| 37 | theme, |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | pub fn set_theme(& |
| 42 | self.theme = theme |
| 43 | } |
| 44 | |
| 45 | pub fn feed(& |
| 46 | for c in render |
| 47 | match c { |
| 48 | '\n' => { |
| 49 | let line = |
| 50 | self.emit_line(&line |
| 51 | } |
| 52 | '\r' => {} |
| 53 | '\t' => self |
| 54 | c => self. |
| 55 | } |
| 56 | } |
| 57 | self.out.flush() |
| 58 | Ok(()) |
| 59 | } |
| 60 | |
| 61 | pub fn finish(& |
| 62 | if !self.line. |
| 63 | let line = |
| 64 | self.emit_line(&line |
| 65 | } |
| 66 | // An unclosed fence at end-of-stream still renders its panel so the |
| 67 | // transcript never shows a half-open block. |
| 68 | if self.in_code { |
| 69 | self.flush_code_panel()?; |
| 70 | } |
| 71 | self.out.flush() |
| 72 | Ok(()) |
| 73 | } |
| 74 | |
| 75 | /// True when an in-progress line exists that `render_partial` can show. |
| 76 | pub fn has_partial(& |
| 77 | !self.line.is_empty |
| 78 | } |
| 79 | |
| 80 | /// Render the current in-progress line styled for its context, WITHOUT |
| 81 | /// mutating any state (fences/headings re-style correctly when the line |
| 82 | /// completes and `emit_line` takes over). The caller owns replacing the |
| 83 | /// previous partial render, so a streamed line reads character-by-character |
| 84 | /// instead of popping in whole only when a newline lands. Exactly one line |
| 85 | /// is written, matching the one-line invariant of `emit_line`. |
| 86 | pub fn render_partial(& |
| 87 | let line = |
| 88 | if line.is_empty() |
| 89 | return Ok(()); |
| 90 | } |
| 91 | // Inside a fence, fx streams each code line behind a dim vertical |
| 92 | // rule; the boxed panel replaces it once the block closes. |
| 93 | if self.in_code { |
| 94 | return self.wln( |
| 95 | } |
| 96 | // Delegate to the renderer for a single line so the partial render |
| 97 | // matches the final styled output exactly. No state is mutated: a |
| 98 | // fence-opening line will be re-evaluated when the newline lands and |
| 99 | // emit_line toggles in_code. |
| 100 | let styled = |
| 101 | // render_markdown always appends a trailing newline; strip it since |
| 102 | // wln adds its own CRLF. |
| 103 | let styled = |
| 104 | self.wln(styled |
| 105 | } |
| 106 | |
| 107 | pub fn text(& |
| 108 | self.feed(text |
| 109 | self.finish() |
| 110 | } |
| 111 | |
| 112 | pub fn output(& |
| 113 | &self.out |
| 114 | } |
| 115 | |
| 116 | pub fn output_mut(& |
| 117 | &mut self.out |
| 118 | } |
| 119 | |
| 120 | pub fn set_cols(& |
| 121 | self.cols = cols |
| 122 | } |
| 123 | |
| 124 | fn wln(&mut |
| 125 | write!(self.out |
| 126 | } |
| 127 | |
| 128 | fn emit_line(&mut |
| 129 | let line = |
| 130 | let t = |
| 131 | |
| 132 | // Code fence open/close: buffer the block and render fx's panel. |
| 133 | if let Some |
| 134 | let _ = |
| 135 | if self.in_code { |
| 136 | self.in_code = false; |
| 137 | let lang = |
| 138 | let lines = |
| 139 | return self.write_code_panel( |
| 140 | } |
| 141 | self.in_code = true; |
| 142 | self.code_lang = crate:: |
| 143 | self.code_lines.clear(); |
| 144 | return Ok(()); |
| 145 | } |
| 146 | if self.in_code { |
| 147 | self.code_lines.push( |
| 148 | return Ok(()); |
| 149 | } |
| 150 | |
| 151 | // For all other block types, delegate the styling to the renderer. |
| 152 | // render_markdown expects a full document, so we pass the single line |
| 153 | // and strip the trailing newline it appends. This keeps the partial |
| 154 | // and final render paths identical. |
| 155 | let styled = |
| 156 | let styled = |
| 157 | self.wln(styled |
| 158 | } |
| 159 | |
| 160 | /// fx's boxed code panel (`transcript_blocks.zig`): |
| 161 | /// `┌ {dim}lang{reset} ─…┐` |
| 162 | /// `│ {code padded} │` |
| 163 | /// `└─…┘` |
| 164 | /// Falls back to unboxed rows when the terminal is too narrow. |
| 165 | fn write_code_panel(&mut |
| 166 | let visible = |
| 167 | let max_line = |
| 168 | let lang_w = |
| 169 | // Inner content width: fits the widest line and the language tag, |
| 170 | // clamped to the available columns (fx falls back unboxed ≤ 5 cols). |
| 171 | let w = |
| 172 | .max(lang_w) |
| 173 | .clamp(1, self |
| 174 | if self.cols <= |
| 175 | for l in lines |
| 176 | self.wln(&format! |
| 177 | } |
| 178 | return Ok(()); |
| 179 | } |
| 180 | // Top: `┌ {dim}lang{reset} {dashes}┐` — dashes fill up to the shared width. |
| 181 | let dash_n = |
| 182 | self.wln(&format! |
| 183 | for l in lines |
| 184 | // Truncate to the shared width so long lines cannot overflow the |
| 185 | // `┌─/└─` border; pad shorter lines so the closing `│` aligns. |
| 186 | // The DIM style is reset before the closing bar so neither the |
| 187 | // padding nor the bar inherits it. |
| 188 | let truncated: String |
| 189 | let pad = |
| 190 | self.wln(&format! |
| 191 | "│ {}{}{}{} │", |
| 192 | crate::fx::DIM, |
| 193 | truncated, |
| 194 | " ".repeat(pad |
| 195 | RESET |
| 196 | ))?; |
| 197 | } |
| 198 | self.wln(&format! |
| 199 | Ok(()) |
| 200 | } |
| 201 | |
| 202 | fn flush_code_panel(&mut |
| 203 | if !self.in_code { |
| 204 | return Ok(()); |
| 205 | } |
| 206 | self.in_code = false; |
| 207 | let lang = |
| 208 | let lines = |
| 209 | self.write_code_panel(&lang |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | /// Strip ANSI escape sequences from a string. Re-exported from [`render`] for |
| 214 | /// backward compatibility with existing callers. |
| 215 | pub(crate) fn strip_ansi |
| 216 | render::strip_ansi(s |
| 217 | } |
| 218 | |
| 219 | #[cfg(test)] |
| 220 | mod tests { |
| 221 | use super::*; |
| 222 | |
| 223 | fn render(text: |
| 224 | let mut buf |
| 225 | let mut md |
| 226 | md.text(text |
| 227 | String::from_utf8( |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn strips_ansi_from_input() { |
| 232 | assert_eq!(strip_ansi( |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn plain_text_passes_through() { |
| 237 | let out = |
| 238 | assert!(out.contains |
| 239 | } |
| 240 | |
| 241 | #[test] |
| 242 | fn crlf_newlines() { |
| 243 | let out = |
| 244 | assert!(out.contains |
| 245 | assert!(!out. |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn bold_and_code() { |
| 250 | let out = |
| 251 | assert!(out.contains |
| 252 | assert!(out.contains |
| 253 | } |
| 254 | |
| 255 | #[test] |
| 256 | fn heading() { |
| 257 | let out = |
| 258 | assert!(out.contains |
| 259 | } |
| 260 | |
| 261 | #[test] |
| 262 | fn code_block_renders_fx_boxed_panel() { |
| 263 | let out = |
| 264 | // fx panel: `┌ rust ─┐` header, dim `│ ` body rows, `└──┘` footer. |
| 265 | assert!(out.contains |
| 266 | assert!(out.contains |
| 267 | assert!(out.contains |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn bullet_and_numbered() { |
| 272 | let out = |
| 273 | // fx bytes: dim-wrapped markers, then plain content. |
| 274 | assert!(out.contains |
| 275 | assert!(out.contains |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn quote_and_hr() { |
| 280 | let out = |
| 281 | assert!(out.contains |
| 282 | assert!(out.contains |
| 283 | } |
| 284 | |
| 285 | #[test] |
| 286 | fn unclosed_fence_still_renders_panel_at_finish() { |
| 287 | let out = |
| 288 | assert!(out.contains |
| 289 | assert!(out.contains |
| 290 | } |
| 291 | } |