use anyhow::Result; use crate::app::{App, AppEvent, WorkspaceRuntime}; use crate::model::{ group_session_entries, ControllerState, SessionEntry, SessionSource, SessionStream, }; use crate::repo; use crate::ui::{self, SessionRenderRow}; use super::usage; pub(super) fn drain_workspace_events(app: &mut App) -> Result<()> { let Some(workspace) = app.workspace.as_mut() else { return Ok(()); }; let mut events = Vec::new(); while let Ok(event) = workspace.event_rx.try_recv() { events.push(event); } for event in events { apply_event(app, event); } Ok(()) } pub(super) fn push_local_entry( app: &mut App, source: SessionSource, stream: SessionStream, title: &str, tag: Option, body: &str, ) { let Some(workspace) = app.workspace.as_mut() else { return; }; workspace.session_follow_output = true; reset_session_interaction(workspace); workspace.session_entries.push(SessionEntry { source, stream, title: title.to_string(), tag, body: body.to_string(), run_id: repo::next_run_id(), }); workspace.session_rows = rebuild_session_rows(&workspace.session_entries); workspace.session_view = None; } pub(super) fn rebuild_session_rows(entries: &[SessionEntry]) -> Vec { ui::build_session_render_rows(&group_session_entries(entries)) } fn apply_event(app: &mut App, event: AppEvent) { let Some(workspace) = app.workspace.as_mut() else { return; }; match event { AppEvent::Session(entry) => { let should_keep_following = workspace.session_follow_output || workspace.session_scrollbar.position_lines >= workspace.session_scrollbar.scroll_range().saturating_sub(1); reset_session_interaction(workspace); workspace.session_entries.push(entry); workspace.session_rows = rebuild_session_rows(&workspace.session_entries); workspace.session_view = None; workspace.session_follow_output = should_keep_following; } AppEvent::Snapshot { goal_md, standards_md, plan, state, } => apply_snapshot(workspace, goal_md, standards_md, plan, state), AppEvent::CodexUsage { input_tokens, output_tokens, } => { *workspace.session_input_tokens.get_or_insert(0) += input_tokens; *workspace.session_output_tokens.get_or_insert(0) += output_tokens; } } } fn apply_snapshot( workspace: &mut WorkspaceRuntime, goal_md: String, standards_md: String, plan: crate::model::Plan, state: ControllerState, ) { let should_keep_following_sidebar = workspace.sidebar_follow_output || workspace.sidebar_scrollbar.position_lines >= workspace.sidebar_scrollbar.scroll_range().saturating_sub(1); let has_usage = usage::state_has_usage(&state); workspace.goal_md = goal_md; workspace.standards_md = standards_md; workspace.plan = plan; workspace.state = state.clone(); workspace.sidebar_view = None; workspace.sidebar_follow_output = should_keep_following_sidebar; if has_usage { workspace.usage_snapshot = usage::usage_snapshot_from_state(&state); } } fn reset_session_interaction(workspace: &mut WorkspaceRuntime) { workspace.session_selection = None; workspace.session_drag_active = false; workspace.session_scrollbar.stop_arrow_hold(); workspace.session_scrollbar.stop_drag(); } #[cfg(test)] mod tests { use std::path::PathBuf; use std::sync::mpsc; use std::time::Instant; use crate::app::{ControlCommand, WorkspaceRuntime}; use crate::cli::DEFAULT_TASK_CONFIG_PATH; use crate::model::{ ControllerPhase, ControllerState, Plan, Screen, SessionCursor, SessionSelection, UsageSnapshot, }; use crate::ui::{ scroll::VerticalScrollState, SessionView, SessionViewMetrics, SidebarView, SidebarViewMetrics, }; use super::*; fn sample_app() -> (App, mpsc::Sender) { let (event_tx, event_rx) = mpsc::channel(); let (control_tx, _control_rx) = mpsc::channel::(); let mut workspace = WorkspaceRuntime { task_config: crate::model::TaskConfig::default_for("alpha"), goal_md: "goal".to_string(), standards_md: "standards".to_string(), plan: Plan::default(), state: ControllerState::default(), input: String::new(), session_entries: Vec::new(), event_rx, control_tx, session_input_tokens: None, session_output_tokens: None, usage_snapshot: UsageSnapshot::unavailable("usage unavailable"), last_usage_refresh: Instant::now(), session_follow_output: false, session_scrollbar: VerticalScrollState::new(false), session_rows: Vec::new(), session_view: Some(SessionView { metrics: SessionViewMetrics { text_rect: ratatui::layout::Rect::default(), scrollbar_rect: ratatui::layout::Rect::default(), total_lines: 0, has_scrollbar: false, }, lines: Vec::new(), }), session_view_area: ratatui::layout::Rect::default(), sidebar_follow_output: false, sidebar_scrollbar: VerticalScrollState::new(false), sidebar_view: Some(SidebarView { metrics: SidebarViewMetrics { text_rect: ratatui::layout::Rect::default(), scrollbar_rect: ratatui::layout::Rect::default(), total_lines: 0, has_scrollbar: false, }, lines: Vec::new(), }), sidebar_view_area: ratatui::layout::Rect::default(), session_selection: Some(SessionSelection { anchor: SessionCursor { line: 0, column: 0 }, focus: SessionCursor { line: 0, column: 4 }, }), session_drag_active: true, }; workspace.session_scrollbar.set_content_viewport(8, 3); workspace.sidebar_scrollbar.set_content_viewport(8, 3); ( App { screen: Screen::Workspace, picker_items: Vec::new(), picker_selected: 0, create_input: String::new(), create_model_index: 0, create_fast_mode: false, create_allow_branching: false, create_error: None, default_task_path: PathBuf::from(DEFAULT_TASK_CONFIG_PATH), frame_tick: 0, workspace: Some(workspace), }, event_tx, ) } #[test] fn session_events_clear_selection_and_rebuild_rows() { let (mut app, event_tx) = sample_app(); { let workspace = app.workspace.as_mut().expect("workspace"); workspace.session_follow_output = false; workspace.session_scrollbar.position_lines = 0; } event_tx .send(AppEvent::Session(SessionEntry { source: SessionSource::Planner, stream: SessionStream::Stdout, title: "Plan".to_string(), tag: Some("alpha".to_string()), body: "first line\nsecond line".to_string(), run_id: 7, })) .expect("send session event"); drain_workspace_events(&mut app).expect("drain events"); let workspace = app.workspace.as_ref().expect("workspace"); assert_eq!(workspace.session_entries.len(), 1); assert!(workspace.session_selection.is_none()); assert!(!workspace.session_drag_active); assert!(!workspace.session_follow_output); assert!(workspace.session_view.is_none()); assert!(!workspace.session_rows.is_empty()); } #[test] fn snapshot_events_refresh_sidebar_and_cached_usage() { let (mut app, event_tx) = sample_app(); { let workspace = app.workspace.as_mut().expect("workspace"); workspace.sidebar_scrollbar.position_lines = workspace.sidebar_scrollbar.scroll_range().saturating_sub(1); } let state = ControllerState { phase: ControllerPhase::Blocked, last_usage_refresh_at: Some("123".to_string()), last_usage_input_tokens: Some(21), last_usage_output_tokens: Some(34), ..ControllerState::default() }; event_tx .send(AppEvent::Snapshot { goal_md: "updated goal".to_string(), standards_md: "updated standards".to_string(), plan: Plan::default(), state, }) .expect("send snapshot event"); drain_workspace_events(&mut app).expect("drain events"); let workspace = app.workspace.as_ref().expect("workspace"); assert_eq!(workspace.goal_md, "updated goal"); assert_eq!(workspace.standards_md, "updated standards"); assert!(workspace.sidebar_follow_output); assert!(workspace.sidebar_view.is_none()); assert_eq!(workspace.usage_snapshot.input_tokens, Some(21)); assert_eq!(workspace.usage_snapshot.output_tokens, Some(34)); assert_eq!( workspace.usage_snapshot.refreshed_at.as_deref(), Some("123") ); } #[test] fn codex_usage_events_accumulate_session_totals() { let (mut app, event_tx) = sample_app(); event_tx .send(AppEvent::CodexUsage { input_tokens: 4, output_tokens: 9, }) .expect("send first usage"); event_tx .send(AppEvent::CodexUsage { input_tokens: 6, output_tokens: 1, }) .expect("send second usage"); drain_workspace_events(&mut app).expect("drain events"); let workspace = app.workspace.as_ref().expect("workspace"); assert_eq!(workspace.session_input_tokens, Some(10)); assert_eq!(workspace.session_output_tokens, Some(10)); } }