stynx_code_tui/widgets/
thinking_panel.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Modifier, Style},
5 text::{Line, Span},
6 widgets::{Paragraph, Widget},
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 = 2;
61 let inner_x = area.x + pad;
62 let inner_width = area.width.saturating_sub(pad + 1) as usize;
63 let _ = bar_col;
64
65 let header = Line::from(vec![
66 Span::styled(
67 format!("{frame} "),
68 Style::default()
69 .fg(accent)
70 .add_modifier(Modifier::BOLD),
71 ),
72 Span::styled(
73 "thinking",
74 Style::default()
75 .fg(theme::SUBTLE())
76 .add_modifier(Modifier::ITALIC),
77 ),
78 ]);
79
80 let inner_area = Rect {
81 x: inner_x,
82 y: area.y,
83 width: inner_width as u16,
84 height: area.height,
85 };
86
87 let mut lines: Vec<Line<'static>> = vec![header];
88 for raw in body_lines {
89 lines.extend(wrap_thinking_line(raw.trim_end(), inner_width));
90 }
91
92 Paragraph::new(lines).render(inner_area, buf);
93 }
94}
95
96fn wrap_thinking_line(raw: &str, width: usize) -> Vec<Line<'static>> {
100 let width = width.max(1);
101 let trimmed = raw.trim_start();
102 let indent_w = raw.len() - trimmed.len();
103 let base_indent = indent_w;
104
105 let (prefix, body, prefix_style) = if let Some(rest) = trimmed.strip_prefix("### ") {
106 ("### ".to_string(), rest, Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD))
107 } else if let Some(rest) = trimmed.strip_prefix("## ") {
108 ("## ".to_string(), rest, Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD))
109 } else if let Some(rest) = trimmed.strip_prefix("# ") {
110 ("# ".to_string(), rest, Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD))
111 } else if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
112 ("• ".to_string(), rest, Style::default().fg(theme::SUBTLE()))
113 } else if let Some((num, rest)) = split_numbered(trimmed) {
114 (format!("{num}. "), rest, Style::default().fg(theme::SUBTLE()))
115 } else {
116 (String::new(), trimmed, Style::default())
117 };
118
119 let hang = base_indent + prefix.chars().count();
121 let avail = width.saturating_sub(hang).max(1);
122
123 let mut words: Vec<(String, Style)> = Vec::new();
125 for span in parse_thinking_inline(body) {
126 let style = span.style;
127 for w in span.content.split(' ') {
128 if !w.is_empty() {
129 words.push((w.to_string(), style));
130 }
131 }
132 }
133
134 let new_line = |first: bool| -> Vec<Span<'static>> {
135 if first {
136 let mut v = vec![Span::styled(" ".repeat(base_indent), Style::default())];
137 if !prefix.is_empty() {
138 v.push(Span::styled(prefix.clone(), prefix_style));
139 }
140 v
141 } else {
142 vec![Span::styled(" ".repeat(hang), Style::default())]
143 }
144 };
145
146 let mut out: Vec<Line<'static>> = Vec::new();
147 let mut spans = new_line(true);
148 let mut text_w = 0usize; for (word, style) in words {
151 let wlen = word.chars().count();
152 let need = if text_w == 0 { wlen } else { wlen + 1 };
153 if text_w > 0 && text_w + need > avail {
154 out.push(Line::from(std::mem::take(&mut spans)));
155 spans = new_line(false);
156 text_w = 0;
157 }
158 if text_w > 0 {
159 spans.push(Span::styled(" ".to_string(), Style::default()));
160 text_w += 1;
161 }
162 spans.push(Span::styled(word, style));
163 text_w += wlen;
164 }
165 out.push(Line::from(spans));
166 out
167}
168
169fn split_numbered(s: &str) -> Option<(String, &str)> {
170 let bytes = s.as_bytes();
171 let mut i = 0;
172 while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
173 if i == 0 || i >= bytes.len() { return None; }
174 if bytes[i] == b'.' && i + 1 < bytes.len() && bytes[i + 1] == b' ' {
175 let num = s[..i].to_string();
176 let rest = &s[i + 2..];
177 Some((num, rest))
178 } else {
179 None
180 }
181}
182
183fn parse_thinking_inline(text: &str) -> Vec<Span<'static>> {
184 let mut spans: Vec<Span<'static>> = Vec::new();
185 let mut buf = String::new();
186 let chars: Vec<char> = text.chars().collect();
187 let mut i = 0;
188 let base = Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC);
189 let bold = Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::BOLD);
190 let code = Style::default().fg(theme::FOAM()).add_modifier(Modifier::ITALIC);
191
192 let flush = |buf: &mut String, spans: &mut Vec<Span<'static>>| {
193 if !buf.is_empty() {
194 spans.push(Span::styled(std::mem::take(buf), base));
195 }
196 };
197
198 while i < chars.len() {
199 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
200 flush(&mut buf, &mut spans);
201 i += 2;
202 let mut inner = String::new();
203 while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '*') {
204 inner.push(chars[i]);
205 i += 1;
206 }
207 spans.push(Span::styled(inner, bold));
208 i += 2;
209 } else if chars[i] == '`' {
210 flush(&mut buf, &mut spans);
211 i += 1;
212 let mut inner = String::new();
213 while i < chars.len() && chars[i] != '`' {
214 inner.push(chars[i]);
215 i += 1;
216 }
217 spans.push(Span::styled(inner, code));
218 if i < chars.len() { i += 1; }
219 } else {
220 buf.push(chars[i]);
221 i += 1;
222 }
223 }
224 flush(&mut buf, &mut spans);
225 spans
226}