agcodex_tui/
insert_history.rs

1use std::fmt;
2use std::io;
3use std::io::Write;
4
5use crate::tui;
6use ratatui::crossterm::Command;
7use ratatui::crossterm::cursor::MoveTo;
8use ratatui::crossterm::queue;
9use ratatui::crossterm::style::Attribute as CAttribute;
10use ratatui::crossterm::style::Color as CColor;
11use ratatui::crossterm::style::Colors;
12use ratatui::crossterm::style::Print;
13use ratatui::crossterm::style::SetAttribute;
14use ratatui::crossterm::style::SetBackgroundColor;
15use ratatui::crossterm::style::SetColors;
16use ratatui::crossterm::style::SetForegroundColor;
17use ratatui::layout::Size;
18use ratatui::style::Color;
19use ratatui::style::Modifier;
20use ratatui::text::Line;
21use ratatui::text::Span;
22use textwrap::Options as TwOptions;
23use textwrap::WordSplitter;
24
25/// Insert `lines` above the viewport.
26pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
27    let mut out = std::io::stdout();
28    insert_history_lines_to_writer(terminal, &mut out, lines);
29}
30
31/// Like `insert_history_lines`, but writes ANSI to the provided writer. This
32/// is intended for testing where a capture buffer is used instead of stdout.
33pub fn insert_history_lines_to_writer<B, W>(
34    terminal: &mut crate::custom_terminal::Terminal<B>,
35    writer: &mut W,
36    lines: Vec<Line>,
37) where
38    B: ratatui::backend::Backend,
39    W: Write,
40{
41    let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
42    let cursor_pos = terminal.get_cursor_position().ok();
43
44    let mut area = terminal.get_frame().area();
45
46    // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
47    // formatting as the TUI. This avoids character-level hard wrapping by the terminal.
48    let wrapped = word_wrap_lines(&lines, area.width.max(1));
49    let wrapped_lines = wrapped.len() as u16;
50    let cursor_top = if area.bottom() < screen_size.height {
51        // If the viewport is not at the bottom of the screen, scroll it down to make room.
52        // Don't scroll it past the bottom of the screen.
53        let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
54
55        // Emit ANSI to scroll the lower region (from the top of the viewport to the bottom
56        // of the screen) downward by `scroll_amount` lines. We do this by:
57        //   1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds)
58        //   2) Placing the cursor at the top margin of that region
59        //   3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times
60        //   4) Resetting the scroll region back to full screen
61        let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM
62        queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok();
63        queue!(writer, MoveTo(0, area.top())).ok();
64        for _ in 0..scroll_amount {
65            // Reverse Index (RI): ESC M
66            queue!(writer, Print("\x1bM")).ok();
67        }
68        queue!(writer, ResetScrollRegion).ok();
69
70        let cursor_top = area.top().saturating_sub(1);
71        area.y += scroll_amount;
72        terminal.set_viewport_area(area);
73        cursor_top
74    } else {
75        area.top().saturating_sub(1)
76    };
77
78    // Limit the scroll region to the lines from the top of the screen to the
79    // top of the viewport. With this in place, when we add lines inside this
80    // area, only the lines in this area will be scrolled. We place the cursor
81    // at the end of the scroll region, and add lines starting there.
82    //
83    // ┌─Screen───────────────────────┐
84    // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
85    // │┆                            ┆│
86    // │┆                            ┆│
87    // │┆                            ┆│
88    // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
89    // │╭─Viewport───────────────────╮│
90    // ││                            ││
91    // │╰────────────────────────────╯│
92    // └──────────────────────────────┘
93    queue!(writer, SetScrollRegion(1..area.top())).ok();
94
95    // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
96    // terminal's last_known_cursor_position, which hopefully will still be accurate after we
97    // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
98    queue!(writer, MoveTo(0, cursor_top)).ok();
99
100    for line in wrapped {
101        queue!(writer, Print("\r\n")).ok();
102        write_spans(writer, line.iter()).ok();
103    }
104
105    queue!(writer, ResetScrollRegion).ok();
106
107    // Restore the cursor position to where it was before we started.
108    if let Some(cursor_pos) = cursor_pos {
109        queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SetScrollRegion(pub std::ops::Range<u16>);
115
116impl Command for SetScrollRegion {
117    fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
118        write!(f, "\x1b[{};{}r", self.0.start, self.0.end)
119    }
120
121    #[cfg(windows)]
122    fn execute_winapi(&self) -> std::io::Result<()> {
123        panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead");
124    }
125
126    #[cfg(windows)]
127    fn is_ansi_code_supported(&self) -> bool {
128        // TODO(nornagon): is this supported on Windows?
129        true
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct ResetScrollRegion;
135
136impl Command for ResetScrollRegion {
137    fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
138        write!(f, "\x1b[r")
139    }
140
141    #[cfg(windows)]
142    fn execute_winapi(&self) -> std::io::Result<()> {
143        panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead");
144    }
145
146    #[cfg(windows)]
147    fn is_ansi_code_supported(&self) -> bool {
148        // TODO(nornagon): is this supported on Windows?
149        true
150    }
151}
152
153struct ModifierDiff {
154    pub from: Modifier,
155    pub to: Modifier,
156}
157
158impl ModifierDiff {
159    fn queue<W>(self, mut w: W) -> io::Result<()>
160    where
161        W: io::Write,
162    {
163        use ratatui::crossterm::style::Attribute as CAttribute;
164        let removed = self.from - self.to;
165        if removed.contains(Modifier::REVERSED) {
166            queue!(w, SetAttribute(CAttribute::NoReverse))?;
167        }
168        if removed.contains(Modifier::BOLD) {
169            queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
170            if self.to.contains(Modifier::DIM) {
171                queue!(w, SetAttribute(CAttribute::Dim))?;
172            }
173        }
174        if removed.contains(Modifier::ITALIC) {
175            queue!(w, SetAttribute(CAttribute::NoItalic))?;
176        }
177        if removed.contains(Modifier::UNDERLINED) {
178            queue!(w, SetAttribute(CAttribute::NoUnderline))?;
179        }
180        if removed.contains(Modifier::DIM) {
181            queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
182        }
183        if removed.contains(Modifier::CROSSED_OUT) {
184            queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
185        }
186        if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
187            queue!(w, SetAttribute(CAttribute::NoBlink))?;
188        }
189
190        let added = self.to - self.from;
191        if added.contains(Modifier::REVERSED) {
192            queue!(w, SetAttribute(CAttribute::Reverse))?;
193        }
194        if added.contains(Modifier::BOLD) {
195            queue!(w, SetAttribute(CAttribute::Bold))?;
196        }
197        if added.contains(Modifier::ITALIC) {
198            queue!(w, SetAttribute(CAttribute::Italic))?;
199        }
200        if added.contains(Modifier::UNDERLINED) {
201            queue!(w, SetAttribute(CAttribute::Underlined))?;
202        }
203        if added.contains(Modifier::DIM) {
204            queue!(w, SetAttribute(CAttribute::Dim))?;
205        }
206        if added.contains(Modifier::CROSSED_OUT) {
207            queue!(w, SetAttribute(CAttribute::CrossedOut))?;
208        }
209        if added.contains(Modifier::SLOW_BLINK) {
210            queue!(w, SetAttribute(CAttribute::SlowBlink))?;
211        }
212        if added.contains(Modifier::RAPID_BLINK) {
213            queue!(w, SetAttribute(CAttribute::RapidBlink))?;
214        }
215
216        Ok(())
217    }
218}
219
220fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
221where
222    I: Iterator<Item = &'a Span<'a>>,
223{
224    let mut fg = Color::Reset;
225    let mut bg = Color::Reset;
226    let mut last_modifier = Modifier::empty();
227    for span in content {
228        let mut modifier = Modifier::empty();
229        modifier.insert(span.style.add_modifier);
230        modifier.remove(span.style.sub_modifier);
231        if modifier != last_modifier {
232            let diff = ModifierDiff {
233                from: last_modifier,
234                to: modifier,
235            };
236            diff.queue(&mut writer)?;
237            last_modifier = modifier;
238        }
239        let next_fg = span.style.fg.unwrap_or(Color::Reset);
240        let next_bg = span.style.bg.unwrap_or(Color::Reset);
241        if next_fg != fg || next_bg != bg {
242            queue!(
243                writer,
244                SetColors(Colors::new(next_fg.into(), next_bg.into()))
245            )?;
246            fg = next_fg;
247            bg = next_bg;
248        }
249
250        queue!(writer, Print(span.content.clone()))?;
251    }
252
253    queue!(
254        writer,
255        SetForegroundColor(CColor::Reset),
256        SetBackgroundColor(CColor::Reset),
257        SetAttribute(CAttribute::Reset),
258    )
259}
260
261/// Word-aware wrapping for a list of `Line`s preserving styles.
262pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
263    let mut out = Vec::new();
264    let w = width.max(1) as usize;
265    for line in lines {
266        out.extend(word_wrap_line(line, w));
267    }
268    out
269}
270
271fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
272    if width == 0 {
273        return vec![to_owned_line(line)];
274    }
275    // Concatenate content and keep span boundaries for later re-slicing.
276    let mut flat = String::new();
277    let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
278    let mut cursor = 0usize;
279    for s in &line.spans {
280        let text = s.content.as_ref();
281        let start = cursor;
282        flat.push_str(text);
283        cursor += text.len();
284        span_bounds.push((start, cursor, s.style));
285    }
286
287    // Use textwrap for robust word-aware wrapping; no hyphenation, no breaking words.
288    let opts = TwOptions::new(width)
289        .break_words(false)
290        .word_splitter(WordSplitter::NoHyphenation);
291    let wrapped = textwrap::wrap(&flat, &opts);
292
293    if wrapped.len() <= 1 {
294        return vec![to_owned_line(line)];
295    }
296
297    // Map wrapped pieces back to byte ranges in `flat` sequentially.
298    let mut start_cursor = 0usize;
299    let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
300    for piece in wrapped {
301        let piece_str: &str = &piece;
302        if piece_str.is_empty() {
303            out.push(Line {
304                style: line.style,
305                alignment: line.alignment,
306                spans: Vec::new(),
307            });
308            continue;
309        }
310        // Find the next occurrence of piece_str at or after start_cursor.
311        // textwrap preserves order, so a linear scan is sufficient.
312        if let Some(rel) = flat[start_cursor..].find(piece_str) {
313            let s = start_cursor + rel;
314            let e = s + piece_str.len();
315            out.push(slice_line_spans(line, &span_bounds, s, e));
316            start_cursor = e;
317        } else {
318            // Fallback: slice by length from cursor.
319            let s = start_cursor;
320            let e = (start_cursor + piece_str.len()).min(flat.len());
321            out.push(slice_line_spans(line, &span_bounds, s, e));
322            start_cursor = e;
323        }
324    }
325
326    out
327}
328
329fn to_owned_line(l: &Line<'_>) -> Line<'static> {
330    Line {
331        style: l.style,
332        alignment: l.alignment,
333        spans: l
334            .spans
335            .iter()
336            .map(|s| Span {
337                style: s.style,
338                content: std::borrow::Cow::Owned(s.content.to_string()),
339            })
340            .collect(),
341    }
342}
343
344fn slice_line_spans(
345    original: &Line<'_>,
346    span_bounds: &[(usize, usize, ratatui::style::Style)],
347    start_byte: usize,
348    end_byte: usize,
349) -> Line<'static> {
350    let mut acc: Vec<Span<'static>> = Vec::new();
351    for (i, (s, e, style)) in span_bounds.iter().enumerate() {
352        if *e <= start_byte {
353            continue;
354        }
355        if *s >= end_byte {
356            break;
357        }
358        let seg_start = start_byte.max(*s);
359        let seg_end = end_byte.min(*e);
360        if seg_end > seg_start {
361            let local_start = seg_start - *s;
362            let local_end = seg_end - *s;
363            let content = original.spans[i].content.as_ref();
364            let slice = &content[local_start..local_end];
365            acc.push(Span {
366                style: *style,
367                content: std::borrow::Cow::Owned(slice.to_string()),
368            });
369        }
370        if *e >= end_byte {
371            break;
372        }
373    }
374    Line {
375        style: original.style,
376        alignment: original.alignment,
377        spans: acc,
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use ratatui::crossterm::style::Attribute as CAttribute;
385
386    #[test]
387    fn writes_bold_then_regular_spans() {
388        use ratatui::style::Stylize;
389
390        let spans = ["A".bold(), "B".into()];
391
392        let mut actual: Vec<u8> = Vec::new();
393        write_spans(&mut actual, spans.iter()).unwrap();
394
395        let mut expected: Vec<u8> = Vec::new();
396        queue!(
397            expected,
398            SetAttribute(CAttribute::Bold),
399            Print("A"),
400            SetAttribute(CAttribute::NormalIntensity),
401            Print("B"),
402            SetForegroundColor(CColor::Reset),
403            SetBackgroundColor(CColor::Reset),
404            SetAttribute(CAttribute::Reset),
405        )
406        .unwrap();
407
408        assert_eq!(
409            String::from_utf8(actual).unwrap(),
410            String::from_utf8(expected).unwrap()
411        );
412    }
413
414    #[test]
415    fn line_height_counts_double_width_emoji() {
416        let line = Line::from("😀😀😀"); // each emoji ~ width 2
417        assert_eq!(word_wrap_line(&line, 4).len(), 2);
418        assert_eq!(word_wrap_line(&line, 2).len(), 3);
419        assert_eq!(word_wrap_line(&line, 6).len(), 1);
420    }
421
422    #[test]
423    fn word_wrap_does_not_split_words_simple_english() {
424        let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
425        let line = Line::from(sample);
426        // Force small width to exercise wrapping at spaces.
427        let wrapped = word_wrap_lines(&[line], 40);
428        let joined: String = wrapped
429            .iter()
430            .map(|l| {
431                l.spans
432                    .iter()
433                    .map(|s| s.content.clone())
434                    .collect::<String>()
435            })
436            .collect::<Vec<_>>()
437            .join("\n");
438        assert!(
439            !joined.contains("bo\nth"),
440            "word 'both' should not be split across lines:\n{joined}"
441        );
442        assert!(
443            !joined.contains("Willowm\nere"),
444            "should not split inside words:\n{joined}"
445        );
446    }
447}