303 lines
10 KiB
Rust
303 lines
10 KiB
Rust
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<String>,
|
|
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<SessionRenderRow> {
|
|
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<AppEvent>) {
|
|
let (event_tx, event_rx) = mpsc::channel();
|
|
let (control_tx, _control_rx) = mpsc::channel::<ControlCommand>();
|
|
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));
|
|
}
|
|
}
|