Skip to main content

wisp/conversation/
progress_indicator.rs

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    accepts_activity: bool,
53    now: Instant,
54    phase_started_at: Instant,
55    thought: String,
56}
57
58impl Default for ProgressIndicator {
59    fn default() -> Self {
60        let now = Instant::now();
61        Self {
62            phase: ProgressPhase::Idle,
63            agent_phase: ProgressPhase::Idle,
64            interruptible: false,
65            accepts_activity: true,
66            now,
67            phase_started_at: now,
68            thought: String::new(),
69        }
70    }
71}
72
73impl ProgressIndicator {
74    pub(crate) fn accepts_activity(&self) -> bool {
75        self.accepts_activity
76    }
77
78    pub(crate) fn prompt_started(&mut self) {
79        self.thought.clear();
80        self.accepts_activity = true;
81        self.set_agent_phase(ProgressPhase::Thinking);
82    }
83
84    pub(crate) fn response_started(&mut self) {
85        self.set_agent_phase(ProgressPhase::Responding);
86    }
87
88    pub(crate) fn tool_activity(&mut self) {
89        self.set_agent_phase(ProgressPhase::Working);
90    }
91
92    pub(crate) fn prompt_finished(&mut self) {
93        self.thought.clear();
94        self.set_agent_phase(ProgressPhase::Idle);
95        self.accepts_activity = false;
96    }
97
98    pub(crate) fn refresh(&mut self, override_phase: Option<ProgressPhase>, interruptible: bool) {
99        let phase = override_phase.unwrap_or(self.agent_phase);
100        if phase != self.phase {
101            self.phase_started_at = self.now;
102        }
103        self.phase = phase;
104        self.interruptible = interruptible;
105    }
106
107    pub(crate) fn record_thought(&mut self, chunk: &str) {
108        if !self.accepts_activity {
109            return;
110        }
111        self.set_agent_phase(ProgressPhase::Thinking);
112        for character in chunk.chars() {
113            if character.is_whitespace() {
114                if !self.thought.is_empty() && !self.thought.ends_with(' ') {
115                    self.thought.push(' ');
116                }
117            } else {
118                self.thought.push(character);
119            }
120        }
121        let excess = self.thought.chars().count().saturating_sub(THOUGHT_TAIL_CAPACITY);
122        if excess > 0 {
123            let cut = self.thought.char_indices().nth(excess).map_or(self.thought.len(), |(index, _)| index);
124            self.thought.drain(..cut);
125        }
126    }
127
128    pub(crate) fn on_tick(&mut self, now: Instant) {
129        self.now = now;
130    }
131
132    fn set_agent_phase(&mut self, phase: ProgressPhase) {
133        if phase != ProgressPhase::Idle && !self.accepts_activity {
134            return;
135        }
136        if self.agent_phase == ProgressPhase::Thinking && phase != ProgressPhase::Thinking {
137            self.thought.clear();
138        }
139        self.agent_phase = phase;
140    }
141
142    pub fn is_active(&self) -> bool {
143        self.phase != ProgressPhase::Idle
144    }
145
146    pub(crate) fn is_interruptible(&self) -> bool {
147        self.interruptible && self.is_active()
148    }
149
150    pub(crate) fn height(&self) -> u16 {
151        if self.is_active() { 3 } else { 0 }
152    }
153
154    fn lines(&self, theme: &Theme, tick: usize, width: u16) -> Vec<Line<'static>> {
155        if !self.is_active() {
156            return Vec::new();
157        }
158        vec![Line::default(), self.activity_line(theme, tick, width), Line::default()]
159    }
160
161    fn activity_line(&self, theme: &Theme, tick: usize, width: u16) -> Line<'static> {
162        let label = format!(" {}", self.phase.label());
163        let elapsed = format!("  {}", format_elapsed(self.now.saturating_duration_since(self.phase_started_at)));
164        let hint = self.is_interruptible().then_some(INTERRUPT_HINT);
165        let fixed = 1 + label.width() + elapsed.width() + hint.map_or(0, UnicodeWidthStr::width) + 1;
166        let room = usize::from(width).saturating_sub(fixed);
167        let mut spans = vec![
168            Span::styled(spinner_frame(tick).to_string(), Style::new().fg(self.phase.spinner_color(theme))),
169            Span::styled(label, Style::new().fg(theme.text_secondary)),
170        ];
171        if self.has_thought() && room > 0 {
172            spans.push(Span::styled(
173                format!(" {}", tail_to_width(&self.thought, room)),
174                Style::new().fg(theme.blockquote).add_modifier(Modifier::ITALIC | Modifier::DIM),
175            ));
176        }
177        spans.push(Span::styled(elapsed, Style::new().fg(theme.text_secondary)));
178        if let Some(hint) = hint {
179            spans.push(Span::styled(hint.to_string(), Style::new().fg(theme.muted).add_modifier(Modifier::ITALIC)));
180        }
181        Line::from(spans)
182    }
183
184    fn has_thought(&self) -> bool {
185        self.phase == ProgressPhase::Thinking && !self.thought.is_empty()
186    }
187}
188
189pub struct ProgressIndicatorView<'a> {
190    indicator: &'a ProgressIndicator,
191    theme: &'a Theme,
192    tick: usize,
193}
194
195impl<'a> ProgressIndicatorView<'a> {
196    pub fn new(indicator: &'a ProgressIndicator, theme: &'a Theme, tick: usize) -> Self {
197        Self { indicator, theme, tick }
198    }
199}
200
201impl Widget for ProgressIndicatorView<'_> {
202    fn render(self, area: Rect, buf: &mut Buffer) {
203        let height = usize::from(area.height);
204        if height == 0 {
205            return;
206        }
207        let mut lines = self.indicator.lines(self.theme, self.tick, area.width);
208        if lines.len() > height {
209            lines.pop();
210        }
211        if lines.len() > height {
212            lines.remove(0);
213        }
214        lines.truncate(height);
215        Paragraph::new(lines).render(area, buf);
216    }
217}
218
219fn format_elapsed(elapsed: Duration) -> String {
220    let seconds = elapsed.as_secs();
221    if seconds < 60 { format!("{seconds}s") } else { format!("{}m{:02}s", seconds / 60, seconds % 60) }
222}
223
224const THOUGHT_TAIL_CAPACITY: usize = 240;
225const INTERRUPT_HINT: &str = "  (esc to interrupt)";