Skip to main content

slt/context/widgets_display/
rich_output.rs

1use super::*;
2
3impl Context {
4    /// Render 8x8 bitmap text as half-block pixels (4 terminal rows tall).
5    pub fn big_text(&mut self, s: impl Into<String>) -> Response {
6        let text = s.into();
7        let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
8        let total_width = (glyphs.len() as u32).saturating_mul(8);
9        let on_color = self.theme.primary;
10
11        self.container().w(total_width).h(4).draw(move |buf, rect| {
12            if rect.width == 0 || rect.height == 0 {
13                return;
14            }
15
16            for (glyph_idx, glyph) in glyphs.iter().enumerate() {
17                let base_x = rect.x + (glyph_idx as u32) * 8;
18                if base_x >= rect.right() {
19                    break;
20                }
21
22                for pair in 0..4usize {
23                    let y = rect.y + pair as u32;
24                    if y >= rect.bottom() {
25                        continue;
26                    }
27
28                    let upper = glyph[pair * 2];
29                    let lower = glyph[pair * 2 + 1];
30
31                    for bit in 0..8u32 {
32                        let x = base_x + bit;
33                        if x >= rect.right() {
34                            break;
35                        }
36
37                        let mask = 1u8 << (bit as u8);
38                        let upper_on = (upper & mask) != 0;
39                        let lower_on = (lower & mask) != 0;
40                        let (ch, fg, bg) = match (upper_on, lower_on) {
41                            (true, true) => ('█', on_color, on_color),
42                            (true, false) => ('▀', on_color, Color::Reset),
43                            (false, true) => ('▄', on_color, Color::Reset),
44                            (false, false) => (' ', Color::Reset, Color::Reset),
45                        };
46                        buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
47                    }
48                }
49            }
50        });
51
52        Response::none()
53    }
54
55    /// Render a half-block image in the terminal.
56    ///
57    /// Each terminal cell displays two vertical pixels using the `▀` character
58    /// with foreground (upper pixel) and background (lower pixel) colors.
59    ///
60    /// Create a [`HalfBlockImage`] from a file (requires `image` feature):
61    /// ```ignore
62    /// let img = image::open("photo.png").unwrap();
63    /// let half = HalfBlockImage::from_dynamic(&img, 40, 20);
64    /// ui.image(&half);
65    /// ```
66    ///
67    /// Or from raw RGB data (no feature needed):
68    /// ```no_run
69    /// # use slt::{Context, HalfBlockImage};
70    /// # slt::run(|ui: &mut Context| {
71    /// let rgb = vec![255u8; 30 * 20 * 3];
72    /// let half = HalfBlockImage::from_rgb(&rgb, 30, 10);
73    /// ui.image(&half);
74    /// # });
75    /// ```
76    pub fn image(&mut self, img: &HalfBlockImage) -> Response {
77        let pixels: Vec<(Color, Color)> = img.pixels.clone();
78        let (w, h) = (img.width, img.height);
79        self.container().w(w).h(h).draw(move |buf, rect| {
80            for row in 0..h {
81                for col in 0..w {
82                    if let Some(&(fg, bg)) = pixels.get((row * w + col) as usize) {
83                        buf.set_char(rect.x + col, rect.y + row, '▀', Style::new().fg(fg).bg(bg));
84                    }
85                }
86            }
87        });
88
89        Response::none()
90    }
91
92    /// Render a pixel-perfect image using the Kitty graphics protocol.
93    ///
94    /// The image data must be raw RGBA bytes (4 bytes per pixel).
95    /// The widget allocates `cols` x `rows` cells and renders the image
96    /// at full pixel resolution within that space.
97    ///
98    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
99    /// On unsupported terminals, the area will be blank.
100    ///
101    /// # Arguments
102    /// * `rgba` - Raw RGBA pixel data
103    /// * `pixel_width` - Image width in pixels
104    /// * `pixel_height` - Image height in pixels
105    /// * `cols` - Terminal cell columns to occupy
106    /// * `rows` - Terminal cell rows to occupy
107    pub fn kitty_image(
108        &mut self,
109        rgba: &[u8],
110        pixel_width: u32,
111        pixel_height: u32,
112        cols: u32,
113        rows: u32,
114    ) -> Response {
115        let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
116        let content_hash = crate::buffer::hash_rgba(&rgba_data);
117        let rgba_arc = std::sync::Arc::new(rgba_data);
118        let sw = pixel_width;
119        let sh = pixel_height;
120
121        self.container().w(cols).h(rows).draw(move |buf, rect| {
122            if rect.width == 0 || rect.height == 0 {
123                return;
124            }
125            buf.kitty_place(crate::buffer::KittyPlacement {
126                content_hash,
127                rgba: rgba_arc.clone(),
128                src_width: sw,
129                src_height: sh,
130                x: rect.x,
131                y: rect.y,
132                cols: rect.width,
133                rows: rect.height,
134                crop_y: 0,
135                crop_h: 0,
136            });
137        });
138        Response::none()
139    }
140
141    /// Render a pixel-perfect image that preserves aspect ratio.
142    ///
143    /// Sends the original RGBA data to the terminal and lets the Kitty
144    /// protocol handle scaling. The container width is `cols` cells;
145    /// height is calculated automatically from the image aspect ratio
146    /// using detected cell pixel dimensions (falls back to 8×16 if
147    /// detection fails).
148    ///
149    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
150    pub fn kitty_image_fit(
151        &mut self,
152        rgba: &[u8],
153        src_width: u32,
154        src_height: u32,
155        cols: u32,
156    ) -> Response {
157        #[cfg(feature = "crossterm")]
158        let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
159        #[cfg(not(feature = "crossterm"))]
160        let (cell_w, cell_h) = (8u32, 16u32);
161
162        let rows = if src_width == 0 {
163            1
164        } else {
165            ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
166                .ceil()
167                .max(1.0) as u32
168        };
169        let rgba_data = normalize_rgba(rgba, src_width, src_height);
170        let content_hash = crate::buffer::hash_rgba(&rgba_data);
171        let rgba_arc = std::sync::Arc::new(rgba_data);
172        let sw = src_width;
173        let sh = src_height;
174
175        self.container().w(cols).h(rows).draw(move |buf, rect| {
176            if rect.width == 0 || rect.height == 0 {
177                return;
178            }
179            buf.kitty_place(crate::buffer::KittyPlacement {
180                content_hash,
181                rgba: rgba_arc.clone(),
182                src_width: sw,
183                src_height: sh,
184                x: rect.x,
185                y: rect.y,
186                cols: rect.width,
187                rows: rect.height,
188                crop_y: 0,
189                crop_h: 0,
190            });
191        });
192        Response::none()
193    }
194
195    /// Render an image using the Sixel protocol.
196    ///
197    /// `rgba` is raw RGBA pixel data, `pixel_width`/`pixel_height` are pixel dimensions,
198    /// and `cols`/`rows` are the terminal cell size to reserve for the image.
199    ///
200    /// Requires the `crossterm` feature (enabled by default). Falls back to
201    /// `[sixel unsupported]` on terminals without Sixel support. Set the
202    /// `SLT_FORCE_SIXEL=1` environment variable to skip terminal detection.
203    ///
204    /// # Example
205    ///
206    /// ```no_run
207    /// # slt::run(|ui: &mut slt::Context| {
208    /// // 2x2 red square (RGBA: 4 pixels × 4 bytes)
209    /// let rgba = [255u8, 0, 0, 255].repeat(4);
210    /// ui.sixel_image(&rgba, 2, 2, 20, 2);
211    /// # });
212    /// ```
213    #[cfg(feature = "crossterm")]
214    #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
215    pub fn sixel_image(
216        &mut self,
217        rgba: &[u8],
218        pixel_width: u32,
219        pixel_height: u32,
220        cols: u32,
221        rows: u32,
222    ) -> Response {
223        // Issue #264: consult the negotiated capability snapshot first (the
224        // DA1 probe is authoritative when it answered). Fall back to the env
225        // allowlist (now including WezTerm/Ghostty) / `SLT_FORCE_SIXEL` when the
226        // probe returned unknown. App code never selects a protocol — the
227        // blitter ladder resolves it.
228        let sixel_supported =
229            self.is_real_terminal && (self.capabilities.sixel || terminal_supports_sixel());
230        if !sixel_supported {
231            self.container().w(cols).h(rows).draw(|buf, rect| {
232                if rect.width == 0 || rect.height == 0 {
233                    return;
234                }
235                buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
236            });
237            return Response::none();
238        }
239
240        let rgba = normalize_rgba(rgba, pixel_width, pixel_height);
241        let content_hash = crate::buffer::hash_rgba(&rgba);
242        let encoded = crate::sixel::encode_sixel(&rgba, pixel_width, pixel_height, 256);
243
244        if encoded.is_empty() {
245            self.container().w(cols).h(rows).draw(|buf, rect| {
246                if rect.width == 0 || rect.height == 0 {
247                    return;
248                }
249                buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
250            });
251            return Response::none();
252        }
253
254        // Issue #265: route through the sprixel damage matrix instead of a flat
255        // `raw_sequence`, so a text edit adjacent to the image no longer forces
256        // a full re-blit. The footprint is recorded as fully `Opaque`; the flush
257        // layer flips cells to `Annihilated` only where text overwrites ink.
258        self.container().w(cols).h(rows).draw(move |buf, rect| {
259            if rect.width == 0 || rect.height == 0 {
260                return;
261            }
262            let cells = (rect.width as usize).saturating_mul(rect.height as usize);
263            buf.sprixel_place(crate::buffer::SprixelPlacement {
264                content_hash,
265                seq: encoded,
266                x: rect.x,
267                y: rect.y,
268                cols: rect.width,
269                rows: rect.height,
270                cells: vec![crate::buffer::SprixelCell::Opaque; cells],
271            });
272        });
273        Response::none()
274    }
275
276    /// Render an image via iTerm2's OSC 1337 inline-image protocol.
277    ///
278    /// Unlike [`Context::kitty_image`] (raw RGBA) or [`Context::sixel_image`]
279    /// (raw RGBA, quantized), `data` is **encoded image-file bytes**
280    /// (PNG/JPEG/GIF): the terminal decodes and scales the file itself. This is
281    /// the pixel-accurate path on Tabby, older iTerm2 builds, and WezTerm's
282    /// iTerm2-compat mode (issue #265).
283    ///
284    /// `cols`/`rows` reserve the cell box for the image. On a terminal without
285    /// OSC 1337 support the area is reserved and `[iterm2 unsupported]` is
286    /// drawn, mirroring the Sixel fallback. Set `SLT_FORCE_ITERM=1` to skip
287    /// detection.
288    ///
289    /// # Example
290    ///
291    /// ```no_run
292    /// # slt::run(|ui: &mut slt::Context| {
293    /// // `png` holds encoded PNG bytes loaded from disk or memory.
294    /// let png = [0x89u8, b'P', b'N', b'G'];
295    /// ui.iterm_image(&png, 20, 4);
296    /// # });
297    /// ```
298    #[cfg(feature = "crossterm")]
299    #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
300    pub fn iterm_image(&mut self, data: &[u8], cols: u32, rows: u32) -> Response {
301        // Issue #264 ladder integration: consult the negotiated capability
302        // snapshot first, then the env allowlist / `SLT_FORCE_ITERM`. App code
303        // never selects a protocol directly.
304        let supported =
305            self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
306        if !supported {
307            return self.iterm_placeholder(cols, rows);
308        }
309
310        let content_hash = crate::buffer::hash_rgba(data);
311        let encoded = crate::iterm::encode_iterm_osc1337(data, cols, rows, false);
312        if encoded.is_empty() {
313            return self.iterm_placeholder(cols, rows);
314        }
315
316        self.container().w(cols).h(rows).draw(move |buf, rect| {
317            if rect.width == 0 || rect.height == 0 {
318                return;
319            }
320            let cells = (rect.width as usize).saturating_mul(rect.height as usize);
321            buf.sprixel_place(crate::buffer::SprixelPlacement {
322                content_hash,
323                seq: encoded,
324                x: rect.x,
325                y: rect.y,
326                cols: rect.width,
327                rows: rect.height,
328                cells: vec![crate::buffer::SprixelCell::Opaque; cells],
329            });
330        });
331        Response::none()
332    }
333
334    /// Render an iTerm2 OSC 1337 inline image preserving aspect ratio.
335    ///
336    /// `data` is **encoded image-file bytes** (PNG/JPEG/GIF). The container is
337    /// `cols` cells wide; height is reserved from the detected cell pixel
338    /// dimensions (falling back to 8×16) and the OSC 1337 `height=auto` /
339    /// `preserveAspectRatio=1` flags let the terminal scale to fit. Mirrors
340    /// [`Context::kitty_image_fit`] (issue #265).
341    ///
342    /// Falls back to `[iterm2 unsupported]` on terminals without OSC 1337.
343    ///
344    /// # Example
345    ///
346    /// ```no_run
347    /// # slt::run(|ui: &mut slt::Context| {
348    /// let png = [0x89u8, b'P', b'N', b'G'];
349    /// ui.iterm_image_fit(&png, 20);
350    /// # });
351    /// ```
352    #[cfg(feature = "crossterm")]
353    #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
354    pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
355        let supported =
356            self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
357
358        // Reserve rows from the cell aspect using a 1:1 source assumption — the
359        // terminal does the real aspect math from the decoded file; this only
360        // sizes the cell box so layout below the image is not clobbered. Use a
361        // square-ish default of `cols / 2` rows (cells are ~2:1 tall), min 1.
362        let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
363        let rows = if cell_h == 0 {
364            cols.max(1)
365        } else {
366            ((cols as f64 * cell_w as f64) / cell_h as f64)
367                .ceil()
368                .max(1.0) as u32
369        };
370
371        if !supported {
372            return self.iterm_placeholder(cols, rows);
373        }
374
375        let content_hash = crate::buffer::hash_rgba(data);
376        // `rows == 0` signals `height=auto`; the reserved cell box is `rows`.
377        let encoded = crate::iterm::encode_iterm_osc1337(data, cols, 0, true);
378        if encoded.is_empty() {
379            return self.iterm_placeholder(cols, rows);
380        }
381
382        self.container().w(cols).h(rows).draw(move |buf, rect| {
383            if rect.width == 0 || rect.height == 0 {
384                return;
385            }
386            let cells = (rect.width as usize).saturating_mul(rect.height as usize);
387            buf.sprixel_place(crate::buffer::SprixelPlacement {
388                content_hash,
389                seq: encoded,
390                x: rect.x,
391                y: rect.y,
392                cols: rect.width,
393                rows: rect.height,
394                cells: vec![crate::buffer::SprixelCell::Opaque; cells],
395            });
396        });
397        Response::none()
398    }
399
400    /// Reserve a `cols`×`rows` container and draw the unsupported placeholder,
401    /// matching the Sixel fallback pattern.
402    #[cfg(feature = "crossterm")]
403    fn iterm_placeholder(&mut self, cols: u32, rows: u32) -> Response {
404        self.container().w(cols).h(rows).draw(|buf, rect| {
405            if rect.width == 0 || rect.height == 0 {
406                return;
407            }
408            buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
409        });
410        Response::none()
411    }
412
413    /// Render an image via iTerm2's OSC 1337 inline-image protocol.
414    #[cfg(not(feature = "crossterm"))]
415    pub fn iterm_image(&mut self, _data: &[u8], cols: u32, rows: u32) -> Response {
416        self.container().w(cols).h(rows).draw(|buf, rect| {
417            if rect.width == 0 || rect.height == 0 {
418                return;
419            }
420            buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
421        });
422        Response::none()
423    }
424
425    /// Render an iTerm2 OSC 1337 inline image preserving aspect ratio.
426    #[cfg(not(feature = "crossterm"))]
427    pub fn iterm_image_fit(&mut self, _data: &[u8], cols: u32) -> Response {
428        // Without `crossterm` there is no cell-pixel probe; reserve a
429        // square-ish box so layout below the image is stable.
430        let rows = (cols / 2).max(1);
431        self.container().w(cols).h(rows).draw(|buf, rect| {
432            if rect.width == 0 || rect.height == 0 {
433                return;
434            }
435            buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
436        });
437        Response::none()
438    }
439
440    /// Render an image using the Sixel protocol.
441    #[cfg(not(feature = "crossterm"))]
442    pub fn sixel_image(
443        &mut self,
444        _rgba: &[u8],
445        _pixel_width: u32,
446        _pixel_height: u32,
447        cols: u32,
448        rows: u32,
449    ) -> Response {
450        self.container().w(cols).h(rows).draw(|buf, rect| {
451            if rect.width == 0 || rect.height == 0 {
452                return;
453            }
454            buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
455        });
456        Response::none()
457    }
458
459    /// Render streaming text with a typing cursor indicator.
460    ///
461    /// Displays the accumulated text content. While `streaming` is true,
462    /// shows a blinking cursor (`▌`) at the end.
463    ///
464    /// ```no_run
465    /// # use slt::widgets::StreamingTextState;
466    /// # slt::run(|ui: &mut slt::Context| {
467    /// let mut stream = StreamingTextState::new();
468    /// stream.start();
469    /// stream.push("Hello from ");
470    /// stream.push("the AI!");
471    /// ui.streaming_text(&mut stream);
472    /// # });
473    /// ```
474    pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
475        if state.streaming {
476            state.cursor_tick = state.cursor_tick.wrapping_add(1);
477            state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
478        }
479
480        if state.content.is_empty() && state.streaming {
481            let cursor = if state.cursor_visible { "▌" } else { " " };
482            let primary = self.theme.primary;
483            self.text(cursor).fg(primary);
484            return Response::none();
485        }
486
487        if !state.content.is_empty() {
488            self.text(&state.content).wrap();
489            if state.streaming && state.cursor_visible {
490                let primary = self.theme.primary;
491                self.styled("▌", Style::new().fg(primary));
492            }
493        }
494
495        Response::none()
496    }
497
498    /// Render streaming markdown with a typing cursor indicator.
499    ///
500    /// Parses accumulated markdown content line-by-line while streaming.
501    /// Supports headings, lists, inline formatting, horizontal rules, and
502    /// fenced code blocks with open/close tracking across stream chunks.
503    ///
504    /// ```no_run
505    /// # use slt::widgets::StreamingMarkdownState;
506    /// # slt::run(|ui: &mut slt::Context| {
507    /// let mut stream = StreamingMarkdownState::new();
508    /// stream.start();
509    /// stream.push("# Hello\n");
510    /// stream.push("- **streaming** markdown\n");
511    /// stream.push("```rust\nlet x = 1;\n");
512    /// ui.streaming_markdown(&mut stream);
513    /// # });
514    /// ```
515    pub fn streaming_markdown(
516        &mut self,
517        state: &mut crate::widgets::StreamingMarkdownState,
518    ) -> Response {
519        if state.streaming {
520            state.cursor_tick = state.cursor_tick.wrapping_add(1);
521            state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
522        }
523
524        if state.content.is_empty() && state.streaming {
525            let cursor = if state.cursor_visible { "▌" } else { " " };
526            let primary = self.theme.primary;
527            self.text(cursor).fg(primary);
528            return Response::none();
529        }
530
531        let show_cursor = state.streaming && state.cursor_visible;
532        let trailing_newline = state.content.ends_with('\n');
533        let lines: Vec<&str> = state.content.lines().collect();
534        let last_line_index = lines.len().saturating_sub(1);
535
536        self.commands
537            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
538                direction: Direction::Column,
539                gap: 0,
540                align: Align::Start,
541                align_self: None,
542                justify: Justify::Start,
543                border: None,
544                border_sides: BorderSides::all(),
545                border_style: Style::new().fg(self.theme.border),
546                bg_color: None,
547                padding: Padding::default(),
548                margin: Margin::default(),
549                constraints: Constraints::default(),
550                title: None,
551                grow: 0,
552                group_name: None,
553            })));
554        self.skip_interaction_slot();
555
556        let text_style = Style::new().fg(self.theme.text);
557        let bold_style = Style::new().fg(self.theme.text).bold();
558        let code_style = Style::new().fg(self.theme.accent);
559        let border_style = Style::new().fg(self.theme.border).dim();
560
561        let mut in_code_block = false;
562        let mut code_block_lang = String::new();
563
564        for (idx, line) in lines.iter().enumerate() {
565            let line = *line;
566            let trimmed = line.trim();
567            let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
568            let cursor = if append_cursor { "▌" } else { "" };
569
570            if in_code_block {
571                if trimmed.starts_with("```") {
572                    in_code_block = false;
573                    code_block_lang.clear();
574                    let mut line = String::from("  └────");
575                    line.push_str(cursor);
576                    self.styled(line, border_style);
577                } else {
578                    self.line(|ui| {
579                        ui.text("  ");
580                        render_highlighted_line(ui, line);
581                        if !cursor.is_empty() {
582                            ui.styled(cursor, Style::new().fg(ui.theme.primary));
583                        }
584                    });
585                }
586                continue;
587            }
588
589            if trimmed.is_empty() {
590                if append_cursor {
591                    self.styled("▌", Style::new().fg(self.theme.primary));
592                } else {
593                    self.text(" ");
594                }
595                continue;
596            }
597
598            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
599                let mut line = "─".repeat(40);
600                line.push_str(cursor);
601                self.styled(line, border_style);
602                continue;
603            }
604
605            if let Some(heading) = trimmed.strip_prefix("### ") {
606                let mut line = String::with_capacity(heading.len() + cursor.len());
607                line.push_str(heading);
608                line.push_str(cursor);
609                self.styled(line, Style::new().bold().fg(self.theme.accent));
610                continue;
611            }
612
613            if let Some(heading) = trimmed.strip_prefix("## ") {
614                let mut line = String::with_capacity(heading.len() + cursor.len());
615                line.push_str(heading);
616                line.push_str(cursor);
617                self.styled(line, Style::new().bold().fg(self.theme.secondary));
618                continue;
619            }
620
621            if let Some(heading) = trimmed.strip_prefix("# ") {
622                let mut line = String::with_capacity(heading.len() + cursor.len());
623                line.push_str(heading);
624                line.push_str(cursor);
625                self.styled(line, Style::new().bold().fg(self.theme.primary));
626                continue;
627            }
628
629            if let Some(code) = trimmed.strip_prefix("```") {
630                in_code_block = true;
631                code_block_lang = code.trim().to_string();
632                let label = if code_block_lang.is_empty() {
633                    "code".to_string()
634                } else {
635                    let mut label = String::from("code:");
636                    label.push_str(&code_block_lang);
637                    label
638                };
639                let mut line = String::with_capacity(5 + label.len() + cursor.len());
640                line.push_str("  ┌─");
641                line.push_str(&label);
642                line.push('─');
643                line.push_str(cursor);
644                self.styled(line, border_style);
645                continue;
646            }
647
648            if let Some(item) = trimmed
649                .strip_prefix("- ")
650                .or_else(|| trimmed.strip_prefix("* "))
651            {
652                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
653                if segs.len() <= 1 {
654                    let mut line = String::with_capacity(4 + item.len() + cursor.len());
655                    line.push_str("  • ");
656                    line.push_str(item);
657                    line.push_str(cursor);
658                    self.styled(line, text_style);
659                } else {
660                    self.line(|ui| {
661                        ui.styled("  • ", text_style);
662                        for (s, st) in segs {
663                            ui.styled(s, st);
664                        }
665                        if append_cursor {
666                            ui.styled("▌", Style::new().fg(ui.theme.primary));
667                        }
668                    });
669                }
670                continue;
671            }
672
673            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
674                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
675                if parts.len() == 2 {
676                    let segs =
677                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
678                    if segs.len() <= 1 {
679                        let mut line = String::with_capacity(
680                            4 + parts[0].len() + parts[1].len() + cursor.len(),
681                        );
682                        line.push_str("  ");
683                        line.push_str(parts[0]);
684                        line.push_str(". ");
685                        line.push_str(parts[1]);
686                        line.push_str(cursor);
687                        self.styled(line, text_style);
688                    } else {
689                        self.line(|ui| {
690                            let mut prefix = String::with_capacity(4 + parts[0].len());
691                            prefix.push_str("  ");
692                            prefix.push_str(parts[0]);
693                            prefix.push_str(". ");
694                            ui.styled(prefix, text_style);
695                            for (s, st) in segs {
696                                ui.styled(s, st);
697                            }
698                            if append_cursor {
699                                ui.styled("▌", Style::new().fg(ui.theme.primary));
700                            }
701                        });
702                    }
703                } else {
704                    let mut line = String::with_capacity(trimmed.len() + cursor.len());
705                    line.push_str(trimmed);
706                    line.push_str(cursor);
707                    self.styled(line, text_style);
708                }
709                continue;
710            }
711
712            let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
713            if segs.len() <= 1 {
714                let mut line = String::with_capacity(trimmed.len() + cursor.len());
715                line.push_str(trimmed);
716                line.push_str(cursor);
717                self.styled(line, text_style);
718            } else {
719                self.line(|ui| {
720                    for (s, st) in segs {
721                        ui.styled(s, st);
722                    }
723                    if append_cursor {
724                        ui.styled("▌", Style::new().fg(ui.theme.primary));
725                    }
726                });
727            }
728        }
729
730        if show_cursor && trailing_newline {
731            if in_code_block {
732                self.styled("  ▌", code_style);
733            } else {
734                self.styled("▌", Style::new().fg(self.theme.primary));
735            }
736        }
737
738        if state.in_code_block != in_code_block {
739            state.in_code_block = in_code_block;
740        }
741        if state.code_block_lang != code_block_lang {
742            state.code_block_lang = code_block_lang;
743        }
744
745        self.commands.push(Command::EndContainer);
746        self.rollback.last_text_idx = None;
747        Response::none()
748    }
749
750    /// Render a tool approval widget with approve/reject buttons.
751    ///
752    /// Shows the tool name, description, and two action buttons.
753    /// Returns the updated [`ApprovalAction`] each frame.
754    ///
755    /// ```no_run
756    /// # use slt::widgets::{ApprovalAction, ToolApprovalState};
757    /// # slt::run(|ui: &mut slt::Context| {
758    /// let mut tool = ToolApprovalState::new("read_file", "Read contents of config.toml");
759    /// ui.tool_approval(&mut tool);
760    /// if tool.action == ApprovalAction::Approved {
761    /// }
762    /// # });
763    /// ```
764    pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
765        let old_action = state.action;
766        let theme = self.theme;
767        let _ = self.bordered(Border::Rounded).col(|ui| {
768            let _ = ui.row(|ui| {
769                ui.text("⚡").fg(theme.warning);
770                ui.text(&state.tool_name).bold().fg(theme.primary);
771            });
772            ui.text(&state.description).dim();
773
774            if state.action == ApprovalAction::Pending {
775                let _ = ui.row(|ui| {
776                    if ui.button("✓ Approve").clicked {
777                        state.action = ApprovalAction::Approved;
778                    }
779                    if ui.button("✗ Reject").clicked {
780                        state.action = ApprovalAction::Rejected;
781                    }
782                });
783            } else {
784                let (label, color) = match state.action {
785                    ApprovalAction::Approved => ("✓ Approved", theme.success),
786                    ApprovalAction::Rejected => ("✗ Rejected", theme.error),
787                    ApprovalAction::Pending => unreachable!(),
788                };
789                ui.text(label).fg(color).bold();
790            }
791        });
792
793        Response {
794            changed: state.action != old_action,
795            ..Response::none()
796        }
797    }
798
799    /// Render a context bar showing active context items with token counts.
800    ///
801    /// Displays a horizontal bar of context sources (files, URLs, etc.)
802    /// with their token counts, useful for AI chat interfaces.
803    ///
804    /// ```no_run
805    /// # use slt::widgets::ContextItem;
806    /// # slt::run(|ui: &mut slt::Context| {
807    /// let items = vec![ContextItem::new("main.rs", 1200), ContextItem::new("lib.rs", 800)];
808    /// ui.context_bar(&items);
809    /// # });
810    /// ```
811    pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
812        if items.is_empty() {
813            return Response::none();
814        }
815
816        let theme = self.theme;
817        let total: usize = items.iter().map(|item| item.tokens).sum();
818
819        let _ = self.container().row(|ui| {
820            ui.text("📎").dim();
821            for item in items {
822                let token_count = format_token_count(item.tokens);
823                let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
824                line.push_str(&item.label);
825                line.push_str(" (");
826                line.push_str(&token_count);
827                line.push(')');
828                ui.text(line).fg(theme.secondary);
829            }
830            ui.spacer();
831            let total_text = format_token_count(total);
832            let mut line = String::with_capacity(2 + total_text.len());
833            line.push_str("Σ ");
834            line.push_str(&total_text);
835            ui.text(line).dim();
836        });
837
838        Response::none()
839    }
840}