Skip to main content

stynx_code_tui/widgets/
thinking_panel.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Paragraph, Widget, Wrap},
7};
8
9use crate::theme;
10use crate::widgets::spinner::FRAMES;
11
12pub struct ThinkingPanel<'a> {
13    pub text: &'a str,
14    pub spinner_frame: usize,
15}
16
17impl<'a> ThinkingPanel<'a> {
18    pub fn new(text: &'a str, spinner_frame: usize) -> Self {
19        Self { text, spinner_frame }
20    }
21}
22
23impl<'a> Widget for ThinkingPanel<'a> {
24    fn render(self, area: Rect, buf: &mut Buffer) {
25        if self.text.trim().is_empty() || area.height == 0 || area.width < 4 {
26            return;
27        }
28
29        let frame = FRAMES[self.spinner_frame % FRAMES.len()];
30
31        let visible_body = (area.height as usize).saturating_sub(1);
32        let body_lines: Vec<&str> = {
33            let mut v: Vec<&str> = self
34                .text
35                .lines()
36                .filter(|l| !l.trim().is_empty())
37                .collect();
38            if v.len() > visible_body {
39                v = v[v.len() - visible_body..].to_vec();
40            }
41            v
42        };
43
44        for y in area.y..area.y + area.height {
45            for x in area.x..area.x + area.width {
46                buf[(x, y)].set_style(Style::default().bg(theme::BACKGROUND()));
47            }
48        }
49
50        let accent = theme::IRIS();
51        let bar_col = theme::OVERLAY();
52
53        for y in area.y..area.y + area.height {
54            buf[(area.x, y)]
55                .set_char('▌')
56                .set_style(Style::default().fg(accent).bg(theme::BACKGROUND()));
57        }
58
59        let pad: u16 = 1;
60        let inner_x = area.x + pad;
61        let inner_width = area.width.saturating_sub(pad + 1) as usize;
62        let _ = bar_col;
63
64        let header = Line::from(vec![
65            Span::styled(
66                format!("  {frame} "),
67                Style::default()
68                    .fg(accent)
69                    .add_modifier(Modifier::BOLD),
70            ),
71            Span::styled(
72                "thinking",
73                Style::default()
74                    .fg(theme::SUBTLE())
75                    .add_modifier(Modifier::ITALIC),
76            ),
77        ]);
78
79        let inner_area = Rect {
80            x: inner_x,
81            y: area.y,
82            width: inner_width as u16,
83            height: area.height,
84        };
85
86        let mut lines: Vec<Line<'static>> = vec![header];
87        for raw in body_lines {
88            lines.push(render_thinking_line(raw.trim_end()));
89        }
90
91        Paragraph::new(lines)
92            .wrap(Wrap { trim: false })
93            .render(inner_area, buf);
94    }
95}
96
97fn render_thinking_line(raw: &str) -> Line<'static> {
98    let trimmed = raw.trim_start();
99    let indent_w = raw.len() - trimmed.len();
100    let indent = " ".repeat(4 + indent_w);
101
102    let (prefix, body, prefix_style) = if let Some(rest) = trimmed.strip_prefix("### ") {
103        (
104            "### ".to_string(),
105            rest,
106            Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD),
107        )
108    } else if let Some(rest) = trimmed.strip_prefix("## ") {
109        (
110            "## ".to_string(),
111            rest,
112            Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD),
113        )
114    } else if let Some(rest) = trimmed.strip_prefix("# ") {
115        (
116            "# ".to_string(),
117            rest,
118            Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD),
119        )
120    } else if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
121        ("• ".to_string(), rest, Style::default().fg(theme::SUBTLE()))
122    } else if let Some((num, rest)) = split_numbered(trimmed) {
123        (format!("{num}. "), rest, Style::default().fg(theme::SUBTLE()))
124    } else {
125        (String::new(), trimmed, Style::default())
126    };
127
128    let mut spans: Vec<Span<'static>> = vec![Span::styled(indent, Style::default())];
129    if !prefix.is_empty() {
130        spans.push(Span::styled(prefix, prefix_style));
131    }
132    spans.extend(parse_thinking_inline(body));
133    Line::from(spans)
134}
135
136fn split_numbered(s: &str) -> Option<(String, &str)> {
137    let bytes = s.as_bytes();
138    let mut i = 0;
139    while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
140    if i == 0 || i >= bytes.len() { return None; }
141    if bytes[i] == b'.' && i + 1 < bytes.len() && bytes[i + 1] == b' ' {
142        let num = s[..i].to_string();
143        let rest = &s[i + 2..];
144        Some((num, rest))
145    } else {
146        None
147    }
148}
149
150fn parse_thinking_inline(text: &str) -> Vec<Span<'static>> {
151    let mut spans: Vec<Span<'static>> = Vec::new();
152    let mut buf = String::new();
153    let chars: Vec<char> = text.chars().collect();
154    let mut i = 0;
155    let base = Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC);
156    let bold = Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD);
157    let code = Style::default().fg(theme::FOAM()).add_modifier(Modifier::ITALIC);
158
159    let flush = |buf: &mut String, spans: &mut Vec<Span<'static>>| {
160        if !buf.is_empty() {
161            spans.push(Span::styled(std::mem::take(buf), base));
162        }
163    };
164
165    while i < chars.len() {
166        if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
167            flush(&mut buf, &mut spans);
168            i += 2;
169            let mut inner = String::new();
170            while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '*') {
171                inner.push(chars[i]);
172                i += 1;
173            }
174            spans.push(Span::styled(inner, bold));
175            i += 2;
176        } else if chars[i] == '`' {
177            flush(&mut buf, &mut spans);
178            i += 1;
179            let mut inner = String::new();
180            while i < chars.len() && chars[i] != '`' {
181                inner.push(chars[i]);
182                i += 1;
183            }
184            spans.push(Span::styled(inner, code));
185            if i < chars.len() { i += 1; }
186        } else {
187            buf.push(chars[i]);
188            i += 1;
189        }
190    }
191    flush(&mut buf, &mut spans);
192    spans
193}