1use crate::theme::Theme;
2use crate::view::wrap::tail_to_width;
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Paragraph, Widget};
8use std::time::{Duration, Instant};
9use unicode_width::UnicodeWidthStr;
10
11pub const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
13pub fn spinner_frame(tick: usize) -> &'static str {
14 SPINNER_FRAMES[tick % SPINNER_FRAMES.len()]
15}
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
18pub(crate) enum ProgressPhase {
19 #[default]
20 Idle,
21 Thinking,
22 Responding,
23 Working,
24 Compacting,
25 MovingWorkspace,
26 LoadingSession,
27}
28
29impl ProgressPhase {
30 fn label(self) -> &'static str {
31 match self {
32 Self::Idle => "",
33 Self::Thinking => "Thinking…",
34 Self::Responding => "Responding…",
35 Self::Working => "Working…",
36 Self::Compacting => "Compacting context...",
37 Self::MovingWorkspace => "Moving workspace...",
38 Self::LoadingSession => "Loading session in new workspace...",
39 }
40 }
41
42 fn spinner_color(self, theme: &Theme) -> Color {
43 if self == Self::Compacting { theme.warning } else { theme.info }
44 }
45}
46
47#[derive(Debug)]
48pub struct ProgressIndicator {
49 phase: ProgressPhase,
50 agent_phase: ProgressPhase,
51 interruptible: bool,
52 now: Instant,
53 phase_started_at: Instant,
54 thought: String,
55}
56
57impl Default for ProgressIndicator {
58 fn default() -> Self {
59 let now = Instant::now();
60 Self {
61 phase: ProgressPhase::Idle,
62 agent_phase: ProgressPhase::Idle,
63 interruptible: false,
64 now,
65 phase_started_at: now,
66 thought: String::new(),
67 }
68 }
69}
70
71impl ProgressIndicator {
72 pub(crate) fn prompt_started(&mut self) {
73 self.thought.clear();
74 self.set_agent_phase(ProgressPhase::Thinking);
75 }
76
77 pub(crate) fn response_started(&mut self) {
78 self.set_agent_phase(ProgressPhase::Responding);
79 }
80
81 pub(crate) fn tool_activity(&mut self) {
82 self.set_agent_phase(ProgressPhase::Working);
83 }
84
85 pub(crate) fn prompt_finished(&mut self) {
86 self.thought.clear();
87 self.set_agent_phase(ProgressPhase::Idle);
88 }
89
90 pub(crate) fn refresh(&mut self, override_phase: Option<ProgressPhase>, interruptible: bool) {
91 let phase = override_phase.unwrap_or(self.agent_phase);
92 if phase != self.phase {
93 self.phase_started_at = self.now;
94 }
95 self.phase = phase;
96 self.interruptible = interruptible;
97 }
98
99 pub(crate) fn record_thought(&mut self, chunk: &str) {
100 self.set_agent_phase(ProgressPhase::Thinking);
101 for character in chunk.chars() {
102 if character.is_whitespace() {
103 if !self.thought.is_empty() && !self.thought.ends_with(' ') {
104 self.thought.push(' ');
105 }
106 } else {
107 self.thought.push(character);
108 }
109 }
110 let excess = self.thought.chars().count().saturating_sub(THOUGHT_TAIL_CAPACITY);
111 if excess > 0 {
112 let cut = self.thought.char_indices().nth(excess).map_or(self.thought.len(), |(index, _)| index);
113 self.thought.drain(..cut);
114 }
115 }
116
117 pub(crate) fn on_tick(&mut self, now: Instant) {
118 self.now = now;
119 }
120
121 fn set_agent_phase(&mut self, phase: ProgressPhase) {
122 if self.agent_phase == ProgressPhase::Thinking && phase != ProgressPhase::Thinking {
123 self.thought.clear();
124 }
125 self.agent_phase = phase;
126 }
127
128 pub fn is_active(&self) -> bool {
129 self.phase != ProgressPhase::Idle
130 }
131
132 pub(crate) fn is_interruptible(&self) -> bool {
133 self.interruptible && self.is_active()
134 }
135
136 pub(crate) fn height(&self) -> u16 {
137 if self.is_active() { 3 } else { 0 }
138 }
139
140 fn lines(&self, theme: &Theme, tick: usize, width: u16) -> Vec<Line<'static>> {
141 if !self.is_active() {
142 return Vec::new();
143 }
144 vec![Line::default(), self.activity_line(theme, tick, width), Line::default()]
145 }
146
147 fn activity_line(&self, theme: &Theme, tick: usize, width: u16) -> Line<'static> {
148 let label = format!(" {}", self.phase.label());
149 let elapsed = format!(" {}", format_elapsed(self.now.saturating_duration_since(self.phase_started_at)));
150 let hint = self.is_interruptible().then_some(INTERRUPT_HINT);
151 let fixed = 1 + label.width() + elapsed.width() + hint.map_or(0, UnicodeWidthStr::width) + 1;
152 let room = usize::from(width).saturating_sub(fixed);
153 let mut spans = vec![
154 Span::styled(spinner_frame(tick).to_string(), Style::new().fg(self.phase.spinner_color(theme))),
155 Span::styled(label, Style::new().fg(theme.text_secondary)),
156 ];
157 if self.has_thought() && room > 0 {
158 spans.push(Span::styled(
159 format!(" {}", tail_to_width(&self.thought, room)),
160 Style::new().fg(theme.blockquote).add_modifier(Modifier::ITALIC | Modifier::DIM),
161 ));
162 }
163 spans.push(Span::styled(elapsed, Style::new().fg(theme.text_secondary)));
164 if let Some(hint) = hint {
165 spans.push(Span::styled(hint.to_string(), Style::new().fg(theme.muted).add_modifier(Modifier::ITALIC)));
166 }
167 Line::from(spans)
168 }
169
170 fn has_thought(&self) -> bool {
171 self.phase == ProgressPhase::Thinking && !self.thought.is_empty()
172 }
173}
174
175pub struct ProgressIndicatorView<'a> {
176 indicator: &'a ProgressIndicator,
177 theme: &'a Theme,
178 tick: usize,
179}
180
181impl<'a> ProgressIndicatorView<'a> {
182 pub fn new(indicator: &'a ProgressIndicator, theme: &'a Theme, tick: usize) -> Self {
183 Self { indicator, theme, tick }
184 }
185}
186
187impl Widget for ProgressIndicatorView<'_> {
188 fn render(self, area: Rect, buf: &mut Buffer) {
189 let height = usize::from(area.height);
190 if height == 0 {
191 return;
192 }
193 let mut lines = self.indicator.lines(self.theme, self.tick, area.width);
194 if lines.len() > height {
195 lines.pop();
196 }
197 if lines.len() > height {
198 lines.remove(0);
199 }
200 lines.truncate(height);
201 Paragraph::new(lines).render(area, buf);
202 }
203}
204
205fn format_elapsed(elapsed: Duration) -> String {
206 let seconds = elapsed.as_secs();
207 if seconds < 60 { format!("{seconds}s") } else { format!("{}m{:02}s", seconds / 60, seconds % 60) }
208}
209
210const THOUGHT_TAIL_CAPACITY: usize = 240;
211const INTERRUPT_HINT: &str = " (esc to interrupt)";