Skip to main content

limit_tui/components/
activity.rs

1// Activity feed component for limit-tui
2//
3// This module provides an ActivityFeed component for displaying
4// recent tool activities in a compact log format.
5
6use ratatui::{
7    buffer::Buffer,
8    layout::Rect,
9    style::{Color, Style},
10    text::{Line, Span},
11    widgets::{Paragraph, Widget},
12};
13use tracing::debug;
14
15/// Maximum number of activities to keep in the feed
16const MAX_ACTIVITIES: usize = 5;
17
18/// A single activity entry
19#[derive(Debug, Clone)]
20pub struct Activity {
21    /// Activity message (e.g., "Reading src/main.rs...")
22    pub message: String,
23    /// Whether this activity is currently in progress
24    pub in_progress: bool,
25}
26
27/// Activity feed component that displays recent tool activities
28///
29/// The feed shows a compact list of recent activities with
30/// visual indicators for in-progress items.
31#[derive(Debug, Clone)]
32pub struct ActivityFeed {
33    /// List of recent activities (most recent first)
34    activities: Vec<Activity>,
35}
36
37impl Default for ActivityFeed {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl ActivityFeed {
44    /// Create a new empty activity feed
45    pub fn new() -> Self {
46        debug!(component = %"ActivityFeed", "Component created");
47        Self {
48            activities: Vec::with_capacity(MAX_ACTIVITIES),
49        }
50    }
51
52    /// Add a new activity to the feed
53    ///
54    /// Activities are always added to the top of the list.
55    /// Multiple in-progress activities can exist simultaneously.
56    pub fn add(&mut self, message: String, in_progress: bool) {
57        // Add new activity at the beginning
58        self.activities.insert(
59            0,
60            Activity {
61                message,
62                in_progress,
63            },
64        );
65
66        // Prune old activities
67        if self.activities.len() > MAX_ACTIVITIES {
68            self.activities.truncate(MAX_ACTIVITIES);
69        }
70    }
71
72    /// Mark the most recent in-progress activity as complete
73    ///
74    /// Since activities are added at the beginning, we mark the first
75    /// in-progress activity (most recent) as complete.
76    pub fn complete_current(&mut self) {
77        for activity in &mut self.activities {
78            if activity.in_progress {
79                activity.in_progress = false;
80                break;
81            }
82        }
83    }
84
85    /// Clear all activities
86    pub fn clear(&mut self) {
87        self.activities.clear();
88    }
89
90    /// Mark all in-progress activities as complete
91    pub fn complete_all(&mut self) {
92        for activity in &mut self.activities {
93            activity.in_progress = false;
94        }
95    }
96
97    /// Check if there are any activities
98    pub fn is_empty(&self) -> bool {
99        self.activities.is_empty()
100    }
101
102    /// Check if there are any in-progress activities
103    pub fn has_in_progress(&self) -> bool {
104        self.activities.iter().any(|a| a.in_progress)
105    }
106
107    /// Get the number of activities
108    pub fn len(&self) -> usize {
109        self.activities.len()
110    }
111
112    /// Render the activity feed to a buffer
113    ///
114    /// # Arguments
115    ///
116    /// * `area` - The area to render the feed in
117    /// * `buf` - The buffer to render to
118    pub fn render(&self, area: Rect, buf: &mut Buffer) {
119        if area.height == 0 || self.activities.is_empty() {
120            return;
121        }
122
123        let mut lines = Vec::with_capacity(self.activities.len());
124
125        for activity in &self.activities {
126            let (indicator, color) = if activity.in_progress {
127                ("⏳", Color::Yellow)
128            } else {
129                ("✓", Color::Green)
130            };
131
132            lines.push(Line::from(vec![
133                Span::styled(indicator, Style::default().fg(color)),
134                Span::raw(" "),
135                Span::styled(&activity.message, Style::default().fg(Color::DarkGray)),
136            ]));
137        }
138
139        let paragraph = Paragraph::new(lines);
140        paragraph.render(area, buf);
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_activity_feed_new() {
150        let feed = ActivityFeed::new();
151        assert!(feed.is_empty());
152        assert_eq!(feed.len(), 0);
153    }
154
155    #[test]
156    fn test_activity_feed_default() {
157        let feed = ActivityFeed::default();
158        assert!(feed.is_empty());
159    }
160
161    #[test]
162    fn test_activity_feed_add() {
163        let mut feed = ActivityFeed::new();
164        feed.add("Reading file...".to_string(), true);
165        assert_eq!(feed.len(), 1);
166    }
167
168    #[test]
169    fn test_activity_feed_add_multiple() {
170        let mut feed = ActivityFeed::new();
171        feed.add("Reading file...".to_string(), false);
172        feed.add("Editing file...".to_string(), false);
173        assert_eq!(feed.len(), 2);
174        // Most recent should be first
175        assert_eq!(feed.activities[0].message, "Editing file...");
176    }
177
178    #[test]
179    fn test_activity_feed_multiple_in_progress() {
180        let mut feed = ActivityFeed::new();
181        feed.add("Reading file...".to_string(), true);
182        feed.add("Editing file...".to_string(), true);
183        // Should have both activities (no longer replaces)
184        assert_eq!(feed.len(), 2);
185        assert_eq!(feed.activities[0].message, "Editing file...");
186        assert!(feed.activities[0].in_progress);
187        assert!(feed.activities[1].in_progress);
188    }
189
190    #[test]
191    fn test_activity_feed_complete_current() {
192        let mut feed = ActivityFeed::new();
193        feed.add("Reading file...".to_string(), true);
194        assert!(feed.activities[0].in_progress);
195        feed.complete_current();
196        assert!(!feed.activities[0].in_progress);
197    }
198
199    #[test]
200    fn test_activity_feed_has_in_progress() {
201        let mut feed = ActivityFeed::new();
202        assert!(!feed.has_in_progress());
203        feed.add("Reading file...".to_string(), true);
204        assert!(feed.has_in_progress());
205        feed.complete_current();
206        assert!(!feed.has_in_progress());
207    }
208
209    #[test]
210    fn test_activity_feed_prune() {
211        let mut feed = ActivityFeed::new();
212        feed.add("Reading file...".to_string(), true);
213        feed.clear();
214        assert!(feed.is_empty());
215    }
216
217    #[test]
218    fn test_activity_feed_render() {
219        let mut buffer = Buffer::empty(Rect {
220            x: 0,
221            y: 0,
222            width: 40,
223            height: 3,
224        });
225
226        let mut feed = ActivityFeed::new();
227        feed.add("Reading file...".to_string(), false);
228        feed.add("Editing file...".to_string(), true);
229        feed.render(
230            Rect {
231                x: 0,
232                y: 0,
233                width: 40,
234                height: 3,
235            },
236            &mut buffer,
237        );
238
239        // Verify that something was rendered
240        assert!(!buffer.content.is_empty());
241    }
242}