Skip to main content

wisp/conversation/
progress_indicator.rs

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)";