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