Skip to content

Commit 26f589e

Browse files
d4ncerclaude
andcommitted
feat(ui): add events state fields, scroll methods, and apply() handler
Add events panel state management to AppState: VecDeque<EventLine> ring buffer (cap 200), Option<usize> scroll tracking with auto-scroll, scroll_up/down/to_bottom methods mirroring agent_scroll pattern, and apply() handler for UiEvent::Event that adjusts pinned scroll offset when oldest entries are dropped. Add events: Option<Rect> to FrameAreas for mouse scroll routing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b64e70b commit 26f589e

1 file changed

Lines changed: 153 additions & 3 deletions

File tree

src/ui/state.rs

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
33
use std::collections::VecDeque;
44

5-
use crate::ui::event::{ToolLine, UiEvent};
5+
use crate::ui::event::{EventLine, ToolLine, UiEvent};
66

77
const MAX_TOOL_LINES: usize = 200;
8+
const MAX_EVENT_LINES: usize = 200;
89
const MAX_AGENT_CHARS: usize = 60_000;
910

1011
/// Cached rectangle positions of dashboard frames from the last render pass.
@@ -14,6 +15,7 @@ pub struct FrameAreas {
1415
pub tools: Option<ratatui::layout::Rect>,
1516
pub agent: Option<ratatui::layout::Rect>,
1617
pub input: Option<ratatui::layout::Rect>,
18+
pub events: Option<ratatui::layout::Rect>,
1719
}
1820

1921
/// Optional modal rendered above the base screen.
@@ -72,6 +74,11 @@ pub struct AppState {
7274
pub input_choices: Option<Vec<String>>,
7375
/// Which choice is highlighted in choice mode.
7476
pub input_choice_cursor: usize,
77+
/// Ring buffer of structured orchestration events for the Events panel.
78+
pub events: VecDeque<EventLine>,
79+
/// When `None`, Events panel auto-scrolls to the bottom.
80+
/// When `Some(offset)`, the user has pinned the scroll position.
81+
pub events_scroll: Option<usize>,
7582
}
7683

7784
impl Default for AppState {
@@ -96,6 +103,8 @@ impl Default for AppState {
96103
input_cursor: 0,
97104
input_choices: None,
98105
input_choice_cursor: 0,
106+
events: VecDeque::new(),
107+
events_scroll: None,
99108
}
100109
}
101110
}
@@ -167,8 +176,18 @@ impl AppState {
167176
self.tools.pop_front();
168177
}
169178
}
170-
UiEvent::Event(_) => {
171-
// TODO: Events panel state management (Phase 1 state task)
179+
UiEvent::Event(line) => {
180+
self.events.push_back(line);
181+
let mut dropped = 0usize;
182+
while self.events.len() > MAX_EVENT_LINES {
183+
self.events.pop_front();
184+
dropped += 1;
185+
}
186+
if dropped > 0 {
187+
if let Some(offset) = self.events_scroll {
188+
self.events_scroll = Some(offset.saturating_sub(dropped));
189+
}
190+
}
172191
}
173192
}
174193
}
@@ -232,6 +251,31 @@ impl AppState {
232251
self.agent_scroll = None;
233252
}
234253

254+
/// Scroll the Events panel up by `n` lines. Activates pinned scroll mode.
255+
pub fn events_scroll_up(&mut self, n: usize) {
256+
let current = self.events_scroll.unwrap_or(self.events.len());
257+
self.events_scroll = Some(current.saturating_sub(n));
258+
}
259+
260+
/// Scroll the Events panel down by `n` lines, capped at the bottom.
261+
/// Reaching the bottom resumes auto-scroll.
262+
pub fn events_scroll_down(&mut self, n: usize, max_offset: usize) {
263+
if let Some(offset) = self.events_scroll {
264+
let new = (offset + n).min(max_offset);
265+
if new >= max_offset {
266+
self.events_scroll = None;
267+
} else {
268+
self.events_scroll = Some(new);
269+
}
270+
}
271+
// If None (auto-scroll), down is a no-op — already at bottom.
272+
}
273+
274+
/// Reset Events panel to auto-scroll (follow the tail).
275+
pub fn events_scroll_to_bottom(&mut self) {
276+
self.events_scroll = None;
277+
}
278+
235279
/// Scroll the Tool Activity panel up by `n` lines.
236280
pub fn tools_scroll_up(&mut self, n: usize) {
237281
self.tools_scroll = self.tools_scroll.saturating_sub(n);
@@ -386,6 +430,112 @@ mod tests {
386430
assert!(state.input_choices.is_none());
387431
}
388432

433+
fn make_event(category: &str, message: &str) -> EventLine {
434+
EventLine {
435+
category: category.to_string(),
436+
message: message.to_string(),
437+
timestamp: "12:00:00".to_string(),
438+
is_error: false,
439+
}
440+
}
441+
442+
#[test]
443+
fn apply_handles_ui_event_event() {
444+
let mut state = AppState::default();
445+
assert!(state.events.is_empty());
446+
447+
state.apply(UiEvent::Event(make_event("task", "t-abc12345 claimed")));
448+
assert_eq!(state.events.len(), 1);
449+
assert_eq!(state.events[0].category, "task");
450+
assert_eq!(state.events[0].message, "t-abc12345 claimed");
451+
}
452+
453+
#[test]
454+
fn events_ring_buffer_caps_at_max() {
455+
let mut state = AppState::default();
456+
for i in 0..201 {
457+
state.apply(UiEvent::Event(make_event("iter", &format!("event {i}"))));
458+
}
459+
assert_eq!(state.events.len(), 200);
460+
// First event (index 0) should have been dropped; front is now event 1.
461+
assert_eq!(state.events[0].message, "event 1");
462+
assert_eq!(state.events[199].message, "event 200");
463+
}
464+
465+
#[test]
466+
fn events_ring_buffer_adjusts_scroll_offset() {
467+
let mut state = AppState::default();
468+
// Fill to capacity.
469+
for i in 0..200 {
470+
state.apply(UiEvent::Event(make_event("dag", &format!("event {i}"))));
471+
}
472+
// Pin scroll at offset 10.
473+
state.events_scroll = Some(10);
474+
475+
// Push one more — drops one from front, offset should decrease by 1.
476+
state.apply(UiEvent::Event(make_event("dag", "event 200")));
477+
assert_eq!(state.events.len(), 200);
478+
assert_eq!(state.events_scroll, Some(9));
479+
480+
// Pin scroll at 0, push another — offset stays at 0 (saturating_sub).
481+
state.events_scroll = Some(0);
482+
state.apply(UiEvent::Event(make_event("dag", "event 201")));
483+
assert_eq!(state.events_scroll, Some(0));
484+
}
485+
486+
#[test]
487+
fn events_scroll_up_from_auto_scroll() {
488+
let mut state = AppState::default();
489+
for i in 0..50 {
490+
state.apply(UiEvent::Event(make_event("task", &format!("event {i}"))));
491+
}
492+
assert_eq!(state.events_scroll, None); // auto-scroll
493+
494+
// Scroll up 5 from auto-scroll: should pin at len - 5 = 45.
495+
state.events_scroll_up(5);
496+
assert_eq!(state.events_scroll, Some(45));
497+
498+
// Scroll up 10 more: 45 - 10 = 35.
499+
state.events_scroll_up(10);
500+
assert_eq!(state.events_scroll, Some(35));
501+
502+
// Scroll up more than remaining: saturates to 0.
503+
state.events_scroll_up(100);
504+
assert_eq!(state.events_scroll, Some(0));
505+
}
506+
507+
#[test]
508+
fn events_scroll_down_resumes_auto_scroll() {
509+
let mut state = AppState::default();
510+
for i in 0..50 {
511+
state.apply(UiEvent::Event(make_event("task", &format!("event {i}"))));
512+
}
513+
let max_offset = 40; // hypothetical panel height of 10
514+
515+
// Pin at offset 30.
516+
state.events_scroll = Some(30);
517+
518+
// Scroll down 5: 30 + 5 = 35.
519+
state.events_scroll_down(5, max_offset);
520+
assert_eq!(state.events_scroll, Some(35));
521+
522+
// Scroll down 5 more: 35 + 5 = 40 >= max_offset, resume auto-scroll.
523+
state.events_scroll_down(5, max_offset);
524+
assert_eq!(state.events_scroll, None);
525+
526+
// When already auto-scrolling, down is a no-op.
527+
state.events_scroll_down(10, max_offset);
528+
assert_eq!(state.events_scroll, None);
529+
}
530+
531+
#[test]
532+
fn events_scroll_to_bottom() {
533+
let mut state = AppState::default();
534+
state.events_scroll = Some(15);
535+
state.events_scroll_to_bottom();
536+
assert_eq!(state.events_scroll, None);
537+
}
538+
389539
#[test]
390540
fn explorer_scroll_bounds() {
391541
let mut state = AppState::default();

0 commit comments

Comments
 (0)