22
33use std:: collections:: VecDeque ;
44
5- use crate :: ui:: event:: { ToolLine , UiEvent } ;
5+ use crate :: ui:: event:: { EventLine , ToolLine , UiEvent } ;
66
77const MAX_TOOL_LINES : usize = 200 ;
8+ const MAX_EVENT_LINES : usize = 200 ;
89const 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
7784impl 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