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