mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 19:33:37 +08:00
11061 lines
383 KiB
Rust
11061 lines
383 KiB
Rust
use chrono::{Duration, Utc};
|
|
use crossterm::event::KeyEvent;
|
|
use ratatui::{
|
|
prelude::*,
|
|
widgets::{
|
|
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap,
|
|
},
|
|
};
|
|
use regex::Regex;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::time::UNIX_EPOCH;
|
|
use tokio::sync::broadcast;
|
|
|
|
use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter};
|
|
use crate::comms;
|
|
use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme};
|
|
use crate::observability::ToolLogEntry;
|
|
use crate::session::manager;
|
|
use crate::session::output::{
|
|
OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT,
|
|
};
|
|
use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore};
|
|
use crate::session::{FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState};
|
|
use crate::worktree;
|
|
|
|
#[cfg(test)]
|
|
use crate::session::{SessionMetrics, WorktreeInfo};
|
|
|
|
const DEFAULT_GRID_SIZE_PERCENT: u16 = 50;
|
|
const OUTPUT_PANE_PERCENT: u16 = 70;
|
|
const MIN_PANE_SIZE_PERCENT: u16 = 20;
|
|
const MAX_PANE_SIZE_PERCENT: u16 = 80;
|
|
const PANE_RESIZE_STEP_PERCENT: u16 = 5;
|
|
const MAX_LOG_ENTRIES: u64 = 12;
|
|
const MAX_DIFF_PREVIEW_LINES: usize = 6;
|
|
const MAX_DIFF_PATCH_LINES: usize = 80;
|
|
const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct WorktreeDiffColumns {
|
|
removals: Text<'static>,
|
|
additions: Text<'static>,
|
|
hunk_offsets: Vec<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct ThemePalette {
|
|
accent: Color,
|
|
row_highlight_bg: Color,
|
|
muted: Color,
|
|
help_border: Color,
|
|
}
|
|
|
|
pub struct Dashboard {
|
|
db: StateStore,
|
|
cfg: Config,
|
|
output_store: SessionOutputStore,
|
|
output_rx: broadcast::Receiver<OutputEvent>,
|
|
sessions: Vec<Session>,
|
|
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
|
unread_message_counts: HashMap<String, usize>,
|
|
approval_queue_counts: HashMap<String, usize>,
|
|
approval_queue_preview: Vec<SessionMessage>,
|
|
handoff_backlog_counts: HashMap<String, usize>,
|
|
worktree_health_by_session: HashMap<String, worktree::WorktreeHealth>,
|
|
global_handoff_backlog_leads: usize,
|
|
global_handoff_backlog_messages: usize,
|
|
daemon_activity: DaemonActivity,
|
|
selected_messages: Vec<SessionMessage>,
|
|
selected_parent_session: Option<String>,
|
|
selected_child_sessions: Vec<DelegatedChildSummary>,
|
|
focused_delegate_session_id: Option<String>,
|
|
selected_team_summary: Option<TeamSummary>,
|
|
selected_route_preview: Option<String>,
|
|
logs: Vec<ToolLogEntry>,
|
|
selected_diff_summary: Option<String>,
|
|
selected_diff_preview: Vec<String>,
|
|
selected_diff_patch: Option<String>,
|
|
selected_diff_hunk_offsets_unified: Vec<usize>,
|
|
selected_diff_hunk_offsets_split: Vec<usize>,
|
|
selected_diff_hunk: usize,
|
|
diff_view_mode: DiffViewMode,
|
|
selected_conflict_protocol: Option<String>,
|
|
selected_merge_readiness: Option<worktree::MergeReadiness>,
|
|
selected_git_status_entries: Vec<worktree::GitStatusEntry>,
|
|
selected_git_status: usize,
|
|
output_mode: OutputMode,
|
|
output_filter: OutputFilter,
|
|
output_time_filter: OutputTimeFilter,
|
|
timeline_event_filter: TimelineEventFilter,
|
|
timeline_scope: SearchScope,
|
|
selected_pane: Pane,
|
|
selected_session: usize,
|
|
show_help: bool,
|
|
operator_note: Option<String>,
|
|
pane_command_mode: bool,
|
|
output_follow: bool,
|
|
output_scroll_offset: usize,
|
|
last_output_height: usize,
|
|
metrics_scroll_offset: usize,
|
|
last_metrics_height: usize,
|
|
pane_size_percent: u16,
|
|
collapsed_panes: HashSet<Pane>,
|
|
search_input: Option<String>,
|
|
spawn_input: Option<String>,
|
|
commit_input: Option<String>,
|
|
search_query: Option<String>,
|
|
search_scope: SearchScope,
|
|
search_agent_filter: SearchAgentFilter,
|
|
search_matches: Vec<SearchMatch>,
|
|
selected_search_match: usize,
|
|
session_table_state: TableState,
|
|
last_cost_metrics_signature: Option<(u64, u128)>,
|
|
last_tool_activity_signature: Option<(u64, u128)>,
|
|
last_budget_alert_state: BudgetState,
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
struct SessionSummary {
|
|
total: usize,
|
|
projects: usize,
|
|
task_groups: usize,
|
|
pending: usize,
|
|
running: usize,
|
|
idle: usize,
|
|
stale: usize,
|
|
completed: usize,
|
|
failed: usize,
|
|
stopped: usize,
|
|
unread_messages: usize,
|
|
inbox_sessions: usize,
|
|
conflicted_worktrees: usize,
|
|
in_progress_worktrees: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
enum Pane {
|
|
Sessions,
|
|
Output,
|
|
Metrics,
|
|
Log,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum OutputMode {
|
|
SessionOutput,
|
|
Timeline,
|
|
WorktreeDiff,
|
|
ConflictProtocol,
|
|
GitStatus,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DiffViewMode {
|
|
Split,
|
|
Unified,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum OutputFilter {
|
|
All,
|
|
ErrorsOnly,
|
|
ToolCallsOnly,
|
|
FileChangesOnly,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum OutputTimeFilter {
|
|
AllTime,
|
|
Last15Minutes,
|
|
LastHour,
|
|
Last24Hours,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum TimelineEventFilter {
|
|
All,
|
|
Lifecycle,
|
|
Messages,
|
|
ToolCalls,
|
|
FileChanges,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SearchScope {
|
|
SelectedSession,
|
|
AllSessions,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SearchAgentFilter {
|
|
AllAgents,
|
|
SelectedAgentType,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum PaneDirection {
|
|
Left,
|
|
Right,
|
|
Up,
|
|
Down,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SearchMatch {
|
|
session_id: String,
|
|
line_index: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum TimelineEventType {
|
|
Lifecycle,
|
|
Message,
|
|
ToolCall,
|
|
FileChange,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TimelineEvent {
|
|
occurred_at: chrono::DateTime<Utc>,
|
|
session_id: String,
|
|
event_type: TimelineEventType,
|
|
summary: String,
|
|
detail_lines: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SpawnRequest {
|
|
requested_count: usize,
|
|
task: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SpawnPlan {
|
|
requested_count: usize,
|
|
spawn_count: usize,
|
|
task: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct PaneAreas {
|
|
sessions: Rect,
|
|
output: Option<Rect>,
|
|
metrics: Option<Rect>,
|
|
log: Option<Rect>,
|
|
}
|
|
|
|
impl PaneAreas {
|
|
fn assign(&mut self, pane: Pane, area: Rect) {
|
|
match pane {
|
|
Pane::Sessions => self.sessions = area,
|
|
Pane::Output => self.output = Some(area),
|
|
Pane::Metrics => self.metrics = Some(area),
|
|
Pane::Log => self.log = Some(area),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct AggregateUsage {
|
|
total_tokens: u64,
|
|
total_cost_usd: f64,
|
|
token_state: BudgetState,
|
|
cost_state: BudgetState,
|
|
overall_state: BudgetState,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct DelegatedChildSummary {
|
|
session_id: String,
|
|
state: SessionState,
|
|
worktree_health: Option<worktree::WorktreeHealth>,
|
|
approval_backlog: usize,
|
|
handoff_backlog: usize,
|
|
tokens_used: u64,
|
|
files_changed: u32,
|
|
duration_secs: u64,
|
|
task_preview: String,
|
|
branch: Option<String>,
|
|
last_output_preview: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
struct TeamSummary {
|
|
total: usize,
|
|
idle: usize,
|
|
running: usize,
|
|
pending: usize,
|
|
stale: usize,
|
|
failed: usize,
|
|
stopped: usize,
|
|
}
|
|
|
|
impl Dashboard {
|
|
pub fn new(db: StateStore, cfg: Config) -> Self {
|
|
Self::with_output_store(db, cfg, SessionOutputStore::default())
|
|
}
|
|
|
|
pub fn with_output_store(
|
|
db: StateStore,
|
|
cfg: Config,
|
|
output_store: SessionOutputStore,
|
|
) -> Self {
|
|
let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout);
|
|
let initial_cost_metrics_signature = metrics_file_signature(&cfg.cost_metrics_path());
|
|
let initial_tool_activity_signature =
|
|
metrics_file_signature(&cfg.tool_activity_metrics_path());
|
|
let _ = db.refresh_session_durations();
|
|
if initial_cost_metrics_signature.is_some() {
|
|
let _ = db.sync_cost_tracker_metrics(&cfg.cost_metrics_path());
|
|
}
|
|
if initial_tool_activity_signature.is_some() {
|
|
let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path());
|
|
}
|
|
let sessions = db.list_sessions().unwrap_or_default();
|
|
let output_rx = output_store.subscribe();
|
|
let mut session_table_state = TableState::default();
|
|
if !sessions.is_empty() {
|
|
session_table_state.select(Some(0));
|
|
}
|
|
|
|
let mut dashboard = Self {
|
|
db,
|
|
cfg,
|
|
output_store,
|
|
output_rx,
|
|
sessions,
|
|
session_output_cache: HashMap::new(),
|
|
unread_message_counts: HashMap::new(),
|
|
approval_queue_counts: HashMap::new(),
|
|
approval_queue_preview: Vec::new(),
|
|
handoff_backlog_counts: HashMap::new(),
|
|
worktree_health_by_session: HashMap::new(),
|
|
global_handoff_backlog_leads: 0,
|
|
global_handoff_backlog_messages: 0,
|
|
daemon_activity: DaemonActivity::default(),
|
|
selected_messages: Vec::new(),
|
|
selected_parent_session: None,
|
|
selected_child_sessions: Vec::new(),
|
|
focused_delegate_session_id: None,
|
|
selected_team_summary: None,
|
|
selected_route_preview: None,
|
|
logs: Vec::new(),
|
|
selected_diff_summary: None,
|
|
selected_diff_preview: Vec::new(),
|
|
selected_diff_patch: None,
|
|
selected_diff_hunk_offsets_unified: Vec::new(),
|
|
selected_diff_hunk_offsets_split: Vec::new(),
|
|
selected_diff_hunk: 0,
|
|
diff_view_mode: DiffViewMode::Split,
|
|
selected_conflict_protocol: None,
|
|
selected_merge_readiness: None,
|
|
selected_git_status_entries: Vec::new(),
|
|
selected_git_status: 0,
|
|
output_mode: OutputMode::SessionOutput,
|
|
output_filter: OutputFilter::All,
|
|
output_time_filter: OutputTimeFilter::AllTime,
|
|
timeline_event_filter: TimelineEventFilter::All,
|
|
timeline_scope: SearchScope::SelectedSession,
|
|
selected_pane: Pane::Sessions,
|
|
selected_session: 0,
|
|
show_help: false,
|
|
operator_note: None,
|
|
pane_command_mode: false,
|
|
output_follow: true,
|
|
output_scroll_offset: 0,
|
|
last_output_height: 0,
|
|
metrics_scroll_offset: 0,
|
|
last_metrics_height: 0,
|
|
pane_size_percent,
|
|
collapsed_panes: HashSet::new(),
|
|
search_input: None,
|
|
spawn_input: None,
|
|
commit_input: None,
|
|
search_query: None,
|
|
search_scope: SearchScope::SelectedSession,
|
|
search_agent_filter: SearchAgentFilter::AllAgents,
|
|
search_matches: Vec::new(),
|
|
selected_search_match: 0,
|
|
session_table_state,
|
|
last_cost_metrics_signature: initial_cost_metrics_signature,
|
|
last_tool_activity_signature: initial_tool_activity_signature,
|
|
last_budget_alert_state: BudgetState::Normal,
|
|
};
|
|
sort_sessions_for_display(&mut dashboard.sessions);
|
|
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
|
|
dashboard.sync_handoff_backlog_counts();
|
|
dashboard.sync_global_handoff_backlog();
|
|
dashboard.sync_selected_output();
|
|
dashboard.sync_selected_diff();
|
|
dashboard.sync_selected_messages();
|
|
dashboard.sync_selected_lineage();
|
|
dashboard.refresh_logs();
|
|
dashboard.last_budget_alert_state = dashboard.aggregate_usage().overall_state;
|
|
dashboard
|
|
}
|
|
|
|
pub fn render(&mut self, frame: &mut Frame) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3),
|
|
Constraint::Min(10),
|
|
Constraint::Length(3),
|
|
])
|
|
.split(frame.area());
|
|
|
|
self.render_header(frame, chunks[0]);
|
|
|
|
if self.show_help {
|
|
self.render_help(frame, chunks[1]);
|
|
} else {
|
|
let pane_areas = self.pane_areas(chunks[1]);
|
|
self.render_sessions(frame, pane_areas.sessions);
|
|
if let Some(output_area) = pane_areas.output {
|
|
self.render_output(frame, output_area);
|
|
}
|
|
if let Some(metrics_area) = pane_areas.metrics {
|
|
self.render_metrics(frame, metrics_area);
|
|
}
|
|
|
|
if let Some(log_area) = pane_areas.log {
|
|
self.render_log(frame, log_area);
|
|
}
|
|
}
|
|
|
|
self.render_status_bar(frame, chunks[2]);
|
|
}
|
|
|
|
fn render_header(&self, frame: &mut Frame, area: Rect) {
|
|
let running = self
|
|
.sessions
|
|
.iter()
|
|
.filter(|session| session.state == SessionState::Running)
|
|
.count();
|
|
let total = self.sessions.len();
|
|
let palette = self.theme_palette();
|
|
|
|
let title = format!(
|
|
" ECC 2.0 | {running} running / {total} total | {} {}% | {} ",
|
|
self.layout_label(),
|
|
self.pane_size_percent,
|
|
self.theme_label()
|
|
);
|
|
let tabs = Tabs::new(
|
|
self.visible_panes()
|
|
.iter()
|
|
.map(|pane| pane.title())
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.select(self.selected_pane_index())
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(palette.accent)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
frame.render_widget(tabs, area);
|
|
}
|
|
|
|
fn render_sessions(&mut self, frame: &mut Frame, area: Rect) {
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(" Sessions ")
|
|
.border_style(self.pane_border_style(Pane::Sessions));
|
|
let inner_area = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner_area.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let stabilized = self
|
|
.daemon_activity
|
|
.stabilized_after_recovery_at()
|
|
.is_some();
|
|
let summary = SessionSummary::from_sessions(
|
|
&self.sessions,
|
|
&self.handoff_backlog_counts,
|
|
&self.worktree_health_by_session,
|
|
stabilized,
|
|
);
|
|
let mut overview_lines = vec![
|
|
summary_line(&summary),
|
|
attention_queue_line(&summary, stabilized),
|
|
approval_queue_line(&self.approval_queue_counts),
|
|
];
|
|
if let Some(preview) = approval_queue_preview_line(&self.approval_queue_preview) {
|
|
overview_lines.push(preview);
|
|
}
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(overview_lines.len() as u16),
|
|
Constraint::Min(3),
|
|
])
|
|
.split(inner_area);
|
|
|
|
frame.render_widget(Paragraph::new(overview_lines), chunks[0]);
|
|
|
|
let mut previous_project: Option<&str> = None;
|
|
let mut previous_task_group: Option<&str> = None;
|
|
let rows = self.sessions.iter().map(|session| {
|
|
let project_cell = if previous_project == Some(session.project.as_str()) {
|
|
None
|
|
} else {
|
|
previous_project = Some(session.project.as_str());
|
|
previous_task_group = None;
|
|
Some(session.project.clone())
|
|
};
|
|
let task_group_cell = if previous_task_group == Some(session.task_group.as_str()) {
|
|
None
|
|
} else {
|
|
previous_task_group = Some(session.task_group.as_str());
|
|
Some(session.task_group.clone())
|
|
};
|
|
|
|
session_row(
|
|
session,
|
|
project_cell,
|
|
task_group_cell,
|
|
self.approval_queue_counts
|
|
.get(&session.id)
|
|
.copied()
|
|
.unwrap_or(0),
|
|
self.handoff_backlog_counts
|
|
.get(&session.id)
|
|
.copied()
|
|
.unwrap_or(0),
|
|
)
|
|
});
|
|
let header = Row::new([
|
|
"ID",
|
|
"Project",
|
|
"Group",
|
|
"Agent",
|
|
"State",
|
|
"Branch",
|
|
"Approvals",
|
|
"Backlog",
|
|
"Tokens",
|
|
"Tools",
|
|
"Files",
|
|
"Duration",
|
|
])
|
|
.style(Style::default().add_modifier(Modifier::BOLD));
|
|
let widths = [
|
|
Constraint::Length(8),
|
|
Constraint::Length(12),
|
|
Constraint::Length(18),
|
|
Constraint::Length(10),
|
|
Constraint::Length(10),
|
|
Constraint::Min(12),
|
|
Constraint::Length(10),
|
|
Constraint::Length(7),
|
|
Constraint::Length(8),
|
|
Constraint::Length(7),
|
|
Constraint::Length(7),
|
|
Constraint::Length(8),
|
|
];
|
|
|
|
let table = Table::new(rows, widths)
|
|
.header(header)
|
|
.column_spacing(1)
|
|
.highlight_symbol(">> ")
|
|
.highlight_spacing(HighlightSpacing::Always)
|
|
.row_highlight_style(
|
|
Style::default()
|
|
.bg(self.theme_palette().row_highlight_bg)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
let selected = if self.sessions.is_empty() {
|
|
None
|
|
} else {
|
|
Some(self.selected_session.min(self.sessions.len() - 1))
|
|
};
|
|
if self.session_table_state.selected() != selected {
|
|
self.session_table_state.select(selected);
|
|
}
|
|
|
|
frame.render_stateful_widget(table, chunks[1], &mut self.session_table_state);
|
|
}
|
|
|
|
fn render_output(&mut self, frame: &mut Frame, area: Rect) {
|
|
self.sync_output_scroll(area.height.saturating_sub(2) as usize);
|
|
|
|
if self.sessions.get(self.selected_session).is_some()
|
|
&& self.output_mode == OutputMode::WorktreeDiff
|
|
&& self.selected_diff_patch.is_some()
|
|
&& self.diff_view_mode == DiffViewMode::Split
|
|
{
|
|
self.render_split_diff_output(frame, area);
|
|
return;
|
|
}
|
|
|
|
let (title, content) = if self.sessions.get(self.selected_session).is_some() {
|
|
match self.output_mode {
|
|
OutputMode::SessionOutput => {
|
|
let lines = self.visible_output_lines();
|
|
let content = if lines.is_empty() {
|
|
Text::from(self.empty_output_message())
|
|
} else if self.search_query.is_some() {
|
|
self.render_searchable_output(&lines)
|
|
} else {
|
|
Text::from(
|
|
lines
|
|
.iter()
|
|
.map(|line| Line::from(line.text.clone()))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
};
|
|
(self.output_title(), content)
|
|
}
|
|
OutputMode::Timeline => {
|
|
let lines = self.visible_timeline_lines();
|
|
let content = if lines.is_empty() {
|
|
Text::from(self.empty_timeline_message())
|
|
} else {
|
|
Text::from(lines)
|
|
};
|
|
(self.output_title(), content)
|
|
}
|
|
OutputMode::WorktreeDiff => {
|
|
let content = if let Some(patch) = self.selected_diff_patch.as_ref() {
|
|
build_unified_diff_text(patch, self.theme_palette())
|
|
} else {
|
|
Text::from(
|
|
self.selected_diff_summary
|
|
.as_ref()
|
|
.map(|summary| {
|
|
format!(
|
|
"{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes."
|
|
)
|
|
})
|
|
.unwrap_or_else(|| {
|
|
"No worktree diff available for the selected session."
|
|
.to_string()
|
|
}),
|
|
)
|
|
};
|
|
(self.output_title(), content)
|
|
}
|
|
OutputMode::ConflictProtocol => {
|
|
let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| {
|
|
"No conflicted worktree available for the selected session.".to_string()
|
|
});
|
|
(" Conflict Protocol ".to_string(), Text::from(content))
|
|
}
|
|
OutputMode::GitStatus => {
|
|
let content = if self.selected_git_status_entries.is_empty() {
|
|
Text::from(self.empty_git_status_message())
|
|
} else {
|
|
Text::from(self.visible_git_status_lines())
|
|
};
|
|
(self.output_title(), content)
|
|
}
|
|
}
|
|
} else {
|
|
(
|
|
self.output_title(),
|
|
Text::from("No sessions. Press 'n' to start one."),
|
|
)
|
|
};
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(title)
|
|
.border_style(self.pane_border_style(Pane::Output)),
|
|
)
|
|
.scroll((self.output_scroll_offset as u16, 0));
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_split_diff_output(&mut self, frame: &mut Frame, area: Rect) {
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(self.output_title())
|
|
.border_style(self.pane_border_style(Pane::Output));
|
|
let inner_area = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner_area.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let Some(patch) = self.selected_diff_patch.as_ref() else {
|
|
return;
|
|
};
|
|
let columns = build_worktree_diff_columns(patch, self.theme_palette());
|
|
let column_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
.split(inner_area);
|
|
|
|
let removals = Paragraph::new(columns.removals)
|
|
.block(Block::default().borders(Borders::ALL).title(" Removals "))
|
|
.scroll((self.output_scroll_offset as u16, 0))
|
|
.wrap(Wrap { trim: false });
|
|
frame.render_widget(removals, column_chunks[0]);
|
|
|
|
let additions = Paragraph::new(columns.additions)
|
|
.block(Block::default().borders(Borders::ALL).title(" Additions "))
|
|
.scroll((self.output_scroll_offset as u16, 0))
|
|
.wrap(Wrap { trim: false });
|
|
frame.render_widget(additions, column_chunks[1]);
|
|
}
|
|
|
|
fn output_title(&self) -> String {
|
|
if self.output_mode == OutputMode::Timeline {
|
|
return format!(
|
|
" Timeline{}{}{} ",
|
|
self.timeline_scope.title_suffix(),
|
|
self.timeline_event_filter.title_suffix(),
|
|
self.output_time_filter.title_suffix()
|
|
);
|
|
}
|
|
|
|
if self.output_mode == OutputMode::WorktreeDiff {
|
|
return format!(
|
|
" Diff{}{} ",
|
|
self.diff_view_mode.title_suffix(),
|
|
self.diff_hunk_title_suffix()
|
|
);
|
|
}
|
|
|
|
if self.output_mode == OutputMode::GitStatus {
|
|
let staged = self
|
|
.selected_git_status_entries
|
|
.iter()
|
|
.filter(|entry| entry.staged)
|
|
.count();
|
|
let unstaged = self
|
|
.selected_git_status_entries
|
|
.iter()
|
|
.filter(|entry| entry.unstaged || entry.untracked)
|
|
.count();
|
|
let total = self.selected_git_status_entries.len();
|
|
let current = if total == 0 {
|
|
0
|
|
} else {
|
|
self.selected_git_status.min(total.saturating_sub(1)) + 1
|
|
};
|
|
return format!(
|
|
" Git status staged:{staged} unstaged:{unstaged} {current}/{total} "
|
|
);
|
|
}
|
|
|
|
let filter = format!(
|
|
"{}{}",
|
|
self.output_filter.title_suffix(),
|
|
self.output_time_filter.title_suffix()
|
|
);
|
|
let scope = self.search_scope.title_suffix();
|
|
let agent = self.search_agent_title_suffix();
|
|
if let Some(input) = self.search_input.as_ref() {
|
|
return format!(" Output{filter}{scope}{agent} /{input}_ ");
|
|
}
|
|
|
|
if let Some(query) = self.search_query.as_ref() {
|
|
let total = self.search_matches.len();
|
|
let current = if total == 0 {
|
|
0
|
|
} else {
|
|
self.selected_search_match.min(total.saturating_sub(1)) + 1
|
|
};
|
|
return format!(" Output{filter}{scope}{agent} /{query} {current}/{total} ");
|
|
}
|
|
|
|
format!(" Output{filter}{scope}{agent} ")
|
|
}
|
|
|
|
fn empty_output_message(&self) -> &'static str {
|
|
match (self.output_filter, self.output_time_filter) {
|
|
(OutputFilter::All, OutputTimeFilter::AllTime) => "Waiting for session output...",
|
|
(OutputFilter::ErrorsOnly, OutputTimeFilter::AllTime) => {
|
|
"No stderr output for this session yet."
|
|
}
|
|
(OutputFilter::ToolCallsOnly, OutputTimeFilter::AllTime) => {
|
|
"No tool-call output for this session yet."
|
|
}
|
|
(OutputFilter::FileChangesOnly, OutputTimeFilter::AllTime) => {
|
|
"No file-change output for this session yet."
|
|
}
|
|
(OutputFilter::All, _) => "No output lines in the selected time range.",
|
|
(OutputFilter::ErrorsOnly, _) => "No stderr output in the selected time range.",
|
|
(OutputFilter::ToolCallsOnly, _) => "No tool-call output in the selected time range.",
|
|
(OutputFilter::FileChangesOnly, _) => {
|
|
"No file-change output in the selected time range."
|
|
}
|
|
}
|
|
}
|
|
|
|
fn empty_git_status_message(&self) -> &'static str {
|
|
"No staged or unstaged changes for this worktree."
|
|
}
|
|
|
|
fn empty_timeline_message(&self) -> &'static str {
|
|
match (
|
|
self.timeline_scope,
|
|
self.timeline_event_filter,
|
|
self.output_time_filter,
|
|
) {
|
|
(SearchScope::AllSessions, TimelineEventFilter::All, OutputTimeFilter::AllTime) => {
|
|
"No timeline events across all sessions yet."
|
|
}
|
|
(
|
|
SearchScope::AllSessions,
|
|
TimelineEventFilter::Lifecycle,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No lifecycle events across all sessions yet.",
|
|
(
|
|
SearchScope::AllSessions,
|
|
TimelineEventFilter::Messages,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No message events across all sessions yet.",
|
|
(
|
|
SearchScope::AllSessions,
|
|
TimelineEventFilter::ToolCalls,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No tool-call events across all sessions yet.",
|
|
(
|
|
SearchScope::AllSessions,
|
|
TimelineEventFilter::FileChanges,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No file-change events across all sessions yet.",
|
|
(SearchScope::AllSessions, TimelineEventFilter::All, _) => {
|
|
"No timeline events across all sessions in the selected time range."
|
|
}
|
|
(SearchScope::AllSessions, TimelineEventFilter::Lifecycle, _) => {
|
|
"No lifecycle events across all sessions in the selected time range."
|
|
}
|
|
(SearchScope::AllSessions, TimelineEventFilter::Messages, _) => {
|
|
"No message events across all sessions in the selected time range."
|
|
}
|
|
(SearchScope::AllSessions, TimelineEventFilter::ToolCalls, _) => {
|
|
"No tool-call events across all sessions in the selected time range."
|
|
}
|
|
(SearchScope::AllSessions, TimelineEventFilter::FileChanges, _) => {
|
|
"No file-change events across all sessions in the selected time range."
|
|
}
|
|
(SearchScope::SelectedSession, TimelineEventFilter::All, OutputTimeFilter::AllTime) => {
|
|
"No timeline events for this session yet."
|
|
}
|
|
(
|
|
SearchScope::SelectedSession,
|
|
TimelineEventFilter::Lifecycle,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No lifecycle events for this session yet.",
|
|
(
|
|
SearchScope::SelectedSession,
|
|
TimelineEventFilter::Messages,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No message events for this session yet.",
|
|
(
|
|
SearchScope::SelectedSession,
|
|
TimelineEventFilter::ToolCalls,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No tool-call events for this session yet.",
|
|
(
|
|
SearchScope::SelectedSession,
|
|
TimelineEventFilter::FileChanges,
|
|
OutputTimeFilter::AllTime,
|
|
) => "No file-change events for this session yet.",
|
|
(SearchScope::SelectedSession, TimelineEventFilter::All, _) => {
|
|
"No timeline events in the selected time range."
|
|
}
|
|
(SearchScope::SelectedSession, TimelineEventFilter::Lifecycle, _) => {
|
|
"No lifecycle events in the selected time range."
|
|
}
|
|
(SearchScope::SelectedSession, TimelineEventFilter::Messages, _) => {
|
|
"No message events in the selected time range."
|
|
}
|
|
(SearchScope::SelectedSession, TimelineEventFilter::ToolCalls, _) => {
|
|
"No tool-call events in the selected time range."
|
|
}
|
|
(SearchScope::SelectedSession, TimelineEventFilter::FileChanges, _) => {
|
|
"No file-change events in the selected time range."
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> {
|
|
let Some(query) = self.search_query.as_deref() else {
|
|
return Text::from(
|
|
lines
|
|
.iter()
|
|
.map(|line| Line::from(line.text.clone()))
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
};
|
|
|
|
let selected_session_id = self.selected_session_id();
|
|
let active_match = self.search_matches.get(self.selected_search_match);
|
|
|
|
Text::from(
|
|
lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, line)| {
|
|
highlight_output_line(
|
|
&line.text,
|
|
query,
|
|
active_match
|
|
.zip(selected_session_id)
|
|
.map(|(search_match, session_id)| {
|
|
search_match.session_id == session_id
|
|
&& search_match.line_index == index
|
|
})
|
|
.unwrap_or(false),
|
|
self.theme_palette(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
}
|
|
|
|
fn render_metrics(&mut self, frame: &mut Frame, area: Rect) {
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(" Metrics ")
|
|
.border_style(self.pane_border_style(Pane::Metrics));
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(2),
|
|
Constraint::Length(2),
|
|
Constraint::Min(1),
|
|
])
|
|
.split(inner);
|
|
|
|
let aggregate = self.aggregate_usage();
|
|
let thresholds = self.cfg.effective_budget_alert_thresholds();
|
|
frame.render_widget(
|
|
TokenMeter::tokens(
|
|
"Token Budget",
|
|
aggregate.total_tokens,
|
|
self.cfg.token_budget,
|
|
thresholds,
|
|
),
|
|
chunks[0],
|
|
);
|
|
frame.render_widget(
|
|
TokenMeter::currency(
|
|
"Cost Budget",
|
|
aggregate.total_cost_usd,
|
|
self.cfg.cost_budget_usd,
|
|
thresholds,
|
|
),
|
|
chunks[1],
|
|
);
|
|
frame.render_widget(
|
|
Paragraph::new(self.selected_session_metrics_text())
|
|
.scroll((self.metrics_scroll_offset as u16, 0))
|
|
.wrap(Wrap { trim: true }),
|
|
chunks[2],
|
|
);
|
|
self.sync_metrics_scroll(chunks[2].height as usize);
|
|
}
|
|
|
|
fn render_log(&self, frame: &mut Frame, area: Rect) {
|
|
let content = if self.sessions.get(self.selected_session).is_none() {
|
|
"No session selected.".to_string()
|
|
} else if self.logs.is_empty() {
|
|
"No tool logs available for this session yet.".to_string()
|
|
} else {
|
|
self.logs
|
|
.iter()
|
|
.map(|entry| {
|
|
let mut block = format!(
|
|
"[{}] {} | {}ms | risk {:.0}%",
|
|
self.short_timestamp(&entry.timestamp),
|
|
entry.tool_name,
|
|
entry.duration_ms,
|
|
entry.risk_score * 100.0,
|
|
);
|
|
if !entry.trigger_summary.trim().is_empty() {
|
|
block.push_str(&format!(
|
|
"\nwhy: {}",
|
|
self.log_field(&entry.trigger_summary)
|
|
));
|
|
}
|
|
if entry.input_params_json.trim() != "{}" {
|
|
block.push_str(&format!(
|
|
"\nparams: {}",
|
|
self.log_field(&entry.input_params_json)
|
|
));
|
|
}
|
|
block.push_str(&format!(
|
|
"\ninput: {}\noutput: {}",
|
|
self.log_field(&entry.input_summary),
|
|
self.log_field(&entry.output_summary)
|
|
));
|
|
block
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n")
|
|
};
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(" Log ")
|
|
.border_style(self.pane_border_style(Pane::Log)),
|
|
)
|
|
.scroll((self.output_scroll_offset as u16, 0))
|
|
.wrap(Wrap { trim: false });
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
|
let base_text = format!(
|
|
" [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ",
|
|
self.pane_focus_shortcuts_label(),
|
|
self.pane_move_shortcuts_label(),
|
|
self.layout_label(),
|
|
self.theme_label()
|
|
);
|
|
|
|
let search_prefix = if let Some(input) = self.spawn_input.as_ref() {
|
|
format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |")
|
|
} else if let Some(input) = self.commit_input.as_ref() {
|
|
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
|
|
} else if let Some(input) = self.search_input.as_ref() {
|
|
format!(
|
|
" /{input}_ | {} | {} | [Enter] apply [Esc] cancel |",
|
|
self.search_scope.label(),
|
|
self.search_agent_filter_label()
|
|
)
|
|
} else if let Some(query) = self.search_query.as_ref() {
|
|
let total = self.search_matches.len();
|
|
let current = if total == 0 {
|
|
0
|
|
} else {
|
|
self.selected_search_match.min(total.saturating_sub(1)) + 1
|
|
};
|
|
format!(
|
|
" /{query} {current}/{total} | {} | {} | [n/N] navigate [Esc] clear |",
|
|
self.search_scope.label(),
|
|
self.search_agent_filter_label()
|
|
)
|
|
} else if self.pane_command_mode {
|
|
" Ctrl+w | [h/j/k/l] move [1-4] focus [s/v/g] layout [+/-] resize [Esc] cancel |"
|
|
.to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let text = if self.spawn_input.is_some()
|
|
|| self.commit_input.is_some()
|
|
|| self.search_input.is_some()
|
|
|| self.search_query.is_some()
|
|
|| self.pane_command_mode
|
|
{
|
|
format!(" {search_prefix}")
|
|
} else if let Some(note) = self.operator_note.as_ref() {
|
|
format!(" {} |{}", truncate_for_dashboard(note, 96), base_text)
|
|
} else {
|
|
base_text
|
|
};
|
|
let aggregate = self.aggregate_usage();
|
|
let (summary_text, summary_style) = self.aggregate_cost_summary();
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(aggregate.overall_state.style());
|
|
let inner = block.inner(area);
|
|
frame.render_widget(block, area);
|
|
|
|
if inner.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let summary_width = summary_text
|
|
.len()
|
|
.min(inner.width.saturating_sub(1) as usize) as u16;
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Min(1), Constraint::Length(summary_width)])
|
|
.split(inner);
|
|
|
|
frame.render_widget(
|
|
Paragraph::new(text).style(Style::default().fg(self.theme_palette().muted)),
|
|
chunks[0],
|
|
);
|
|
frame.render_widget(
|
|
Paragraph::new(summary_text)
|
|
.style(summary_style)
|
|
.alignment(Alignment::Right),
|
|
chunks[1],
|
|
);
|
|
}
|
|
|
|
fn render_help(&self, frame: &mut Frame, area: Rect) {
|
|
let help = vec![
|
|
"Keyboard Shortcuts:".to_string(),
|
|
"".to_string(),
|
|
" n New session".to_string(),
|
|
" N Natural-language multi-agent spawn prompt".to_string(),
|
|
" a Assign follow-up work from selected session".to_string(),
|
|
" b Rebalance backed-up delegate handoff backlog for selected lead".to_string(),
|
|
" B Rebalance backed-up delegate handoff backlog across lead teams".to_string(),
|
|
" i Drain unread task handoffs from selected lead".to_string(),
|
|
" I Jump to the next unread approval/conflict target session".to_string(),
|
|
" g Auto-dispatch unread handoffs across lead sessions".to_string(),
|
|
" G Dispatch then rebalance backlog across lead teams".to_string(),
|
|
" h Collapse the focused non-session pane".to_string(),
|
|
" H Restore all collapsed panes".to_string(),
|
|
" y Toggle selected-session timeline view".to_string(),
|
|
" E Cycle timeline event filter".to_string(),
|
|
" v Toggle selected worktree diff in output pane".to_string(),
|
|
" z Toggle selected worktree git status in output pane".to_string(),
|
|
" V Toggle diff view mode between split and unified".to_string(),
|
|
" {/} Jump to previous/next diff hunk in the active diff view".to_string(),
|
|
" S/U/R Stage, unstage, or reset the selected git-status entry".to_string(),
|
|
" C Commit staged changes for the selected worktree".to_string(),
|
|
" c Show conflict-resolution protocol for selected conflicted worktree"
|
|
.to_string(),
|
|
" e Cycle output content filter: all/errors/tool calls/file changes".to_string(),
|
|
" f Cycle output or timeline time range between all/15m/1h/24h".to_string(),
|
|
" A Toggle search or timeline scope between selected session and all sessions"
|
|
.to_string(),
|
|
" o Toggle search agent filter between all agents and selected agent type"
|
|
.to_string(),
|
|
" m Merge selected ready worktree into base and clean it up".to_string(),
|
|
" M Merge all ready inactive worktrees and clean them up".to_string(),
|
|
" l Cycle pane layout and persist it".to_string(),
|
|
" T Toggle theme and persist it".to_string(),
|
|
" t Toggle default worktree creation for new sessions and delegated work"
|
|
.to_string(),
|
|
" p Toggle daemon auto-dispatch policy and persist config".to_string(),
|
|
" w Toggle daemon auto-merge for ready inactive worktrees".to_string(),
|
|
" ,/. Decrease/increase auto-dispatch limit per lead".to_string(),
|
|
" s Stop selected session".to_string(),
|
|
" u Resume selected session".to_string(),
|
|
" x Cleanup selected worktree".to_string(),
|
|
" X Prune inactive worktrees globally".to_string(),
|
|
" d Delete selected inactive session".to_string(),
|
|
format!(
|
|
" {:<7} Focus Sessions/Output/Metrics/Log directly",
|
|
self.pane_focus_shortcuts_label()
|
|
),
|
|
" Ctrl+w Pane command mode: h/j/k/l move, s/v/g layout, 1-4 focus, +/- resize"
|
|
.to_string(),
|
|
" Tab Next pane".to_string(),
|
|
" S-Tab Previous pane".to_string(),
|
|
format!(
|
|
" {:<7} Move pane focus left/down/up/right",
|
|
self.pane_move_shortcuts_label()
|
|
),
|
|
" j/↓ Scroll down".to_string(),
|
|
" k/↑ Scroll up".to_string(),
|
|
" [ or ] Focus previous/next delegate in lead Metrics board".to_string(),
|
|
" Enter Open focused delegate from lead Metrics board".to_string(),
|
|
" / Search current session output".to_string(),
|
|
" n/N Next/previous search match when search is active".to_string(),
|
|
" Esc Clear active search or cancel search input".to_string(),
|
|
" +/= Increase pane size and persist it".to_string(),
|
|
" - Decrease pane size and persist it".to_string(),
|
|
" r Refresh".to_string(),
|
|
" ? Toggle help".to_string(),
|
|
" q/C-c Quit".to_string(),
|
|
];
|
|
|
|
let paragraph = Paragraph::new(help.join("\n")).block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(" Help ")
|
|
.border_style(Style::default().fg(self.theme_palette().help_border)),
|
|
);
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
pub fn next_pane(&mut self) {
|
|
let visible_panes = self.visible_panes();
|
|
let next_index = self
|
|
.selected_pane_index()
|
|
.checked_add(1)
|
|
.map(|index| index % visible_panes.len())
|
|
.unwrap_or(0);
|
|
|
|
self.selected_pane = visible_panes[next_index];
|
|
}
|
|
|
|
pub fn prev_pane(&mut self) {
|
|
let visible_panes = self.visible_panes();
|
|
let previous_index = if self.selected_pane_index() == 0 {
|
|
visible_panes.len() - 1
|
|
} else {
|
|
self.selected_pane_index() - 1
|
|
};
|
|
|
|
self.selected_pane = visible_panes[previous_index];
|
|
}
|
|
|
|
pub fn focus_pane_number(&mut self, slot: usize) {
|
|
let Some(target) = Pane::from_shortcut(slot) else {
|
|
self.set_operator_note(format!("pane {slot} is not available"));
|
|
return;
|
|
};
|
|
|
|
if !self.visible_panes().contains(&target) {
|
|
self.set_operator_note(format!(
|
|
"{} pane is not visible",
|
|
target.title().to_lowercase()
|
|
));
|
|
return;
|
|
}
|
|
|
|
self.focus_pane(target);
|
|
}
|
|
|
|
pub fn focus_pane_left(&mut self) {
|
|
self.move_pane_focus(PaneDirection::Left);
|
|
}
|
|
|
|
pub fn focus_pane_right(&mut self) {
|
|
self.move_pane_focus(PaneDirection::Right);
|
|
}
|
|
|
|
pub fn focus_pane_up(&mut self) {
|
|
self.move_pane_focus(PaneDirection::Up);
|
|
}
|
|
|
|
pub fn focus_pane_down(&mut self) {
|
|
self.move_pane_focus(PaneDirection::Down);
|
|
}
|
|
|
|
pub fn begin_pane_command_mode(&mut self) {
|
|
self.pane_command_mode = true;
|
|
self.set_operator_note(
|
|
"pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize".to_string(),
|
|
);
|
|
}
|
|
|
|
pub fn is_pane_command_mode(&self) -> bool {
|
|
self.pane_command_mode
|
|
}
|
|
|
|
pub fn handle_pane_navigation_key(&mut self, key: KeyEvent) -> bool {
|
|
match self.cfg.pane_navigation.action_for_key(key) {
|
|
Some(PaneNavigationAction::FocusSlot(slot)) => {
|
|
self.focus_pane_number(slot);
|
|
true
|
|
}
|
|
Some(PaneNavigationAction::MoveLeft) => {
|
|
self.focus_pane_left();
|
|
true
|
|
}
|
|
Some(PaneNavigationAction::MoveDown) => {
|
|
self.focus_pane_down();
|
|
true
|
|
}
|
|
Some(PaneNavigationAction::MoveUp) => {
|
|
self.focus_pane_up();
|
|
true
|
|
}
|
|
Some(PaneNavigationAction::MoveRight) => {
|
|
self.focus_pane_right();
|
|
true
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
pub fn handle_pane_command_key(&mut self, key: KeyEvent) -> bool {
|
|
if !self.pane_command_mode {
|
|
return false;
|
|
}
|
|
|
|
self.pane_command_mode = false;
|
|
match key.code {
|
|
crossterm::event::KeyCode::Esc => {
|
|
self.set_operator_note("pane command cancelled".to_string());
|
|
}
|
|
crossterm::event::KeyCode::Char('h') => self.focus_pane_left(),
|
|
crossterm::event::KeyCode::Char('j') => self.focus_pane_down(),
|
|
crossterm::event::KeyCode::Char('k') => self.focus_pane_up(),
|
|
crossterm::event::KeyCode::Char('l') => self.focus_pane_right(),
|
|
crossterm::event::KeyCode::Char('1') => self.focus_pane_number(1),
|
|
crossterm::event::KeyCode::Char('2') => self.focus_pane_number(2),
|
|
crossterm::event::KeyCode::Char('3') => self.focus_pane_number(3),
|
|
crossterm::event::KeyCode::Char('4') => self.focus_pane_number(4),
|
|
crossterm::event::KeyCode::Char('+') | crossterm::event::KeyCode::Char('=') => {
|
|
self.increase_pane_size()
|
|
}
|
|
crossterm::event::KeyCode::Char('-') => self.decrease_pane_size(),
|
|
crossterm::event::KeyCode::Char('s') => self.set_pane_layout(PaneLayout::Horizontal),
|
|
crossterm::event::KeyCode::Char('v') => self.set_pane_layout(PaneLayout::Vertical),
|
|
crossterm::event::KeyCode::Char('g') => self.set_pane_layout(PaneLayout::Grid),
|
|
_ => self.set_operator_note("unknown pane command".to_string()),
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn collapse_selected_pane(&mut self) {
|
|
if self.selected_pane == Pane::Sessions {
|
|
self.set_operator_note("cannot collapse sessions pane".to_string());
|
|
return;
|
|
}
|
|
|
|
if self.visible_detail_panes().len() <= 1 {
|
|
self.set_operator_note("cannot collapse last detail pane".to_string());
|
|
return;
|
|
}
|
|
|
|
let collapsed = self.selected_pane;
|
|
self.collapsed_panes.insert(collapsed);
|
|
self.ensure_selected_pane_visible();
|
|
self.set_operator_note(format!(
|
|
"collapsed {} pane",
|
|
collapsed.title().to_lowercase()
|
|
));
|
|
}
|
|
|
|
pub fn restore_collapsed_panes(&mut self) {
|
|
if self.collapsed_panes.is_empty() {
|
|
self.set_operator_note("no collapsed panes".to_string());
|
|
return;
|
|
}
|
|
|
|
let restored_count = self.collapsed_panes.len();
|
|
self.collapsed_panes.clear();
|
|
self.ensure_selected_pane_visible();
|
|
self.set_operator_note(format!("restored {restored_count} collapsed pane(s)"));
|
|
}
|
|
|
|
pub fn cycle_pane_layout(&mut self) {
|
|
let config_path = crate::config::Config::config_path();
|
|
self.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save());
|
|
}
|
|
|
|
pub fn set_pane_layout(&mut self, layout: PaneLayout) {
|
|
let config_path = crate::config::Config::config_path();
|
|
self.set_pane_layout_with_save(layout, &config_path, |cfg| cfg.save());
|
|
}
|
|
|
|
fn cycle_pane_layout_with_save<F>(&mut self, config_path: &std::path::Path, save: F)
|
|
where
|
|
F: FnOnce(&Config) -> anyhow::Result<()>,
|
|
{
|
|
let previous_layout = self.cfg.pane_layout;
|
|
let previous_pane_size = self.pane_size_percent;
|
|
let previous_selected_pane = self.selected_pane;
|
|
|
|
self.cfg.pane_layout = match self.cfg.pane_layout {
|
|
PaneLayout::Horizontal => PaneLayout::Vertical,
|
|
PaneLayout::Vertical => PaneLayout::Grid,
|
|
PaneLayout::Grid => PaneLayout::Horizontal,
|
|
};
|
|
self.pane_size_percent = configured_pane_size(&self.cfg, self.cfg.pane_layout);
|
|
self.persist_current_pane_size();
|
|
self.ensure_selected_pane_visible();
|
|
|
|
match save(&self.cfg) {
|
|
Ok(()) => self.set_operator_note(format!(
|
|
"pane layout set to {} | saved to {}",
|
|
self.layout_label(),
|
|
config_path.display()
|
|
)),
|
|
Err(error) => {
|
|
self.cfg.pane_layout = previous_layout;
|
|
self.pane_size_percent = previous_pane_size;
|
|
self.selected_pane = previous_selected_pane;
|
|
self.set_operator_note(format!("failed to persist pane layout: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_pane_layout_with_save<F>(
|
|
&mut self,
|
|
layout: PaneLayout,
|
|
config_path: &std::path::Path,
|
|
save: F,
|
|
) where
|
|
F: FnOnce(&Config) -> anyhow::Result<()>,
|
|
{
|
|
if self.cfg.pane_layout == layout {
|
|
self.set_operator_note(format!("pane layout already {}", self.layout_label()));
|
|
return;
|
|
}
|
|
|
|
let previous_layout = self.cfg.pane_layout;
|
|
let previous_pane_size = self.pane_size_percent;
|
|
let previous_selected_pane = self.selected_pane;
|
|
|
|
self.cfg.pane_layout = layout;
|
|
self.pane_size_percent = configured_pane_size(&self.cfg, self.cfg.pane_layout);
|
|
self.persist_current_pane_size();
|
|
self.ensure_selected_pane_visible();
|
|
|
|
match save(&self.cfg) {
|
|
Ok(()) => self.set_operator_note(format!(
|
|
"pane layout set to {} | saved to {}",
|
|
self.layout_label(),
|
|
config_path.display()
|
|
)),
|
|
Err(error) => {
|
|
self.cfg.pane_layout = previous_layout;
|
|
self.pane_size_percent = previous_pane_size;
|
|
self.selected_pane = previous_selected_pane;
|
|
self.set_operator_note(format!("failed to persist pane layout: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn auto_split_layout_after_spawn(&mut self, spawned_count: usize) -> Option<String> {
|
|
let config_path = crate::config::Config::config_path();
|
|
self.auto_split_layout_after_spawn_with_save(spawned_count, &config_path, |cfg| cfg.save())
|
|
}
|
|
|
|
fn auto_split_layout_after_spawn_with_save<F>(
|
|
&mut self,
|
|
spawned_count: usize,
|
|
config_path: &std::path::Path,
|
|
save: F,
|
|
) -> Option<String>
|
|
where
|
|
F: FnOnce(&Config) -> anyhow::Result<()>,
|
|
{
|
|
if spawned_count <= 1 {
|
|
return None;
|
|
}
|
|
|
|
let live_session_count = self.active_session_count();
|
|
let target_layout = recommended_spawn_layout(live_session_count);
|
|
if self.cfg.pane_layout == target_layout {
|
|
self.selected_pane = Pane::Sessions;
|
|
self.ensure_selected_pane_visible();
|
|
return Some(format!(
|
|
"auto-focused sessions in {} layout for {} live session(s)",
|
|
pane_layout_name(target_layout),
|
|
live_session_count
|
|
));
|
|
}
|
|
|
|
let previous_layout = self.cfg.pane_layout;
|
|
let previous_pane_size = self.pane_size_percent;
|
|
let previous_selected_pane = self.selected_pane;
|
|
|
|
self.cfg.pane_layout = target_layout;
|
|
self.pane_size_percent = configured_pane_size(&self.cfg, target_layout);
|
|
self.persist_current_pane_size();
|
|
self.selected_pane = Pane::Sessions;
|
|
self.ensure_selected_pane_visible();
|
|
|
|
match save(&self.cfg) {
|
|
Ok(()) => Some(format!(
|
|
"auto-split {} layout for {} live session(s)",
|
|
pane_layout_name(target_layout),
|
|
live_session_count
|
|
)),
|
|
Err(error) => {
|
|
self.cfg.pane_layout = previous_layout;
|
|
self.pane_size_percent = previous_pane_size;
|
|
self.selected_pane = previous_selected_pane;
|
|
Some(format!(
|
|
"spawned {} session(s) but failed to persist auto-split layout to {}: {error}",
|
|
spawned_count,
|
|
config_path.display()
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn adjust_pane_size_with_save<F>(
|
|
&mut self,
|
|
delta: isize,
|
|
config_path: &std::path::Path,
|
|
save: F,
|
|
) where
|
|
F: FnOnce(&Config) -> anyhow::Result<()>,
|
|
{
|
|
let previous_size = self.pane_size_percent;
|
|
let previous_linear = self.cfg.linear_pane_size_percent;
|
|
let previous_grid = self.cfg.grid_pane_size_percent;
|
|
let next = (self.pane_size_percent as isize + delta).clamp(
|
|
MIN_PANE_SIZE_PERCENT as isize,
|
|
MAX_PANE_SIZE_PERCENT as isize,
|
|
) as u16;
|
|
|
|
if next == self.pane_size_percent {
|
|
self.set_operator_note(format!(
|
|
"pane size unchanged at {}% for {} layout",
|
|
self.pane_size_percent,
|
|
self.layout_label()
|
|
));
|
|
return;
|
|
}
|
|
|
|
self.pane_size_percent = next;
|
|
self.persist_current_pane_size();
|
|
|
|
match save(&self.cfg) {
|
|
Ok(()) => self.set_operator_note(format!(
|
|
"pane size set to {}% for {} layout | saved to {}",
|
|
self.pane_size_percent,
|
|
self.layout_label(),
|
|
config_path.display()
|
|
)),
|
|
Err(error) => {
|
|
self.pane_size_percent = previous_size;
|
|
self.cfg.linear_pane_size_percent = previous_linear;
|
|
self.cfg.grid_pane_size_percent = previous_grid;
|
|
self.set_operator_note(format!("failed to persist pane size: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn persist_current_pane_size(&mut self) {
|
|
match self.cfg.pane_layout {
|
|
PaneLayout::Horizontal | PaneLayout::Vertical => {
|
|
self.cfg.linear_pane_size_percent = self.pane_size_percent;
|
|
}
|
|
PaneLayout::Grid => {
|
|
self.cfg.grid_pane_size_percent = self.pane_size_percent;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_theme(&mut self) {
|
|
let config_path = crate::config::Config::config_path();
|
|
self.toggle_theme_with_save(&config_path, |cfg| cfg.save());
|
|
}
|
|
|
|
fn toggle_theme_with_save<F>(&mut self, config_path: &std::path::Path, save: F)
|
|
where
|
|
F: FnOnce(&Config) -> anyhow::Result<()>,
|
|
{
|
|
let previous_theme = self.cfg.theme;
|
|
self.cfg.theme = match self.cfg.theme {
|
|
Theme::Dark => Theme::Light,
|
|
Theme::Light => Theme::Dark,
|
|
};
|
|
|
|
match save(&self.cfg) {
|
|
Ok(()) => self.set_operator_note(format!(
|
|
"theme set to {} | saved to {}",
|
|
self.theme_label(),
|
|
config_path.display()
|
|
)),
|
|
Err(error) => {
|
|
self.cfg.theme = previous_theme;
|
|
self.set_operator_note(format!("failed to persist theme: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn increase_pane_size(&mut self) {
|
|
let config_path = crate::config::Config::config_path();
|
|
self.adjust_pane_size_with_save(PANE_RESIZE_STEP_PERCENT as isize, &config_path, |cfg| {
|
|
cfg.save()
|
|
});
|
|
}
|
|
|
|
pub fn decrease_pane_size(&mut self) {
|
|
let config_path = crate::config::Config::config_path();
|
|
self.adjust_pane_size_with_save(
|
|
-(PANE_RESIZE_STEP_PERCENT as isize),
|
|
&config_path,
|
|
|cfg| cfg.save(),
|
|
);
|
|
}
|
|
|
|
pub fn scroll_down(&mut self) {
|
|
match self.selected_pane {
|
|
Pane::Sessions if !self.sessions.is_empty() => {
|
|
self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1);
|
|
self.sync_selection();
|
|
self.reset_output_view();
|
|
self.reset_metrics_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
}
|
|
Pane::Output => {
|
|
if self.output_mode == OutputMode::GitStatus {
|
|
self.output_follow = false;
|
|
if self.selected_git_status + 1 < self.selected_git_status_entries.len() {
|
|
self.selected_git_status += 1;
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
}
|
|
return;
|
|
}
|
|
let max_scroll = self.max_output_scroll();
|
|
if self.output_follow {
|
|
return;
|
|
}
|
|
|
|
if self.output_scroll_offset >= max_scroll.saturating_sub(1) {
|
|
self.output_follow = true;
|
|
self.output_scroll_offset = max_scroll;
|
|
} else {
|
|
self.output_scroll_offset = self.output_scroll_offset.saturating_add(1);
|
|
}
|
|
}
|
|
Pane::Metrics => {
|
|
let max_scroll = self.max_metrics_scroll();
|
|
self.metrics_scroll_offset =
|
|
self.metrics_scroll_offset.saturating_add(1).min(max_scroll);
|
|
}
|
|
Pane::Log => {
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = self.output_scroll_offset.saturating_add(1);
|
|
}
|
|
Pane::Sessions => {}
|
|
}
|
|
}
|
|
|
|
pub fn scroll_up(&mut self) {
|
|
match self.selected_pane {
|
|
Pane::Sessions => {
|
|
self.selected_session = self.selected_session.saturating_sub(1);
|
|
self.sync_selection();
|
|
self.reset_output_view();
|
|
self.reset_metrics_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
}
|
|
Pane::Output => {
|
|
if self.output_mode == OutputMode::GitStatus {
|
|
self.output_follow = false;
|
|
self.selected_git_status = self.selected_git_status.saturating_sub(1);
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
return;
|
|
}
|
|
if self.output_follow {
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = self.max_output_scroll();
|
|
}
|
|
|
|
self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1);
|
|
}
|
|
Pane::Metrics => {
|
|
self.metrics_scroll_offset = self.metrics_scroll_offset.saturating_sub(1);
|
|
}
|
|
Pane::Log => {
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn focus_next_delegate(&mut self) {
|
|
let Some(current_index) = self.focused_delegate_index() else {
|
|
return;
|
|
};
|
|
let next_index = (current_index + 1) % self.selected_child_sessions.len();
|
|
self.set_focused_delegate_by_index(next_index);
|
|
}
|
|
|
|
pub fn focus_previous_delegate(&mut self) {
|
|
let Some(current_index) = self.focused_delegate_index() else {
|
|
return;
|
|
};
|
|
let previous_index = if current_index == 0 {
|
|
self.selected_child_sessions.len() - 1
|
|
} else {
|
|
current_index - 1
|
|
};
|
|
self.set_focused_delegate_by_index(previous_index);
|
|
}
|
|
|
|
pub fn open_focused_delegate(&mut self) {
|
|
let Some(delegate_session_id) = self
|
|
.focused_delegate_index()
|
|
.and_then(|index| self.selected_child_sessions.get(index))
|
|
.map(|delegate| delegate.session_id.clone())
|
|
else {
|
|
return;
|
|
};
|
|
|
|
self.sync_selection_by_id(Some(&delegate_session_id));
|
|
self.reset_output_view();
|
|
self.reset_metrics_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
self.set_operator_note(format!(
|
|
"opened delegate {}",
|
|
format_session_id(&delegate_session_id)
|
|
));
|
|
}
|
|
|
|
pub fn focus_next_approval_target(&mut self) {
|
|
self.sync_approval_queue();
|
|
let Some(target_session_id) = self.next_approval_target_session_id() else {
|
|
self.set_operator_note("approval queue clear".to_string());
|
|
return;
|
|
};
|
|
|
|
self.sync_selection_by_id(Some(&target_session_id));
|
|
self.reset_output_view();
|
|
self.reset_metrics_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.unread_message_counts = self.db.unread_message_counts().unwrap_or_default();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
self.set_operator_note(format!(
|
|
"focused approval target {}",
|
|
format_session_id(&target_session_id)
|
|
));
|
|
}
|
|
|
|
pub async fn new_session(&mut self) {
|
|
if self.active_session_count() >= self.cfg.max_parallel_sessions {
|
|
tracing::warn!(
|
|
"Cannot queue new session: active session limit reached ({})",
|
|
self.cfg.max_parallel_sessions
|
|
);
|
|
self.set_operator_note(format!(
|
|
"cannot queue new session: active session limit reached ({})",
|
|
self.cfg.max_parallel_sessions
|
|
));
|
|
return;
|
|
}
|
|
|
|
let task = self.new_session_task();
|
|
let agent = self.cfg.default_agent.clone();
|
|
let grouping = self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| SessionGrouping {
|
|
project: Some(session.project.clone()),
|
|
task_group: Some(session.task_group.clone()),
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let session_id = match manager::create_session_with_grouping(
|
|
&self.db,
|
|
&self.cfg,
|
|
&task,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
grouping,
|
|
)
|
|
.await
|
|
{
|
|
Ok(session_id) => session_id,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to create new session from dashboard: {error}");
|
|
self.set_operator_note(format!("new session failed: {error}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Some(source_session) = self.sessions.get(self.selected_session) {
|
|
let context = format!(
|
|
"Dashboard handoff from {} [{}] | cwd {}{}",
|
|
format_session_id(&source_session.id),
|
|
source_session.agent_type,
|
|
source_session.working_dir.display(),
|
|
source_session
|
|
.worktree
|
|
.as_ref()
|
|
.map(|worktree| format!(
|
|
" | worktree {} ({})",
|
|
worktree.branch,
|
|
worktree.path.display()
|
|
))
|
|
.unwrap_or_default()
|
|
);
|
|
if let Err(error) = comms::send(
|
|
&self.db,
|
|
&source_session.id,
|
|
&session_id,
|
|
&comms::MessageType::TaskHandoff {
|
|
task: source_session.task.clone(),
|
|
context,
|
|
},
|
|
) {
|
|
tracing::warn!(
|
|
"Failed to send handoff from session {} to {}: {error}",
|
|
source_session.id,
|
|
session_id
|
|
);
|
|
}
|
|
}
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(Some(&session_id));
|
|
let queued_for_worktree = self
|
|
.db
|
|
.pending_worktree_queue_contains(&session_id)
|
|
.unwrap_or(false);
|
|
if queued_for_worktree {
|
|
self.set_operator_note(format!(
|
|
"queued session {} pending worktree slot",
|
|
format_session_id(&session_id)
|
|
));
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"spawned session {}",
|
|
format_session_id(&session_id)
|
|
));
|
|
}
|
|
self.reset_output_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
self.sync_budget_alerts();
|
|
}
|
|
|
|
pub fn toggle_output_mode(&mut self) {
|
|
match self.output_mode {
|
|
OutputMode::SessionOutput => {
|
|
if self.selected_diff_patch.is_some() || self.selected_diff_summary.is_some() {
|
|
self.output_mode = OutputMode::WorktreeDiff;
|
|
self.selected_pane = Pane::Output;
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = self.current_diff_hunk_offset();
|
|
self.set_operator_note("showing selected worktree diff".to_string());
|
|
} else {
|
|
self.set_operator_note("no worktree diff for selected session".to_string());
|
|
}
|
|
}
|
|
OutputMode::WorktreeDiff => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
OutputMode::Timeline => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
OutputMode::ConflictProtocol => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
OutputMode::GitStatus => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_git_status_mode(&mut self) {
|
|
match self.output_mode {
|
|
OutputMode::GitStatus => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
_ => {
|
|
let has_worktree = self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.and_then(|session| session.worktree.as_ref())
|
|
.is_some();
|
|
if !has_worktree {
|
|
self.set_operator_note("selected session has no worktree".to_string());
|
|
return;
|
|
}
|
|
|
|
self.sync_selected_git_status();
|
|
self.output_mode = OutputMode::GitStatus;
|
|
self.selected_pane = Pane::Output;
|
|
self.output_follow = false;
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
self.set_operator_note("showing selected worktree git status".to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn stage_selected_git_status(&mut self) {
|
|
if self.output_mode != OutputMode::GitStatus {
|
|
self.set_operator_note(
|
|
"git staging controls are only available in git status view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let Some((entry, worktree)) = self.selected_git_status_context() else {
|
|
self.set_operator_note("no git status entry selected".to_string());
|
|
return;
|
|
};
|
|
|
|
if let Err(error) = worktree::stage_path(&worktree, &entry.path) {
|
|
tracing::warn!("Failed to stage {}: {error}", entry.path);
|
|
self.set_operator_note(format!("stage failed for {}: {error}", entry.display_path));
|
|
return;
|
|
}
|
|
|
|
self.refresh_after_git_status_action(Some(&entry.path));
|
|
self.set_operator_note(format!("staged {}", entry.display_path));
|
|
}
|
|
|
|
pub fn unstage_selected_git_status(&mut self) {
|
|
if self.output_mode != OutputMode::GitStatus {
|
|
self.set_operator_note(
|
|
"git staging controls are only available in git status view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let Some((entry, worktree)) = self.selected_git_status_context() else {
|
|
self.set_operator_note("no git status entry selected".to_string());
|
|
return;
|
|
};
|
|
|
|
if let Err(error) = worktree::unstage_path(&worktree, &entry.path) {
|
|
tracing::warn!("Failed to unstage {}: {error}", entry.path);
|
|
self.set_operator_note(format!("unstage failed for {}: {error}", entry.display_path));
|
|
return;
|
|
}
|
|
|
|
self.refresh_after_git_status_action(Some(&entry.path));
|
|
self.set_operator_note(format!("unstaged {}", entry.display_path));
|
|
}
|
|
|
|
pub fn reset_selected_git_status(&mut self) {
|
|
if self.output_mode != OutputMode::GitStatus {
|
|
self.set_operator_note(
|
|
"git staging controls are only available in git status view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let Some((entry, worktree)) = self.selected_git_status_context() else {
|
|
self.set_operator_note("no git status entry selected".to_string());
|
|
return;
|
|
};
|
|
|
|
if let Err(error) = worktree::reset_path(&worktree, &entry) {
|
|
tracing::warn!("Failed to reset {}: {error}", entry.path);
|
|
self.set_operator_note(format!("reset failed for {}: {error}", entry.display_path));
|
|
return;
|
|
}
|
|
|
|
self.refresh_after_git_status_action(Some(&entry.path));
|
|
self.set_operator_note(format!("reset {}", entry.display_path));
|
|
}
|
|
|
|
pub fn begin_commit_prompt(&mut self) {
|
|
if self.output_mode != OutputMode::GitStatus {
|
|
self.set_operator_note("commit prompt is only available in git status view".to_string());
|
|
return;
|
|
}
|
|
|
|
if self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.and_then(|session| session.worktree.as_ref())
|
|
.is_none()
|
|
{
|
|
self.set_operator_note("selected session has no worktree".to_string());
|
|
return;
|
|
}
|
|
|
|
if !self.selected_git_status_entries.iter().any(|entry| entry.staged) {
|
|
self.set_operator_note("no staged changes to commit".to_string());
|
|
return;
|
|
}
|
|
|
|
self.commit_input = Some(String::new());
|
|
self.set_operator_note("commit mode | type a message and press Enter".to_string());
|
|
}
|
|
|
|
pub fn toggle_diff_view_mode(&mut self) {
|
|
if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() {
|
|
self.set_operator_note("no active worktree diff view to toggle".to_string());
|
|
return;
|
|
}
|
|
|
|
self.diff_view_mode = match self.diff_view_mode {
|
|
DiffViewMode::Split => DiffViewMode::Unified,
|
|
DiffViewMode::Unified => DiffViewMode::Split,
|
|
};
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = self.current_diff_hunk_offset();
|
|
self.set_operator_note(format!("diff view set to {}", self.diff_view_mode.label()));
|
|
}
|
|
|
|
pub fn next_diff_hunk(&mut self) {
|
|
self.move_diff_hunk(1);
|
|
}
|
|
|
|
pub fn prev_diff_hunk(&mut self) {
|
|
self.move_diff_hunk(-1);
|
|
}
|
|
|
|
fn move_diff_hunk(&mut self, delta: isize) {
|
|
if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() {
|
|
self.set_operator_note("no active worktree diff to navigate".to_string());
|
|
return;
|
|
}
|
|
|
|
let (len, next_offset) = {
|
|
let offsets = self.current_diff_hunk_offsets();
|
|
if offsets.is_empty() {
|
|
self.set_operator_note("no diff hunks in bounded preview".to_string());
|
|
return;
|
|
}
|
|
|
|
let len = offsets.len();
|
|
let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize;
|
|
(len, offsets[next])
|
|
};
|
|
|
|
let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize;
|
|
self.selected_diff_hunk = next;
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = next_offset;
|
|
self.set_operator_note(format!("diff hunk {}/{}", next + 1, len));
|
|
}
|
|
|
|
pub fn toggle_timeline_mode(&mut self) {
|
|
match self.output_mode {
|
|
OutputMode::Timeline => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
_ => {
|
|
if self.sessions.get(self.selected_session).is_some() {
|
|
self.output_mode = OutputMode::Timeline;
|
|
self.selected_pane = Pane::Output;
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = 0;
|
|
self.set_operator_note("showing selected session timeline".to_string());
|
|
} else {
|
|
self.set_operator_note("no session selected for timeline view".to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_conflict_protocol_mode(&mut self) {
|
|
match self.output_mode {
|
|
OutputMode::ConflictProtocol => {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
self.reset_output_view();
|
|
self.set_operator_note("showing session output".to_string());
|
|
}
|
|
_ => {
|
|
if self.selected_conflict_protocol.is_some() {
|
|
self.output_mode = OutputMode::ConflictProtocol;
|
|
self.selected_pane = Pane::Output;
|
|
self.output_follow = false;
|
|
self.output_scroll_offset = 0;
|
|
self.set_operator_note("showing worktree conflict protocol".to_string());
|
|
} else {
|
|
self.set_operator_note(
|
|
"no conflicted worktree for selected session".to_string(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn assign_selected(&mut self) {
|
|
let Some(source_session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
let task = self.new_session_task();
|
|
let agent = self.cfg.default_agent.clone();
|
|
|
|
let outcome = match manager::assign_session(
|
|
&self.db,
|
|
&self.cfg,
|
|
&source_session.id,
|
|
&task,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcome) => outcome,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to assign follow-up work from session {}: {error}",
|
|
source_session.id
|
|
);
|
|
self.set_operator_note(format!("assignment failed: {error}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(Some(&outcome.session_id));
|
|
self.set_operator_note(format!(
|
|
"assigned via {} -> {}",
|
|
assignment_action_label(outcome.action),
|
|
format_session_id(&outcome.session_id)
|
|
));
|
|
self.reset_output_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
}
|
|
|
|
pub async fn rebalance_selected_team(&mut self) {
|
|
let Some(source_session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
let agent = self.cfg.default_agent.clone();
|
|
let source_session_id = source_session.id.clone();
|
|
let outcomes = match manager::rebalance_team_backlog(
|
|
&self.db,
|
|
&self.cfg,
|
|
&source_session_id,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
self.cfg.auto_dispatch_limit_per_session,
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcomes) => outcomes,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to rebalance team backlog for session {}: {error}",
|
|
source_session_id
|
|
);
|
|
self.set_operator_note(format!(
|
|
"rebalance failed for {}: {error}",
|
|
format_session_id(&source_session_id)
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(Some(&source_session_id));
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
|
|
if outcomes.is_empty() {
|
|
self.set_operator_note(format!(
|
|
"no delegate backlog needed rebalancing for {}",
|
|
format_session_id(&source_session_id)
|
|
));
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"rebalanced {} delegate handoff(s) for {}",
|
|
outcomes.len(),
|
|
format_session_id(&source_session_id)
|
|
));
|
|
}
|
|
}
|
|
|
|
pub async fn drain_inbox_selected(&mut self) {
|
|
let Some(source_session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
let agent = self.cfg.default_agent.clone();
|
|
let source_session_id = source_session.id.clone();
|
|
|
|
let outcomes = match manager::drain_inbox(
|
|
&self.db,
|
|
&self.cfg,
|
|
&source_session_id,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
self.cfg.max_parallel_sessions,
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcomes) => outcomes,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to drain inbox for session {}: {error}",
|
|
source_session_id
|
|
);
|
|
self.set_operator_note(format!(
|
|
"drain inbox failed for {}: {error}",
|
|
format_session_id(&source_session_id)
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(Some(&source_session_id));
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
|
|
if outcomes.is_empty() {
|
|
self.set_operator_note(format!(
|
|
"no unread handoffs for {}",
|
|
format_session_id(&source_session_id)
|
|
));
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"drained {} handoff(s) from {}",
|
|
outcomes.len(),
|
|
format_session_id(&source_session_id)
|
|
));
|
|
}
|
|
}
|
|
|
|
pub async fn auto_dispatch_backlog(&mut self) {
|
|
let agent = self.cfg.default_agent.clone();
|
|
let lead_limit = self.sessions.len().max(1);
|
|
|
|
let outcomes = match manager::auto_dispatch_backlog(
|
|
&self.db,
|
|
&self.cfg,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
lead_limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcomes) => outcomes,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to auto-dispatch backlog from dashboard: {error}");
|
|
self.set_operator_note(format!("global auto-dispatch failed: {error}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum();
|
|
let total_routed: usize = outcomes
|
|
.iter()
|
|
.map(|outcome| {
|
|
outcome
|
|
.routed
|
|
.iter()
|
|
.filter(|item| manager::assignment_action_routes_work(item.action))
|
|
.count()
|
|
})
|
|
.sum();
|
|
let total_deferred = total_processed.saturating_sub(total_routed);
|
|
let selected_session_id = self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| session.id.clone());
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(selected_session_id.as_deref());
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
|
|
if total_processed == 0 {
|
|
self.set_operator_note("no unread handoff backlog found".to_string());
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"auto-dispatch processed {} handoff(s) across {} lead session(s) ({} routed, {} deferred)",
|
|
total_processed,
|
|
outcomes.len(),
|
|
total_routed,
|
|
total_deferred
|
|
));
|
|
}
|
|
}
|
|
|
|
pub async fn rebalance_all_teams(&mut self) {
|
|
let agent = self.cfg.default_agent.clone();
|
|
let lead_limit = self.sessions.len().max(1);
|
|
|
|
let outcomes = match manager::rebalance_all_teams(
|
|
&self.db,
|
|
&self.cfg,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
lead_limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcomes) => outcomes,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to rebalance teams from dashboard: {error}");
|
|
self.set_operator_note(format!("global rebalance failed: {error}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let total_rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum();
|
|
let selected_session_id = self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| session.id.clone());
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(selected_session_id.as_deref());
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
|
|
if total_rerouted == 0 {
|
|
self.set_operator_note("no delegate backlog needed global rebalancing".to_string());
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"rebalanced {} handoff(s) across {} lead session(s)",
|
|
total_rerouted,
|
|
outcomes.len()
|
|
));
|
|
}
|
|
}
|
|
|
|
pub async fn coordinate_backlog(&mut self) {
|
|
let agent = self.cfg.default_agent.clone();
|
|
let lead_limit = self.sessions.len().max(1);
|
|
|
|
let outcome = match manager::coordinate_backlog(
|
|
&self.db,
|
|
&self.cfg,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
lead_limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(outcomes) => outcomes,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to coordinate backlog from dashboard: {error}");
|
|
self.set_operator_note(format!("global coordinate failed: {error}"));
|
|
return;
|
|
}
|
|
};
|
|
let total_processed: usize = outcome
|
|
.dispatched
|
|
.iter()
|
|
.map(|dispatch| dispatch.routed.len())
|
|
.sum();
|
|
let total_routed: usize = outcome
|
|
.dispatched
|
|
.iter()
|
|
.map(|dispatch| {
|
|
dispatch
|
|
.routed
|
|
.iter()
|
|
.filter(|item| manager::assignment_action_routes_work(item.action))
|
|
.count()
|
|
})
|
|
.sum();
|
|
let total_deferred = total_processed.saturating_sub(total_routed);
|
|
let total_rerouted: usize = outcome
|
|
.rebalanced
|
|
.iter()
|
|
.map(|rebalance| rebalance.rerouted.len())
|
|
.sum();
|
|
|
|
let selected_session_id = self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| session.id.clone());
|
|
|
|
self.refresh();
|
|
self.sync_selection_by_id(selected_session_id.as_deref());
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
|
|
if total_processed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 {
|
|
self.set_operator_note("backlog already clear".to_string());
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"coordinated backlog: processed {} across {} lead(s) ({} routed, {} deferred), rebalanced {} across {} lead(s), remaining {} across {} session(s) [{} absorbable, {} saturated]",
|
|
total_processed,
|
|
outcome.dispatched.len(),
|
|
total_routed,
|
|
total_deferred,
|
|
total_rerouted,
|
|
outcome.rebalanced.len(),
|
|
outcome.remaining_backlog_messages,
|
|
outcome.remaining_backlog_sessions,
|
|
outcome.remaining_absorbable_sessions,
|
|
outcome.remaining_saturated_sessions
|
|
));
|
|
}
|
|
}
|
|
|
|
pub async fn stop_selected(&mut self) {
|
|
let Some(session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
let session_id = session.id.clone();
|
|
if let Err(error) = manager::stop_session(&self.db, &session_id).await {
|
|
tracing::warn!("Failed to stop session {}: {error}", session.id);
|
|
self.set_operator_note(format!(
|
|
"stop failed for {}: {error}",
|
|
format_session_id(&session_id)
|
|
));
|
|
return;
|
|
}
|
|
|
|
self.refresh();
|
|
self.set_operator_note(format!(
|
|
"stopped session {}",
|
|
format_session_id(&session_id)
|
|
));
|
|
}
|
|
|
|
pub async fn resume_selected(&mut self) {
|
|
let Some(session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
let session_id = session.id.clone();
|
|
if let Err(error) = manager::resume_session(&self.db, &self.cfg, &session_id).await {
|
|
tracing::warn!("Failed to resume session {}: {error}", session.id);
|
|
self.set_operator_note(format!(
|
|
"resume failed for {}: {error}",
|
|
format_session_id(&session_id)
|
|
));
|
|
return;
|
|
}
|
|
|
|
self.refresh();
|
|
self.set_operator_note(format!(
|
|
"resumed session {}",
|
|
format_session_id(&session_id)
|
|
));
|
|
}
|
|
|
|
pub async fn cleanup_selected_worktree(&mut self) {
|
|
let Some(session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
if session.worktree.is_none() {
|
|
return;
|
|
}
|
|
|
|
let session_id = session.id.clone();
|
|
if let Err(error) = manager::cleanup_session_worktree(&self.db, &session_id).await {
|
|
tracing::warn!("Failed to cleanup session {} worktree: {error}", session.id);
|
|
self.set_operator_note(format!(
|
|
"cleanup failed for {}: {error}",
|
|
format_session_id(&session_id)
|
|
));
|
|
return;
|
|
}
|
|
|
|
self.refresh();
|
|
self.set_operator_note(format!(
|
|
"cleaned worktree for {}",
|
|
format_session_id(&session_id)
|
|
));
|
|
}
|
|
|
|
pub async fn merge_selected_worktree(&mut self) {
|
|
let Some(session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
if session.worktree.is_none() {
|
|
self.set_operator_note("selected session has no worktree to merge".to_string());
|
|
return;
|
|
}
|
|
|
|
let session_id = session.id.clone();
|
|
let outcome = match manager::merge_session_worktree(&self.db, &session_id, true).await {
|
|
Ok(outcome) => outcome,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to merge session {} worktree: {error}", session.id);
|
|
self.set_operator_note(format!(
|
|
"merge failed for {}: {error}",
|
|
format_session_id(&session_id)
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
|
|
self.refresh();
|
|
self.set_operator_note(format!(
|
|
"merged {} into {} for {}{}",
|
|
outcome.branch,
|
|
outcome.base_branch,
|
|
format_session_id(&session_id),
|
|
if outcome.already_up_to_date {
|
|
" (already up to date)"
|
|
} else {
|
|
""
|
|
}
|
|
));
|
|
}
|
|
|
|
pub async fn merge_ready_worktrees(&mut self) {
|
|
match manager::merge_ready_worktrees(&self.db, true).await {
|
|
Ok(outcome) => {
|
|
self.refresh();
|
|
if outcome.merged.is_empty()
|
|
&& outcome.active_with_worktree_ids.is_empty()
|
|
&& outcome.conflicted_session_ids.is_empty()
|
|
&& outcome.dirty_worktree_ids.is_empty()
|
|
&& outcome.failures.is_empty()
|
|
{
|
|
self.set_operator_note("no ready worktrees to merge".to_string());
|
|
return;
|
|
}
|
|
|
|
let mut parts = vec![format!("merged {} ready worktree(s)", outcome.merged.len())];
|
|
if !outcome.active_with_worktree_ids.is_empty() {
|
|
parts.push(format!(
|
|
"skipped {} active",
|
|
outcome.active_with_worktree_ids.len()
|
|
));
|
|
}
|
|
if !outcome.conflicted_session_ids.is_empty() {
|
|
parts.push(format!(
|
|
"skipped {} conflicted",
|
|
outcome.conflicted_session_ids.len()
|
|
));
|
|
}
|
|
if !outcome.dirty_worktree_ids.is_empty() {
|
|
parts.push(format!(
|
|
"skipped {} dirty",
|
|
outcome.dirty_worktree_ids.len()
|
|
));
|
|
}
|
|
if !outcome.failures.is_empty() {
|
|
parts.push(format!("{} failed", outcome.failures.len()));
|
|
}
|
|
self.set_operator_note(parts.join("; "));
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to merge ready worktrees: {error}");
|
|
self.set_operator_note(format!("merge ready worktrees failed: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn prune_inactive_worktrees(&mut self) {
|
|
match manager::prune_inactive_worktrees(&self.db, &self.cfg).await {
|
|
Ok(outcome) => {
|
|
self.refresh();
|
|
if outcome.cleaned_session_ids.is_empty() && outcome.retained_session_ids.is_empty()
|
|
{
|
|
self.set_operator_note("no inactive worktrees to prune".to_string());
|
|
} else if outcome.cleaned_session_ids.is_empty() {
|
|
self.set_operator_note(format!(
|
|
"deferred {} inactive worktree(s) within retention",
|
|
outcome.retained_session_ids.len()
|
|
));
|
|
} else if outcome.active_with_worktree_ids.is_empty() {
|
|
if outcome.retained_session_ids.is_empty() {
|
|
self.set_operator_note(format!(
|
|
"pruned {} inactive worktree(s)",
|
|
outcome.cleaned_session_ids.len()
|
|
));
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"pruned {} inactive worktree(s); deferred {} within retention",
|
|
outcome.cleaned_session_ids.len(),
|
|
outcome.retained_session_ids.len()
|
|
));
|
|
}
|
|
} else {
|
|
let mut note = format!(
|
|
"pruned {} inactive worktree(s); skipped {} active session(s)",
|
|
outcome.cleaned_session_ids.len(),
|
|
outcome.active_with_worktree_ids.len()
|
|
);
|
|
if !outcome.retained_session_ids.is_empty() {
|
|
note.push_str(&format!(
|
|
"; deferred {} within retention",
|
|
outcome.retained_session_ids.len()
|
|
));
|
|
}
|
|
self.set_operator_note(note);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to prune inactive worktrees: {error}");
|
|
self.set_operator_note(format!("prune inactive worktrees failed: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn delete_selected_session(&mut self) {
|
|
let Some(session) = self.sessions.get(self.selected_session) else {
|
|
return;
|
|
};
|
|
|
|
let session_id = session.id.clone();
|
|
if let Err(error) = manager::delete_session(&self.db, &session_id).await {
|
|
tracing::warn!("Failed to delete session {}: {error}", session.id);
|
|
self.set_operator_note(format!(
|
|
"delete failed for {}: {error}",
|
|
format_session_id(&session_id)
|
|
));
|
|
return;
|
|
}
|
|
|
|
self.refresh();
|
|
self.set_operator_note(format!(
|
|
"deleted session {}",
|
|
format_session_id(&session_id)
|
|
));
|
|
}
|
|
|
|
pub fn refresh(&mut self) {
|
|
self.sync_from_store();
|
|
}
|
|
|
|
pub fn toggle_help(&mut self) {
|
|
self.show_help = !self.show_help;
|
|
}
|
|
|
|
pub fn is_input_mode(&self) -> bool {
|
|
self.spawn_input.is_some() || self.search_input.is_some() || self.commit_input.is_some()
|
|
}
|
|
|
|
pub fn has_active_search(&self) -> bool {
|
|
self.search_query.is_some()
|
|
}
|
|
|
|
pub fn begin_spawn_prompt(&mut self) {
|
|
if self.search_input.is_some() {
|
|
self.set_operator_note(
|
|
"finish output search input before opening spawn prompt".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
self.spawn_input = Some(self.spawn_prompt_seed());
|
|
self.set_operator_note(
|
|
"spawn mode | try: give me 3 agents working on fix flaky tests".to_string(),
|
|
);
|
|
}
|
|
|
|
pub fn toggle_search_scope(&mut self) {
|
|
if self.output_mode == OutputMode::Timeline {
|
|
self.timeline_scope = self.timeline_scope.next();
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
self.set_operator_note(format!(
|
|
"timeline scope set to {}",
|
|
self.timeline_scope.label()
|
|
));
|
|
return;
|
|
}
|
|
|
|
if self.output_mode != OutputMode::SessionOutput {
|
|
self.set_operator_note(
|
|
"scope toggle is only available in session output or timeline view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
self.search_scope = self.search_scope.next();
|
|
self.recompute_search_matches();
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
|
|
if self.search_query.is_some() {
|
|
self.set_operator_note(format!(
|
|
"search scope set to {} | {} match(es)",
|
|
self.search_scope.label(),
|
|
self.search_matches.len()
|
|
));
|
|
} else {
|
|
self.set_operator_note(format!("search scope set to {}", self.search_scope.label()));
|
|
}
|
|
}
|
|
|
|
pub fn toggle_search_agent_filter(&mut self) {
|
|
if self.output_mode != OutputMode::SessionOutput {
|
|
self.set_operator_note(
|
|
"search agent filter is only available in session output view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let Some(selected_agent_type) = self.selected_agent_type().map(str::to_owned) else {
|
|
self.set_operator_note("search agent filter requires a selected session".to_string());
|
|
return;
|
|
};
|
|
|
|
self.search_agent_filter = match self.search_agent_filter {
|
|
SearchAgentFilter::AllAgents => SearchAgentFilter::SelectedAgentType,
|
|
SearchAgentFilter::SelectedAgentType => SearchAgentFilter::AllAgents,
|
|
};
|
|
self.recompute_search_matches();
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
|
|
if self.search_query.is_some() {
|
|
self.set_operator_note(format!(
|
|
"search agent filter set to {} | {} match(es)",
|
|
self.search_agent_filter.label(&selected_agent_type),
|
|
self.search_matches.len()
|
|
));
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"search agent filter set to {}",
|
|
self.search_agent_filter.label(&selected_agent_type)
|
|
));
|
|
}
|
|
}
|
|
|
|
pub fn begin_search(&mut self) {
|
|
if self.spawn_input.is_some() {
|
|
self.set_operator_note("finish spawn prompt before searching output".to_string());
|
|
return;
|
|
}
|
|
|
|
if self.output_mode != OutputMode::SessionOutput {
|
|
self.set_operator_note("search is only available in session output view".to_string());
|
|
return;
|
|
}
|
|
|
|
self.search_input = Some(self.search_query.clone().unwrap_or_default());
|
|
self.set_operator_note("search mode | type a query and press Enter".to_string());
|
|
}
|
|
|
|
pub fn push_input_char(&mut self, ch: char) {
|
|
if let Some(input) = self.spawn_input.as_mut() {
|
|
input.push(ch);
|
|
} else if let Some(input) = self.search_input.as_mut() {
|
|
input.push(ch);
|
|
} else if let Some(input) = self.commit_input.as_mut() {
|
|
input.push(ch);
|
|
}
|
|
}
|
|
|
|
pub fn pop_input_char(&mut self) {
|
|
if let Some(input) = self.spawn_input.as_mut() {
|
|
input.pop();
|
|
} else if let Some(input) = self.search_input.as_mut() {
|
|
input.pop();
|
|
} else if let Some(input) = self.commit_input.as_mut() {
|
|
input.pop();
|
|
}
|
|
}
|
|
|
|
pub fn cancel_input(&mut self) {
|
|
if self.spawn_input.take().is_some() {
|
|
self.set_operator_note("spawn input cancelled".to_string());
|
|
} else if self.search_input.take().is_some() {
|
|
self.set_operator_note("search input cancelled".to_string());
|
|
} else if self.commit_input.take().is_some() {
|
|
self.set_operator_note("commit input cancelled".to_string());
|
|
}
|
|
}
|
|
|
|
pub async fn submit_input(&mut self) {
|
|
if self.spawn_input.is_some() {
|
|
self.submit_spawn_prompt().await;
|
|
} else if self.commit_input.is_some() {
|
|
self.submit_commit_prompt();
|
|
} else {
|
|
self.submit_search();
|
|
}
|
|
}
|
|
|
|
fn submit_commit_prompt(&mut self) {
|
|
let Some(input) = self.commit_input.take() else {
|
|
return;
|
|
};
|
|
|
|
let message = input.trim().to_string();
|
|
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
|
self.set_operator_note("no session selected".to_string());
|
|
return;
|
|
};
|
|
let Some(worktree) = self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.and_then(|session| session.worktree.clone())
|
|
else {
|
|
self.set_operator_note("selected session has no worktree".to_string());
|
|
return;
|
|
};
|
|
|
|
match worktree::commit_staged(&worktree, &message) {
|
|
Ok(hash) => {
|
|
self.refresh_after_git_status_action(None);
|
|
self.set_operator_note(format!(
|
|
"committed {} as {}",
|
|
format_session_id(&session_id),
|
|
hash
|
|
));
|
|
}
|
|
Err(error) => {
|
|
self.commit_input = Some(input);
|
|
self.set_operator_note(format!("commit failed: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn submit_search(&mut self) {
|
|
let Some(input) = self.search_input.take() else {
|
|
return;
|
|
};
|
|
|
|
let query = input.trim().to_string();
|
|
if query.is_empty() {
|
|
self.clear_search();
|
|
return;
|
|
}
|
|
|
|
if let Err(error) = compile_search_regex(&query) {
|
|
self.search_input = Some(query.clone());
|
|
self.set_operator_note(format!("invalid regex /{query}: {error}"));
|
|
return;
|
|
}
|
|
|
|
self.search_query = Some(query.clone());
|
|
self.recompute_search_matches();
|
|
if self.search_matches.is_empty() {
|
|
self.set_operator_note(format!("search /{query} found no matches"));
|
|
} else {
|
|
self.set_operator_note(format!(
|
|
"search /{query} matched {} line(s) across {} session(s) | n/N navigate matches",
|
|
self.search_matches.len(),
|
|
self.search_match_session_count()
|
|
));
|
|
}
|
|
}
|
|
|
|
async fn submit_spawn_prompt(&mut self) {
|
|
let Some(input) = self.spawn_input.take() else {
|
|
return;
|
|
};
|
|
|
|
let plan = match self.build_spawn_plan(&input) {
|
|
Ok(plan) => plan,
|
|
Err(error) => {
|
|
self.spawn_input = Some(input);
|
|
self.set_operator_note(error);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let source_session = self.sessions.get(self.selected_session).cloned();
|
|
let handoff_context = source_session.as_ref().map(|session| {
|
|
format!(
|
|
"Dashboard handoff from {} [{}] | cwd {}{}",
|
|
format_session_id(&session.id),
|
|
session.agent_type,
|
|
session.working_dir.display(),
|
|
session
|
|
.worktree
|
|
.as_ref()
|
|
.map(|worktree| format!(
|
|
" | worktree {} ({})",
|
|
worktree.branch,
|
|
worktree.path.display()
|
|
))
|
|
.unwrap_or_default()
|
|
)
|
|
});
|
|
let source_task = source_session.as_ref().map(|session| session.task.clone());
|
|
let source_session_id = source_session.as_ref().map(|session| session.id.clone());
|
|
let source_grouping = source_session
|
|
.as_ref()
|
|
.map(|session| SessionGrouping {
|
|
project: Some(session.project.clone()),
|
|
task_group: Some(session.task_group.clone()),
|
|
})
|
|
.unwrap_or_default();
|
|
let agent = self.cfg.default_agent.clone();
|
|
let mut created_ids = Vec::new();
|
|
|
|
for task in expand_spawn_tasks(&plan.task, plan.spawn_count) {
|
|
let session_id = match manager::create_session_with_grouping(
|
|
&self.db,
|
|
&self.cfg,
|
|
&task,
|
|
&agent,
|
|
self.cfg.auto_create_worktrees,
|
|
source_grouping.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(session_id) => session_id,
|
|
Err(error) => {
|
|
let preferred_selection =
|
|
post_spawn_selection_id(source_session_id.as_deref(), &created_ids);
|
|
self.refresh_after_spawn(preferred_selection.as_deref());
|
|
let mut summary = if created_ids.is_empty() {
|
|
format!("spawn failed: {error}")
|
|
} else {
|
|
format!(
|
|
"spawn partially completed: {} of {} queued before failure: {error}",
|
|
created_ids.len(),
|
|
plan.spawn_count
|
|
)
|
|
};
|
|
if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len())
|
|
{
|
|
summary.push_str(" | ");
|
|
summary.push_str(&layout_note);
|
|
}
|
|
self.set_operator_note(summary);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let (Some(source_id), Some(task), Some(context)) = (
|
|
source_session_id.as_ref(),
|
|
source_task.as_ref(),
|
|
handoff_context.as_ref(),
|
|
) {
|
|
if let Err(error) = comms::send(
|
|
&self.db,
|
|
source_id,
|
|
&session_id,
|
|
&comms::MessageType::TaskHandoff {
|
|
task: task.clone(),
|
|
context: context.clone(),
|
|
},
|
|
) {
|
|
tracing::warn!(
|
|
"Failed to send handoff from session {} to {}: {error}",
|
|
source_id,
|
|
session_id
|
|
);
|
|
}
|
|
}
|
|
|
|
created_ids.push(session_id);
|
|
}
|
|
|
|
let preferred_selection =
|
|
post_spawn_selection_id(source_session_id.as_deref(), &created_ids);
|
|
self.refresh_after_spawn(preferred_selection.as_deref());
|
|
let queued_count = created_ids
|
|
.iter()
|
|
.filter(|session_id| {
|
|
self.db
|
|
.pending_worktree_queue_contains(session_id)
|
|
.unwrap_or(false)
|
|
})
|
|
.count();
|
|
let mut note = build_spawn_note(&plan, created_ids.len(), queued_count);
|
|
if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) {
|
|
note.push_str(" | ");
|
|
note.push_str(&layout_note);
|
|
}
|
|
self.set_operator_note(note);
|
|
}
|
|
|
|
pub fn clear_search(&mut self) {
|
|
let had_query = self.search_query.take().is_some();
|
|
let had_input = self.search_input.take().is_some();
|
|
self.search_matches.clear();
|
|
self.selected_search_match = 0;
|
|
if had_query || had_input {
|
|
self.set_operator_note("cleared output search".to_string());
|
|
}
|
|
}
|
|
|
|
pub fn next_search_match(&mut self) {
|
|
if self.search_matches.is_empty() {
|
|
self.set_operator_note("no output search matches to navigate".to_string());
|
|
return;
|
|
}
|
|
|
|
self.selected_search_match = (self.selected_search_match + 1) % self.search_matches.len();
|
|
self.focus_selected_search_match();
|
|
self.set_operator_note(self.search_navigation_note());
|
|
}
|
|
|
|
pub fn prev_search_match(&mut self) {
|
|
if self.search_matches.is_empty() {
|
|
self.set_operator_note("no output search matches to navigate".to_string());
|
|
return;
|
|
}
|
|
|
|
self.selected_search_match = if self.selected_search_match == 0 {
|
|
self.search_matches.len() - 1
|
|
} else {
|
|
self.selected_search_match - 1
|
|
};
|
|
self.focus_selected_search_match();
|
|
self.set_operator_note(self.search_navigation_note());
|
|
}
|
|
|
|
pub fn toggle_output_filter(&mut self) {
|
|
if self.output_mode != OutputMode::SessionOutput {
|
|
self.set_operator_note(
|
|
"output filters are only available in session output view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
self.output_filter = self.output_filter.next();
|
|
self.recompute_search_matches();
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
self.set_operator_note(format!(
|
|
"output filter set to {}",
|
|
self.output_filter.label()
|
|
));
|
|
}
|
|
|
|
pub fn cycle_output_time_filter(&mut self) {
|
|
if !matches!(
|
|
self.output_mode,
|
|
OutputMode::SessionOutput | OutputMode::Timeline
|
|
) {
|
|
self.set_operator_note(
|
|
"time filters are only available in session output or timeline view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
self.output_time_filter = self.output_time_filter.next();
|
|
if self.output_mode == OutputMode::SessionOutput {
|
|
self.recompute_search_matches();
|
|
}
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
let note_prefix = if self.output_mode == OutputMode::Timeline {
|
|
"timeline range"
|
|
} else {
|
|
"output time filter"
|
|
};
|
|
self.set_operator_note(format!(
|
|
"{note_prefix} set to {}",
|
|
self.output_time_filter.label()
|
|
));
|
|
}
|
|
|
|
pub fn cycle_timeline_event_filter(&mut self) {
|
|
if self.output_mode != OutputMode::Timeline {
|
|
self.set_operator_note(
|
|
"timeline event filters are only available in timeline view".to_string(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
self.timeline_event_filter = self.timeline_event_filter.next();
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
self.set_operator_note(format!(
|
|
"timeline filter set to {}",
|
|
self.timeline_event_filter.label()
|
|
));
|
|
}
|
|
|
|
pub fn toggle_auto_dispatch_policy(&mut self) {
|
|
self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs;
|
|
match self.cfg.save() {
|
|
Ok(()) => {
|
|
let state = if self.cfg.auto_dispatch_unread_handoffs {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
};
|
|
self.set_operator_note(format!(
|
|
"daemon auto-dispatch {state} | saved to {}",
|
|
crate::config::Config::config_path().display()
|
|
));
|
|
}
|
|
Err(error) => {
|
|
self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs;
|
|
self.set_operator_note(format!("failed to persist auto-dispatch policy: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_auto_merge_policy(&mut self) {
|
|
self.cfg.auto_merge_ready_worktrees = !self.cfg.auto_merge_ready_worktrees;
|
|
match self.cfg.save() {
|
|
Ok(()) => {
|
|
let state = if self.cfg.auto_merge_ready_worktrees {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
};
|
|
self.set_operator_note(format!(
|
|
"daemon auto-merge {state} | saved to {}",
|
|
crate::config::Config::config_path().display()
|
|
));
|
|
}
|
|
Err(error) => {
|
|
self.cfg.auto_merge_ready_worktrees = !self.cfg.auto_merge_ready_worktrees;
|
|
self.set_operator_note(format!("failed to persist auto-merge policy: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toggle_auto_worktree_policy(&mut self) {
|
|
self.cfg.auto_create_worktrees = !self.cfg.auto_create_worktrees;
|
|
match self.cfg.save() {
|
|
Ok(()) => {
|
|
let state = if self.cfg.auto_create_worktrees {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
};
|
|
self.set_operator_note(format!(
|
|
"default worktree creation {state} | saved to {}",
|
|
crate::config::Config::config_path().display()
|
|
));
|
|
}
|
|
Err(error) => {
|
|
self.cfg.auto_create_worktrees = !self.cfg.auto_create_worktrees;
|
|
self.set_operator_note(format!(
|
|
"failed to persist worktree creation policy: {error}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn adjust_auto_dispatch_limit(&mut self, delta: isize) {
|
|
let next =
|
|
(self.cfg.auto_dispatch_limit_per_session as isize + delta).clamp(1, 50) as usize;
|
|
if next == self.cfg.auto_dispatch_limit_per_session {
|
|
self.set_operator_note(format!(
|
|
"auto-dispatch limit unchanged at {} handoff(s) per lead",
|
|
self.cfg.auto_dispatch_limit_per_session
|
|
));
|
|
return;
|
|
}
|
|
|
|
let previous = self.cfg.auto_dispatch_limit_per_session;
|
|
self.cfg.auto_dispatch_limit_per_session = next;
|
|
match self.cfg.save() {
|
|
Ok(()) => self.set_operator_note(format!(
|
|
"auto-dispatch limit set to {} handoff(s) per lead | saved to {}",
|
|
self.cfg.auto_dispatch_limit_per_session,
|
|
crate::config::Config::config_path().display()
|
|
)),
|
|
Err(error) => {
|
|
self.cfg.auto_dispatch_limit_per_session = previous;
|
|
self.set_operator_note(format!("failed to persist auto-dispatch limit: {error}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn tick(&mut self) {
|
|
loop {
|
|
match self.output_rx.try_recv() {
|
|
Ok(_event) => {}
|
|
Err(broadcast::error::TryRecvError::Empty) => break,
|
|
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
|
|
Err(broadcast::error::TryRecvError::Closed) => break,
|
|
}
|
|
}
|
|
|
|
if let Err(error) = manager::activate_pending_worktree_sessions(&self.db, &self.cfg).await {
|
|
tracing::warn!("Failed to activate queued worktree sessions: {error}");
|
|
}
|
|
|
|
self.sync_from_store();
|
|
}
|
|
|
|
fn sync_runtime_metrics(
|
|
&mut self,
|
|
) -> (
|
|
Option<manager::HeartbeatEnforcementOutcome>,
|
|
Option<manager::BudgetEnforcementOutcome>,
|
|
) {
|
|
if let Err(error) = self.db.refresh_session_durations() {
|
|
tracing::warn!("Failed to refresh session durations: {error}");
|
|
}
|
|
|
|
let metrics_path = self.cfg.cost_metrics_path();
|
|
let signature = metrics_file_signature(&metrics_path);
|
|
if signature != self.last_cost_metrics_signature {
|
|
self.last_cost_metrics_signature = signature;
|
|
if signature.is_some() {
|
|
if let Err(error) = self.db.sync_cost_tracker_metrics(&metrics_path) {
|
|
tracing::warn!("Failed to sync cost tracker metrics: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
let activity_path = self.cfg.tool_activity_metrics_path();
|
|
let activity_signature = metrics_file_signature(&activity_path);
|
|
if activity_signature != self.last_tool_activity_signature {
|
|
self.last_tool_activity_signature = activity_signature;
|
|
if activity_signature.is_some() {
|
|
if let Err(error) = self.db.sync_tool_activity_metrics(&activity_path) {
|
|
tracing::warn!("Failed to sync tool activity metrics: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
let heartbeat_enforcement = match manager::enforce_session_heartbeats(&self.db, &self.cfg) {
|
|
Ok(outcome) => Some(outcome),
|
|
Err(error) => {
|
|
tracing::warn!("Failed to enforce session heartbeats: {error}");
|
|
None
|
|
}
|
|
};
|
|
|
|
let budget_enforcement = match manager::enforce_budget_hard_limits(&self.db, &self.cfg) {
|
|
Ok(outcome) => Some(outcome),
|
|
Err(error) => {
|
|
tracing::warn!("Failed to enforce budget hard limits: {error}");
|
|
None
|
|
}
|
|
};
|
|
|
|
(heartbeat_enforcement, budget_enforcement)
|
|
}
|
|
|
|
fn sync_from_store(&mut self) {
|
|
let (heartbeat_enforcement, budget_enforcement) = self.sync_runtime_metrics();
|
|
let selected_id = self.selected_session_id().map(ToOwned::to_owned);
|
|
self.sessions = match self.db.list_sessions() {
|
|
Ok(mut sessions) => {
|
|
sort_sessions_for_display(&mut sessions);
|
|
sessions
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh sessions: {error}");
|
|
Vec::new()
|
|
}
|
|
};
|
|
self.unread_message_counts = match self.db.unread_message_counts() {
|
|
Ok(counts) => counts,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh unread message counts: {error}");
|
|
HashMap::new()
|
|
}
|
|
};
|
|
self.sync_handoff_backlog_counts();
|
|
self.sync_worktree_health_by_session();
|
|
self.sync_global_handoff_backlog();
|
|
self.sync_daemon_activity();
|
|
self.sync_output_cache();
|
|
self.sync_selection_by_id(selected_id.as_deref());
|
|
self.ensure_selected_pane_visible();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_git_status();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
self.sync_budget_alerts();
|
|
|
|
if let Some(outcome) =
|
|
budget_enforcement.filter(|outcome| !outcome.paused_sessions.is_empty())
|
|
{
|
|
self.set_operator_note(budget_auto_pause_note(&outcome));
|
|
}
|
|
if let Some(outcome) = heartbeat_enforcement.filter(|outcome| {
|
|
!outcome.stale_sessions.is_empty() || !outcome.auto_terminated_sessions.is_empty()
|
|
}) {
|
|
self.set_operator_note(heartbeat_enforcement_note(&outcome));
|
|
}
|
|
}
|
|
|
|
fn sync_budget_alerts(&mut self) {
|
|
let aggregate = self.aggregate_usage();
|
|
let thresholds = self.cfg.effective_budget_alert_thresholds();
|
|
let current_state = aggregate.overall_state;
|
|
if current_state == self.last_budget_alert_state {
|
|
return;
|
|
}
|
|
|
|
let previous_state = self.last_budget_alert_state;
|
|
self.last_budget_alert_state = current_state;
|
|
|
|
if current_state <= previous_state {
|
|
return;
|
|
}
|
|
|
|
let Some(summary_suffix) = current_state.summary_suffix(thresholds) else {
|
|
return;
|
|
};
|
|
|
|
let token_budget = if self.cfg.token_budget > 0 {
|
|
format!(
|
|
"{} / {}",
|
|
format_token_count(aggregate.total_tokens),
|
|
format_token_count(self.cfg.token_budget)
|
|
)
|
|
} else {
|
|
format!("{} / no budget", format_token_count(aggregate.total_tokens))
|
|
};
|
|
let cost_budget = if self.cfg.cost_budget_usd > 0.0 {
|
|
format!(
|
|
"{} / {}",
|
|
format_currency(aggregate.total_cost_usd),
|
|
format_currency(self.cfg.cost_budget_usd)
|
|
)
|
|
} else {
|
|
format!("{} / no budget", format_currency(aggregate.total_cost_usd))
|
|
};
|
|
|
|
self.set_operator_note(format!(
|
|
"{summary_suffix} | tokens {token_budget} | cost {cost_budget}"
|
|
));
|
|
}
|
|
|
|
fn sync_selection(&mut self) {
|
|
if self.sessions.is_empty() {
|
|
self.selected_session = 0;
|
|
self.session_table_state.select(None);
|
|
} else {
|
|
self.selected_session = self.selected_session.min(self.sessions.len() - 1);
|
|
self.session_table_state.select(Some(self.selected_session));
|
|
}
|
|
}
|
|
|
|
fn sync_selection_by_id(&mut self, selected_id: Option<&str>) {
|
|
if let Some(selected_id) = selected_id {
|
|
if let Some(index) = self
|
|
.sessions
|
|
.iter()
|
|
.position(|session| session.id == selected_id)
|
|
{
|
|
self.selected_session = index;
|
|
}
|
|
}
|
|
self.sync_selection();
|
|
}
|
|
|
|
fn sync_output_cache(&mut self) {
|
|
let active_session_ids: HashSet<_> = self
|
|
.sessions
|
|
.iter()
|
|
.map(|session| session.id.as_str())
|
|
.collect();
|
|
self.session_output_cache
|
|
.retain(|session_id, _| active_session_ids.contains(session_id.as_str()));
|
|
|
|
for session in &self.sessions {
|
|
match self.db.get_output_lines(&session.id, OUTPUT_BUFFER_LIMIT) {
|
|
Ok(lines) => {
|
|
self.output_store.replace_lines(&session.id, lines.clone());
|
|
self.session_output_cache.insert(session.id.clone(), lines);
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to load session output for {}: {error}", session.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure_selected_pane_visible(&mut self) {
|
|
if !self.visible_panes().contains(&self.selected_pane) {
|
|
self.selected_pane = Pane::Sessions;
|
|
}
|
|
}
|
|
|
|
fn focus_pane(&mut self, pane: Pane) {
|
|
self.selected_pane = pane;
|
|
self.ensure_selected_pane_visible();
|
|
self.set_operator_note(format!("focused {} pane", pane.title().to_lowercase()));
|
|
}
|
|
|
|
fn move_pane_focus(&mut self, direction: PaneDirection) {
|
|
let visible_panes = self.visible_panes();
|
|
if visible_panes.len() <= 1 {
|
|
return;
|
|
}
|
|
|
|
let pane_areas = self.pane_areas(Rect::new(0, 0, 100, 40));
|
|
let Some(current_rect) = pane_rect(&pane_areas, self.selected_pane) else {
|
|
return;
|
|
};
|
|
let current_center = pane_center(current_rect);
|
|
|
|
let candidate = visible_panes
|
|
.into_iter()
|
|
.filter(|pane| *pane != self.selected_pane)
|
|
.filter_map(|pane| {
|
|
let rect = pane_rect(&pane_areas, pane)?;
|
|
let center = pane_center(rect);
|
|
let dx = center.0 - current_center.0;
|
|
let dy = center.1 - current_center.1;
|
|
|
|
let (primary, secondary) = match direction {
|
|
PaneDirection::Left if dx < 0 => ((-dx) as u16, dy.unsigned_abs()),
|
|
PaneDirection::Right if dx > 0 => (dx as u16, dy.unsigned_abs()),
|
|
PaneDirection::Up if dy < 0 => ((-dy) as u16, dx.unsigned_abs()),
|
|
PaneDirection::Down if dy > 0 => (dy as u16, dx.unsigned_abs()),
|
|
_ => return None,
|
|
};
|
|
|
|
Some((pane, primary, secondary))
|
|
})
|
|
.min_by_key(|(pane, primary, secondary)| (*primary, *secondary, pane.sort_key()));
|
|
|
|
if let Some((pane, _, _)) = candidate {
|
|
self.focus_pane(pane);
|
|
}
|
|
}
|
|
|
|
fn pane_focus_shortcuts_label(&self) -> String {
|
|
self.cfg.pane_navigation.focus_shortcuts_label()
|
|
}
|
|
|
|
fn pane_move_shortcuts_label(&self) -> String {
|
|
self.cfg.pane_navigation.movement_shortcuts_label()
|
|
}
|
|
|
|
fn sync_global_handoff_backlog(&mut self) {
|
|
let limit = self.sessions.len().max(1);
|
|
match self.db.unread_task_handoff_targets(limit) {
|
|
Ok(targets) => {
|
|
self.global_handoff_backlog_leads = targets.len();
|
|
self.global_handoff_backlog_messages =
|
|
targets.iter().map(|(_, unread_count)| *unread_count).sum();
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh global handoff backlog: {error}");
|
|
self.global_handoff_backlog_leads = 0;
|
|
self.global_handoff_backlog_messages = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sync_approval_queue(&mut self) {
|
|
self.approval_queue_counts = match self.db.unread_approval_counts() {
|
|
Ok(counts) => counts,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh approval queue counts: {error}");
|
|
HashMap::new()
|
|
}
|
|
};
|
|
self.approval_queue_preview = match self.db.unread_approval_queue(3) {
|
|
Ok(messages) => messages,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh approval queue preview: {error}");
|
|
Vec::new()
|
|
}
|
|
};
|
|
}
|
|
|
|
fn sync_handoff_backlog_counts(&mut self) {
|
|
let limit = self.sessions.len().max(1);
|
|
self.handoff_backlog_counts.clear();
|
|
match self.db.unread_task_handoff_targets(limit) {
|
|
Ok(targets) => {
|
|
self.handoff_backlog_counts.extend(targets);
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh handoff backlog counts: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sync_worktree_health_by_session(&mut self) {
|
|
self.worktree_health_by_session.clear();
|
|
for session in &self.sessions {
|
|
let Some(worktree) = session.worktree.as_ref() else {
|
|
continue;
|
|
};
|
|
|
|
match worktree::health(worktree) {
|
|
Ok(health) => {
|
|
self.worktree_health_by_session
|
|
.insert(session.id.clone(), health);
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to refresh worktree health for {}: {error}",
|
|
session.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sync_daemon_activity(&mut self) {
|
|
self.daemon_activity = match self.db.daemon_activity() {
|
|
Ok(activity) => activity,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to refresh daemon activity: {error}");
|
|
DaemonActivity::default()
|
|
}
|
|
};
|
|
}
|
|
|
|
fn sync_selected_output(&mut self) {
|
|
if self.selected_session_id().is_none() {
|
|
self.output_scroll_offset = 0;
|
|
self.output_follow = true;
|
|
self.search_matches.clear();
|
|
self.selected_search_match = 0;
|
|
return;
|
|
}
|
|
|
|
self.recompute_search_matches();
|
|
}
|
|
|
|
fn sync_selected_diff(&mut self) {
|
|
let session = self.sessions.get(self.selected_session);
|
|
let worktree = session.and_then(|session| session.worktree.as_ref());
|
|
|
|
self.selected_diff_summary =
|
|
worktree.and_then(|worktree| worktree::diff_summary(worktree).ok().flatten());
|
|
self.selected_diff_preview = worktree
|
|
.and_then(|worktree| worktree::diff_file_preview(worktree, MAX_DIFF_PREVIEW_LINES).ok())
|
|
.unwrap_or_default();
|
|
self.selected_diff_patch = worktree.and_then(|worktree| {
|
|
worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES)
|
|
.ok()
|
|
.flatten()
|
|
});
|
|
self.selected_diff_hunk_offsets_unified = self
|
|
.selected_diff_patch
|
|
.as_deref()
|
|
.map(build_unified_diff_hunk_offsets)
|
|
.unwrap_or_default();
|
|
self.selected_diff_hunk_offsets_split = self
|
|
.selected_diff_patch
|
|
.as_deref()
|
|
.map(|patch| build_worktree_diff_columns(patch, self.theme_palette()).hunk_offsets)
|
|
.unwrap_or_default();
|
|
if self.selected_diff_hunk >= self.current_diff_hunk_offsets().len() {
|
|
self.selected_diff_hunk = 0;
|
|
}
|
|
self.selected_merge_readiness =
|
|
worktree.and_then(|worktree| worktree::merge_readiness(worktree).ok());
|
|
self.selected_conflict_protocol = session
|
|
.zip(worktree)
|
|
.zip(self.selected_merge_readiness.as_ref())
|
|
.and_then(|((session, worktree), merge_readiness)| {
|
|
build_conflict_protocol(&session.id, worktree, merge_readiness)
|
|
});
|
|
if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
}
|
|
if self.output_mode == OutputMode::ConflictProtocol
|
|
&& self.selected_conflict_protocol.is_none()
|
|
{
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
}
|
|
self.sync_selected_git_status();
|
|
}
|
|
|
|
fn sync_selected_git_status(&mut self) {
|
|
let session = self.sessions.get(self.selected_session);
|
|
let worktree = session.and_then(|session| session.worktree.as_ref());
|
|
self.selected_git_status_entries = worktree
|
|
.and_then(|worktree| worktree::git_status_entries(worktree).ok())
|
|
.unwrap_or_default();
|
|
if self.selected_git_status >= self.selected_git_status_entries.len() {
|
|
self.selected_git_status = self
|
|
.selected_git_status_entries
|
|
.len()
|
|
.saturating_sub(1);
|
|
}
|
|
if self.output_mode == OutputMode::GitStatus && worktree.is_none() {
|
|
self.output_mode = OutputMode::SessionOutput;
|
|
}
|
|
}
|
|
|
|
fn selected_git_status_context(
|
|
&self,
|
|
) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> {
|
|
let session = self.sessions.get(self.selected_session)?;
|
|
let worktree = session.worktree.clone()?;
|
|
let entry = self
|
|
.selected_git_status_entries
|
|
.get(self.selected_git_status)
|
|
.cloned()?;
|
|
Some((entry, worktree))
|
|
}
|
|
|
|
fn refresh_after_git_status_action(&mut self, preferred_path: Option<&str>) {
|
|
self.refresh();
|
|
self.output_mode = OutputMode::GitStatus;
|
|
self.selected_pane = Pane::Output;
|
|
self.output_follow = false;
|
|
if let Some(path) = preferred_path {
|
|
if let Some(index) = self
|
|
.selected_git_status_entries
|
|
.iter()
|
|
.position(|entry| entry.path == path)
|
|
{
|
|
self.selected_git_status = index;
|
|
}
|
|
}
|
|
self.sync_output_scroll(self.last_output_height.max(1));
|
|
}
|
|
|
|
fn current_diff_hunk_offsets(&self) -> &[usize] {
|
|
match self.diff_view_mode {
|
|
DiffViewMode::Split => &self.selected_diff_hunk_offsets_split,
|
|
DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified,
|
|
}
|
|
}
|
|
|
|
fn current_diff_hunk_offset(&self) -> usize {
|
|
self.current_diff_hunk_offsets()
|
|
.get(self.selected_diff_hunk)
|
|
.copied()
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn diff_hunk_title_suffix(&self) -> String {
|
|
let total = self.current_diff_hunk_offsets().len();
|
|
if total == 0 {
|
|
String::new()
|
|
} else {
|
|
format!(" {}/{}", self.selected_diff_hunk + 1, total)
|
|
}
|
|
}
|
|
|
|
fn sync_selected_messages(&mut self) {
|
|
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
|
self.selected_messages.clear();
|
|
self.sync_approval_queue();
|
|
return;
|
|
};
|
|
|
|
let unread_count = self
|
|
.unread_message_counts
|
|
.get(&session_id)
|
|
.copied()
|
|
.unwrap_or(0);
|
|
if unread_count > 0 {
|
|
match self.db.mark_messages_read(&session_id) {
|
|
Ok(_) => {
|
|
self.unread_message_counts.insert(session_id.clone(), 0);
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to mark session {} messages as read: {error}",
|
|
session_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.selected_messages = match self.db.list_messages_for_session(&session_id, 5) {
|
|
Ok(messages) => messages,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to load session messages: {error}");
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
self.sync_approval_queue();
|
|
}
|
|
|
|
fn sync_selected_lineage(&mut self) {
|
|
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
|
self.selected_parent_session = None;
|
|
self.selected_child_sessions.clear();
|
|
self.focused_delegate_session_id = None;
|
|
self.selected_team_summary = None;
|
|
self.selected_route_preview = None;
|
|
return;
|
|
};
|
|
|
|
self.selected_parent_session = match self.db.latest_task_handoff_source(&session_id) {
|
|
Ok(parent) => parent,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to load session parent linkage: {error}");
|
|
None
|
|
}
|
|
};
|
|
|
|
self.selected_child_sessions = match self.db.delegated_children(&session_id, 50) {
|
|
Ok(children) => {
|
|
let mut delegated = Vec::new();
|
|
let mut team = TeamSummary::default();
|
|
let mut route_candidates = Vec::new();
|
|
|
|
for child_id in children {
|
|
match self.db.get_session(&child_id) {
|
|
Ok(Some(session)) => {
|
|
team.total += 1;
|
|
let approval_backlog = self
|
|
.approval_queue_counts
|
|
.get(&child_id)
|
|
.copied()
|
|
.unwrap_or(0);
|
|
let handoff_backlog = match self.db.unread_task_handoff_count(&child_id)
|
|
{
|
|
Ok(count) => count,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to load delegated child handoff backlog {}: {error}",
|
|
child_id
|
|
);
|
|
0
|
|
}
|
|
};
|
|
let state = session.state.clone();
|
|
match state {
|
|
SessionState::Idle => team.idle += 1,
|
|
SessionState::Running => team.running += 1,
|
|
SessionState::Pending => team.pending += 1,
|
|
SessionState::Failed => team.failed += 1,
|
|
SessionState::Stopped => team.stopped += 1,
|
|
SessionState::Stale => team.stale += 1,
|
|
SessionState::Completed => {}
|
|
}
|
|
|
|
route_candidates.push(DelegatedChildSummary {
|
|
worktree_health: self
|
|
.worktree_health_by_session
|
|
.get(&child_id)
|
|
.copied(),
|
|
approval_backlog,
|
|
handoff_backlog,
|
|
state: state.clone(),
|
|
session_id: child_id.clone(),
|
|
tokens_used: session.metrics.tokens_used,
|
|
files_changed: session.metrics.files_changed,
|
|
duration_secs: session.metrics.duration_secs,
|
|
task_preview: truncate_for_dashboard(&session.task, 40),
|
|
branch: session
|
|
.worktree
|
|
.as_ref()
|
|
.map(|worktree| worktree.branch.clone()),
|
|
last_output_preview: self
|
|
.db
|
|
.get_output_lines(&child_id, 1)
|
|
.ok()
|
|
.and_then(|lines| lines.last().cloned())
|
|
.map(|line| truncate_for_dashboard(&line.text, 48)),
|
|
});
|
|
delegated.push(DelegatedChildSummary {
|
|
worktree_health: self
|
|
.worktree_health_by_session
|
|
.get(&session.id)
|
|
.copied(),
|
|
approval_backlog,
|
|
handoff_backlog,
|
|
state,
|
|
session_id: child_id,
|
|
tokens_used: session.metrics.tokens_used,
|
|
files_changed: session.metrics.files_changed,
|
|
duration_secs: session.metrics.duration_secs,
|
|
task_preview: truncate_for_dashboard(&session.task, 40),
|
|
branch: session
|
|
.worktree
|
|
.as_ref()
|
|
.map(|worktree| worktree.branch.clone()),
|
|
last_output_preview: self
|
|
.db
|
|
.get_output_lines(&session.id, 1)
|
|
.ok()
|
|
.and_then(|lines| lines.last().cloned())
|
|
.map(|line| truncate_for_dashboard(&line.text, 48)),
|
|
});
|
|
}
|
|
Ok(None) => {}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"Failed to load delegated child session {}: {error}",
|
|
child_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.selected_team_summary = if team.total > 0 { Some(team) } else { None };
|
|
self.selected_route_preview =
|
|
self.build_route_preview(team.total, &route_candidates);
|
|
delegated.sort_by_key(|delegate| {
|
|
(
|
|
delegate_attention_priority(delegate),
|
|
std::cmp::Reverse(delegate.approval_backlog),
|
|
std::cmp::Reverse(delegate.handoff_backlog),
|
|
delegate.session_id.clone(),
|
|
)
|
|
});
|
|
delegated
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("Failed to load delegated child sessions: {error}");
|
|
self.selected_team_summary = None;
|
|
self.selected_route_preview = None;
|
|
Vec::new()
|
|
}
|
|
};
|
|
self.sync_focused_delegate_selection();
|
|
}
|
|
|
|
fn build_route_preview(
|
|
&self,
|
|
delegate_count: usize,
|
|
delegates: &[DelegatedChildSummary],
|
|
) -> Option<String> {
|
|
if let Some(idle_clear) = delegates
|
|
.iter()
|
|
.filter(|delegate| {
|
|
delegate.state == SessionState::Idle && delegate.handoff_backlog == 0
|
|
})
|
|
.min_by_key(|delegate| delegate.session_id.as_str())
|
|
{
|
|
return Some(format!(
|
|
"reuse idle {}",
|
|
format_session_id(&idle_clear.session_id)
|
|
));
|
|
}
|
|
|
|
if delegate_count < self.cfg.max_parallel_sessions {
|
|
return Some("spawn new delegate".to_string());
|
|
}
|
|
|
|
if let Some(idle_backed_up) = delegates
|
|
.iter()
|
|
.filter(|delegate| delegate.state == SessionState::Idle)
|
|
.min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str()))
|
|
{
|
|
return Some(format!(
|
|
"reuse idle {} with backlog {}",
|
|
format_session_id(&idle_backed_up.session_id),
|
|
idle_backed_up.handoff_backlog
|
|
));
|
|
}
|
|
|
|
if let Some(active_delegate) = delegates
|
|
.iter()
|
|
.filter(|delegate| {
|
|
matches!(
|
|
delegate.state,
|
|
SessionState::Running | SessionState::Pending
|
|
)
|
|
})
|
|
.min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str()))
|
|
{
|
|
return Some(format!(
|
|
"reuse active {} with backlog {}",
|
|
format_session_id(&active_delegate.session_id),
|
|
active_delegate.handoff_backlog
|
|
));
|
|
}
|
|
|
|
if delegate_count == 0 {
|
|
Some("spawn new delegate".to_string())
|
|
} else {
|
|
Some("spawn fallback delegate".to_string())
|
|
}
|
|
}
|
|
|
|
fn selected_session_id(&self) -> Option<&str> {
|
|
self.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| session.id.as_str())
|
|
}
|
|
|
|
fn selected_output_lines(&self) -> &[OutputLine] {
|
|
self.selected_session_id()
|
|
.and_then(|session_id| self.session_output_cache.get(session_id))
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
fn selected_agent_type(&self) -> Option<&str> {
|
|
self.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| session.agent_type.as_str())
|
|
}
|
|
|
|
fn search_agent_filter_label(&self) -> String {
|
|
self.search_agent_filter
|
|
.label(self.selected_agent_type().unwrap_or("selected agent"))
|
|
.to_string()
|
|
}
|
|
|
|
fn search_agent_title_suffix(&self) -> String {
|
|
match self.selected_agent_type() {
|
|
Some(agent_type) => self
|
|
.search_agent_filter
|
|
.title_suffix(agent_type)
|
|
.to_string(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
fn visible_output_lines_for_session(&self, session_id: &str) -> Vec<&OutputLine> {
|
|
self.session_output_cache
|
|
.get(session_id)
|
|
.map(|lines| {
|
|
lines
|
|
.iter()
|
|
.filter(|line| {
|
|
self.output_filter.matches(line) && self.output_time_filter.matches(line)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn visible_output_lines(&self) -> Vec<&OutputLine> {
|
|
self.selected_session_id()
|
|
.map(|session_id| self.visible_output_lines_for_session(session_id))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn visible_git_status_lines(&self) -> Vec<Line<'static>> {
|
|
self.selected_git_status_entries
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, entry)| {
|
|
let marker = if index == self.selected_git_status { ">>" } else { "-" };
|
|
let mut flags = Vec::new();
|
|
if entry.conflicted {
|
|
flags.push("conflict");
|
|
}
|
|
if entry.staged {
|
|
flags.push("staged");
|
|
}
|
|
if entry.unstaged {
|
|
flags.push("unstaged");
|
|
}
|
|
if entry.untracked {
|
|
flags.push("untracked");
|
|
}
|
|
let flag_text = if flags.is_empty() {
|
|
"clean".to_string()
|
|
} else {
|
|
flags.join(",")
|
|
};
|
|
Line::from(format!(
|
|
"{} [{}{}] [{}] {}",
|
|
marker,
|
|
entry.index_status,
|
|
entry.worktree_status,
|
|
flag_text,
|
|
entry.display_path
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn visible_timeline_lines(&self) -> Vec<Line<'static>> {
|
|
let show_session_label = self.timeline_scope == SearchScope::AllSessions;
|
|
self.timeline_events()
|
|
.into_iter()
|
|
.filter(|event| self.timeline_event_filter.matches(event.event_type))
|
|
.filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at))
|
|
.flat_map(|event| {
|
|
let prefix = if show_session_label {
|
|
format!("{} ", format_session_id(&event.session_id))
|
|
} else {
|
|
String::new()
|
|
};
|
|
let mut lines = vec![Line::from(format!(
|
|
"[{}] {}{:<11} {}",
|
|
event.occurred_at.format("%H:%M:%S"),
|
|
prefix,
|
|
event.event_type.label(),
|
|
event.summary
|
|
))];
|
|
lines.extend(
|
|
event
|
|
.detail_lines
|
|
.into_iter()
|
|
.map(|line| Line::from(format!(" {}", line))),
|
|
);
|
|
lines
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn timeline_events(&self) -> Vec<TimelineEvent> {
|
|
let mut events = match self.timeline_scope {
|
|
SearchScope::SelectedSession => self
|
|
.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| self.session_timeline_events(session))
|
|
.unwrap_or_default(),
|
|
SearchScope::AllSessions => self
|
|
.sessions
|
|
.iter()
|
|
.flat_map(|session| self.session_timeline_events(session))
|
|
.collect(),
|
|
};
|
|
events.sort_by(|left, right| {
|
|
left.occurred_at
|
|
.cmp(&right.occurred_at)
|
|
.then_with(|| left.session_id.cmp(&right.session_id))
|
|
.then_with(|| left.summary.cmp(&right.summary))
|
|
});
|
|
events
|
|
}
|
|
|
|
fn session_timeline_events(&self, session: &Session) -> Vec<TimelineEvent> {
|
|
let mut events = vec![TimelineEvent {
|
|
occurred_at: session.created_at,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::Lifecycle,
|
|
summary: format!(
|
|
"created session as {} for {}",
|
|
session.agent_type,
|
|
truncate_for_dashboard(&session.task, 64)
|
|
),
|
|
detail_lines: Vec::new(),
|
|
}];
|
|
|
|
if session.updated_at > session.created_at {
|
|
events.push(TimelineEvent {
|
|
occurred_at: session.updated_at,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::Lifecycle,
|
|
summary: format!("state {} | updated session metadata", session.state),
|
|
detail_lines: Vec::new(),
|
|
});
|
|
}
|
|
|
|
if let Some(worktree) = session.worktree.as_ref() {
|
|
events.push(TimelineEvent {
|
|
occurred_at: session.updated_at,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::Lifecycle,
|
|
summary: format!(
|
|
"attached worktree {} from {}",
|
|
worktree.branch, worktree.base_branch
|
|
),
|
|
detail_lines: Vec::new(),
|
|
});
|
|
}
|
|
|
|
let file_activity = self
|
|
.db
|
|
.list_file_activity(&session.id, 64)
|
|
.unwrap_or_default();
|
|
if file_activity.is_empty() && session.metrics.files_changed > 0 {
|
|
events.push(TimelineEvent {
|
|
occurred_at: session.updated_at,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::FileChange,
|
|
summary: format!("files touched {}", session.metrics.files_changed),
|
|
detail_lines: Vec::new(),
|
|
});
|
|
} else {
|
|
events.extend(file_activity.into_iter().map(|entry| TimelineEvent {
|
|
occurred_at: entry.timestamp,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::FileChange,
|
|
summary: file_activity_summary(&entry),
|
|
detail_lines: file_activity_patch_lines(&entry, MAX_FILE_ACTIVITY_PATCH_LINES),
|
|
}));
|
|
}
|
|
|
|
let messages = self
|
|
.db
|
|
.list_messages_for_session(&session.id, 128)
|
|
.unwrap_or_default();
|
|
events.extend(messages.into_iter().map(|message| {
|
|
let (direction, counterpart) = if message.from_session == session.id {
|
|
("sent", format_session_id(&message.to_session))
|
|
} else {
|
|
("received", format_session_id(&message.from_session))
|
|
};
|
|
TimelineEvent {
|
|
occurred_at: message.timestamp,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::Message,
|
|
summary: format!(
|
|
"{direction} {} {} | {}",
|
|
message.msg_type,
|
|
counterpart,
|
|
truncate_for_dashboard(
|
|
&comms::preview(&message.msg_type, &message.content),
|
|
64
|
|
)
|
|
),
|
|
detail_lines: Vec::new(),
|
|
}
|
|
}));
|
|
|
|
let tool_logs = self
|
|
.db
|
|
.query_tool_logs(&session.id, 1, 128)
|
|
.map(|page| page.entries)
|
|
.unwrap_or_default();
|
|
events.extend(tool_logs.into_iter().filter_map(|entry| {
|
|
parse_rfc3339_to_utc(&entry.timestamp).map(|occurred_at| TimelineEvent {
|
|
occurred_at,
|
|
session_id: session.id.clone(),
|
|
event_type: TimelineEventType::ToolCall,
|
|
summary: format!(
|
|
"tool {} | {}ms | {}",
|
|
entry.tool_name,
|
|
entry.duration_ms,
|
|
truncate_for_dashboard(&entry.input_summary, 56)
|
|
),
|
|
detail_lines: tool_log_detail_lines(&entry),
|
|
})
|
|
}));
|
|
events
|
|
}
|
|
|
|
fn recompute_search_matches(&mut self) {
|
|
let Some(query) = self.search_query.clone() else {
|
|
self.search_matches.clear();
|
|
self.selected_search_match = 0;
|
|
return;
|
|
};
|
|
|
|
let Ok(regex) = compile_search_regex(&query) else {
|
|
self.search_matches.clear();
|
|
self.selected_search_match = 0;
|
|
return;
|
|
};
|
|
|
|
self.search_matches = self
|
|
.search_target_session_ids()
|
|
.into_iter()
|
|
.flat_map(|session_id| {
|
|
self.visible_output_lines_for_session(session_id)
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(index, line)| {
|
|
regex.is_match(&line.text).then_some(SearchMatch {
|
|
session_id: session_id.to_string(),
|
|
line_index: index,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect();
|
|
|
|
if self.search_matches.is_empty() {
|
|
self.selected_search_match = 0;
|
|
return;
|
|
}
|
|
|
|
self.selected_search_match = self
|
|
.selected_search_match
|
|
.min(self.search_matches.len().saturating_sub(1));
|
|
self.focus_selected_search_match();
|
|
}
|
|
|
|
fn focus_selected_search_match(&mut self) {
|
|
let Some(search_match) = self.search_matches.get(self.selected_search_match).cloned()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if self.selected_session_id() != Some(search_match.session_id.as_str()) {
|
|
self.sync_selection_by_id(Some(&search_match.session_id));
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
}
|
|
|
|
self.output_follow = false;
|
|
let viewport_height = self.last_output_height.max(1);
|
|
let offset = search_match
|
|
.line_index
|
|
.saturating_sub(viewport_height.saturating_sub(1) / 2);
|
|
self.output_scroll_offset = offset.min(self.max_output_scroll());
|
|
}
|
|
|
|
fn search_navigation_note(&self) -> String {
|
|
let query = self.search_query.as_deref().unwrap_or_default();
|
|
let total = self.search_matches.len();
|
|
let current = if total == 0 {
|
|
0
|
|
} else {
|
|
self.selected_search_match.min(total.saturating_sub(1)) + 1
|
|
};
|
|
|
|
format!(
|
|
"search /{query} match {current}/{total} | {}",
|
|
self.search_scope.label()
|
|
)
|
|
}
|
|
|
|
fn search_match_session_count(&self) -> usize {
|
|
self.search_matches
|
|
.iter()
|
|
.map(|search_match| search_match.session_id.as_str())
|
|
.collect::<HashSet<_>>()
|
|
.len()
|
|
}
|
|
|
|
fn search_target_session_ids(&self) -> Vec<&str> {
|
|
let selected_session_id = self.selected_session_id();
|
|
let selected_agent_type = self.selected_agent_type();
|
|
|
|
self.sessions
|
|
.iter()
|
|
.filter(|session| {
|
|
self.search_scope
|
|
.matches(selected_session_id, session.id.as_str())
|
|
&& self
|
|
.search_agent_filter
|
|
.matches(selected_agent_type, session.agent_type.as_str())
|
|
})
|
|
.map(|session| session.id.as_str())
|
|
.collect()
|
|
}
|
|
|
|
fn next_approval_target_session_id(&self) -> Option<String> {
|
|
let pending_items: usize = self.approval_queue_counts.values().sum();
|
|
if pending_items == 0 {
|
|
return None;
|
|
}
|
|
|
|
let active_session_ids: HashSet<_> =
|
|
self.sessions.iter().map(|session| &session.id).collect();
|
|
let queue = self.db.unread_approval_queue(pending_items).ok()?;
|
|
let mut seen = HashSet::new();
|
|
let ordered_targets = queue
|
|
.into_iter()
|
|
.filter_map(|message| {
|
|
if active_session_ids.contains(&message.to_session)
|
|
&& seen.insert(message.to_session.clone())
|
|
{
|
|
Some(message.to_session)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if ordered_targets.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let current_session_id = self.selected_session_id();
|
|
current_session_id
|
|
.and_then(|session_id| {
|
|
ordered_targets
|
|
.iter()
|
|
.position(|target_session_id| target_session_id == session_id)
|
|
.map(|index| ordered_targets[(index + 1) % ordered_targets.len()].clone())
|
|
})
|
|
.or_else(|| ordered_targets.first().cloned())
|
|
}
|
|
|
|
fn sync_output_scroll(&mut self, viewport_height: usize) {
|
|
self.last_output_height = viewport_height.max(1);
|
|
if self.output_mode == OutputMode::GitStatus {
|
|
let max_scroll = self.max_output_scroll();
|
|
let centered = self
|
|
.selected_git_status
|
|
.saturating_sub(self.last_output_height.max(1).saturating_sub(1) / 2);
|
|
self.output_scroll_offset = centered.min(max_scroll);
|
|
return;
|
|
}
|
|
let max_scroll = self.max_output_scroll();
|
|
|
|
if self.output_follow {
|
|
self.output_scroll_offset = max_scroll;
|
|
} else {
|
|
self.output_scroll_offset = self.output_scroll_offset.min(max_scroll);
|
|
}
|
|
}
|
|
|
|
fn max_output_scroll(&self) -> usize {
|
|
let total_lines = if self.output_mode == OutputMode::GitStatus {
|
|
self.selected_git_status_entries.len()
|
|
} else if self.output_mode == OutputMode::Timeline {
|
|
self.visible_timeline_lines().len()
|
|
} else {
|
|
self.visible_output_lines().len()
|
|
};
|
|
total_lines.saturating_sub(self.last_output_height.max(1))
|
|
}
|
|
|
|
fn sync_metrics_scroll(&mut self, viewport_height: usize) {
|
|
self.last_metrics_height = viewport_height.max(1);
|
|
let max_scroll = self.max_metrics_scroll();
|
|
self.metrics_scroll_offset = self.metrics_scroll_offset.min(max_scroll);
|
|
}
|
|
|
|
fn max_metrics_scroll(&self) -> usize {
|
|
self.selected_session_metrics_text()
|
|
.lines()
|
|
.count()
|
|
.saturating_sub(self.last_metrics_height.max(1))
|
|
}
|
|
|
|
fn focused_delegate_index(&self) -> Option<usize> {
|
|
if self.selected_child_sessions.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
self.focused_delegate_session_id
|
|
.as_deref()
|
|
.and_then(|session_id| {
|
|
self.selected_child_sessions
|
|
.iter()
|
|
.position(|delegate| delegate.session_id == session_id)
|
|
})
|
|
.or(Some(0))
|
|
}
|
|
|
|
fn set_focused_delegate_by_index(&mut self, index: usize) {
|
|
let Some(delegate) = self.selected_child_sessions.get(index) else {
|
|
return;
|
|
};
|
|
let delegate_session_id = delegate.session_id.clone();
|
|
|
|
self.focused_delegate_session_id = Some(delegate_session_id.clone());
|
|
self.ensure_focused_delegate_visible();
|
|
self.set_operator_note(format!(
|
|
"focused delegate {}",
|
|
format_session_id(&delegate_session_id)
|
|
));
|
|
}
|
|
|
|
fn sync_focused_delegate_selection(&mut self) {
|
|
self.focused_delegate_session_id = self
|
|
.focused_delegate_index()
|
|
.and_then(|index| self.selected_child_sessions.get(index))
|
|
.map(|delegate| delegate.session_id.clone());
|
|
self.ensure_focused_delegate_visible();
|
|
}
|
|
|
|
fn ensure_focused_delegate_visible(&mut self) {
|
|
let Some(delegate_index) = self.focused_delegate_index() else {
|
|
return;
|
|
};
|
|
let Some(line_index) = self.delegate_metrics_line_index(delegate_index) else {
|
|
return;
|
|
};
|
|
|
|
let viewport_height = self.last_metrics_height.max(1);
|
|
if line_index < self.metrics_scroll_offset {
|
|
self.metrics_scroll_offset = line_index;
|
|
} else if line_index >= self.metrics_scroll_offset + viewport_height {
|
|
self.metrics_scroll_offset =
|
|
line_index.saturating_sub(viewport_height.saturating_sub(1));
|
|
}
|
|
self.metrics_scroll_offset = self.metrics_scroll_offset.min(self.max_metrics_scroll());
|
|
}
|
|
|
|
fn delegate_metrics_line_index(&self, target_index: usize) -> Option<usize> {
|
|
if target_index >= self.selected_child_sessions.len() {
|
|
return None;
|
|
}
|
|
|
|
let mut line_index = self.metrics_line_count_before_delegates();
|
|
for delegate in self.selected_child_sessions.iter().take(target_index) {
|
|
line_index += 1;
|
|
if delegate.last_output_preview.is_some() {
|
|
line_index += 1;
|
|
}
|
|
}
|
|
|
|
Some(line_index)
|
|
}
|
|
|
|
fn metrics_line_count_before_delegates(&self) -> usize {
|
|
if self.sessions.get(self.selected_session).is_none() {
|
|
return 0;
|
|
}
|
|
|
|
let mut line_count = 2;
|
|
if self.selected_parent_session.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if self.selected_team_summary.is_some() {
|
|
line_count += 1;
|
|
}
|
|
line_count += 1;
|
|
line_count += 1;
|
|
|
|
let stabilized = self.daemon_activity.stabilized_after_recovery_at();
|
|
if self.daemon_activity.chronic_saturation_streak > 0 {
|
|
line_count += 1;
|
|
}
|
|
if self.daemon_activity.operator_escalation_required() {
|
|
line_count += 1;
|
|
}
|
|
if self
|
|
.daemon_activity
|
|
.chronic_saturation_cleared_at()
|
|
.is_some()
|
|
{
|
|
line_count += 1;
|
|
}
|
|
if stabilized.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if self.daemon_activity.last_dispatch_at.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if stabilized.is_none() {
|
|
if self.daemon_activity.last_recovery_dispatch_at.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if self.daemon_activity.last_rebalance_at.is_some() {
|
|
line_count += 1;
|
|
}
|
|
}
|
|
if self.daemon_activity.last_auto_merge_at.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if self.daemon_activity.last_auto_prune_at.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if self.selected_route_preview.is_some() {
|
|
line_count += 1;
|
|
}
|
|
if !self.selected_child_sessions.is_empty() {
|
|
line_count += 1;
|
|
}
|
|
|
|
line_count
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn visible_output_text(&self) -> String {
|
|
self.visible_output_lines()
|
|
.iter()
|
|
.map(|line| line.text.clone())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn reset_output_view(&mut self) {
|
|
self.output_follow = true;
|
|
self.output_scroll_offset = 0;
|
|
}
|
|
|
|
fn reset_metrics_view(&mut self) {
|
|
self.metrics_scroll_offset = 0;
|
|
}
|
|
|
|
fn refresh_logs(&mut self) {
|
|
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
|
self.logs.clear();
|
|
return;
|
|
};
|
|
|
|
match self.db.query_tool_logs(&session_id, 1, MAX_LOG_ENTRIES) {
|
|
Ok(page) => self.logs = page.entries,
|
|
Err(error) => {
|
|
tracing::warn!("Failed to load tool logs: {error}");
|
|
self.logs.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn aggregate_usage(&self) -> AggregateUsage {
|
|
let thresholds = self.cfg.effective_budget_alert_thresholds();
|
|
let total_tokens = self
|
|
.sessions
|
|
.iter()
|
|
.map(|session| session.metrics.tokens_used)
|
|
.sum();
|
|
let total_cost_usd = self
|
|
.sessions
|
|
.iter()
|
|
.map(|session| session.metrics.cost_usd)
|
|
.sum::<f64>();
|
|
let token_state = budget_state(
|
|
total_tokens as f64,
|
|
self.cfg.token_budget as f64,
|
|
thresholds,
|
|
);
|
|
let cost_state = budget_state(total_cost_usd, self.cfg.cost_budget_usd, thresholds);
|
|
|
|
AggregateUsage {
|
|
total_tokens,
|
|
total_cost_usd,
|
|
token_state,
|
|
cost_state,
|
|
overall_state: token_state.max(cost_state),
|
|
}
|
|
}
|
|
|
|
fn selected_session_metrics_text(&self) -> String {
|
|
if let Some(session) = self.sessions.get(self.selected_session) {
|
|
let metrics = &session.metrics;
|
|
let group_peers = self
|
|
.sessions
|
|
.iter()
|
|
.filter(|candidate| {
|
|
candidate.project == session.project
|
|
&& candidate.task_group == session.task_group
|
|
})
|
|
.count();
|
|
let mut lines = vec![
|
|
format!(
|
|
"Selected {} [{}]",
|
|
&session.id[..8.min(session.id.len())],
|
|
session.state
|
|
),
|
|
format!("Task {}", session.task),
|
|
format!(
|
|
"Project {} | Group {} | Peer sessions {}",
|
|
session.project, session.task_group, group_peers
|
|
),
|
|
];
|
|
|
|
if let Some(parent) = self.selected_parent_session.as_ref() {
|
|
lines.push(format!("Delegated from {}", format_session_id(parent)));
|
|
}
|
|
|
|
if let Some(team) = self.selected_team_summary {
|
|
lines.push(format!(
|
|
"Team {}/{} | idle {} | running {} | pending {} | failed {} | stopped {}",
|
|
team.total,
|
|
self.cfg.max_parallel_sessions,
|
|
team.idle,
|
|
team.running,
|
|
team.pending,
|
|
team.failed,
|
|
team.stopped
|
|
));
|
|
}
|
|
|
|
lines.push(format!(
|
|
"Global handoff backlog {} lead(s) / {} handoff(s) | Auto-dispatch {} @ {}/lead | Auto-worktree {} | Auto-merge {}",
|
|
self.global_handoff_backlog_leads,
|
|
self.global_handoff_backlog_messages,
|
|
if self.cfg.auto_dispatch_unread_handoffs {
|
|
"on"
|
|
} else {
|
|
"off"
|
|
},
|
|
self.cfg.auto_dispatch_limit_per_session,
|
|
if self.cfg.auto_create_worktrees {
|
|
"on"
|
|
} else {
|
|
"off"
|
|
},
|
|
if self.cfg.auto_merge_ready_worktrees {
|
|
"on"
|
|
} else {
|
|
"off"
|
|
}
|
|
));
|
|
|
|
let stabilized = self.daemon_activity.stabilized_after_recovery_at();
|
|
|
|
lines.push(format!(
|
|
"Coordination mode {}",
|
|
if self.daemon_activity.dispatch_cooloff_active() {
|
|
"rebalance-cooloff (chronic saturation)"
|
|
} else if self.daemon_activity.prefers_rebalance_first() {
|
|
"rebalance-first (chronic saturation)"
|
|
} else if stabilized.is_some() {
|
|
"dispatch-first (stabilized)"
|
|
} else {
|
|
"dispatch-first"
|
|
}
|
|
));
|
|
|
|
if self.daemon_activity.chronic_saturation_streak > 0 {
|
|
lines.push(format!(
|
|
"Chronic saturation streak {} cycle(s)",
|
|
self.daemon_activity.chronic_saturation_streak
|
|
));
|
|
}
|
|
|
|
if self.daemon_activity.operator_escalation_required() {
|
|
lines.push(
|
|
"Operator escalation recommended: chronic saturation is not clearing".into(),
|
|
);
|
|
}
|
|
|
|
if let Some(cleared_at) = self.daemon_activity.chronic_saturation_cleared_at() {
|
|
lines.push(format!(
|
|
"Chronic saturation cleared @ {}",
|
|
self.short_timestamp(&cleared_at.to_rfc3339())
|
|
));
|
|
}
|
|
|
|
if let Some(stabilized_at) = stabilized {
|
|
lines.push(format!(
|
|
"Recovery stabilized @ {}",
|
|
self.short_timestamp(&stabilized_at.to_rfc3339())
|
|
));
|
|
}
|
|
|
|
if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() {
|
|
lines.push(format!(
|
|
"Last daemon dispatch {} routed / {} deferred across {} lead(s) @ {}",
|
|
self.daemon_activity.last_dispatch_routed,
|
|
self.daemon_activity.last_dispatch_deferred,
|
|
self.daemon_activity.last_dispatch_leads,
|
|
self.short_timestamp(&last_dispatch_at.to_rfc3339())
|
|
));
|
|
}
|
|
|
|
if stabilized.is_none() {
|
|
if let Some(last_recovery_dispatch_at) =
|
|
self.daemon_activity.last_recovery_dispatch_at.as_ref()
|
|
{
|
|
lines.push(format!(
|
|
"Last daemon recovery dispatch {} handoff(s) across {} lead(s) @ {}",
|
|
self.daemon_activity.last_recovery_dispatch_routed,
|
|
self.daemon_activity.last_recovery_dispatch_leads,
|
|
self.short_timestamp(&last_recovery_dispatch_at.to_rfc3339())
|
|
));
|
|
}
|
|
|
|
if let Some(last_rebalance_at) = self.daemon_activity.last_rebalance_at.as_ref() {
|
|
lines.push(format!(
|
|
"Last daemon rebalance {} handoff(s) across {} lead(s) @ {}",
|
|
self.daemon_activity.last_rebalance_rerouted,
|
|
self.daemon_activity.last_rebalance_leads,
|
|
self.short_timestamp(&last_rebalance_at.to_rfc3339())
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Some(last_auto_merge_at) = self.daemon_activity.last_auto_merge_at.as_ref() {
|
|
lines.push(format!(
|
|
"Last daemon auto-merge {} merged / {} active / {} conflicted / {} dirty / {} failed @ {}",
|
|
self.daemon_activity.last_auto_merge_merged,
|
|
self.daemon_activity.last_auto_merge_active_skipped,
|
|
self.daemon_activity.last_auto_merge_conflicted_skipped,
|
|
self.daemon_activity.last_auto_merge_dirty_skipped,
|
|
self.daemon_activity.last_auto_merge_failed,
|
|
self.short_timestamp(&last_auto_merge_at.to_rfc3339())
|
|
));
|
|
}
|
|
|
|
if let Some(last_auto_prune_at) = self.daemon_activity.last_auto_prune_at.as_ref() {
|
|
lines.push(format!(
|
|
"Last daemon auto-prune {} pruned / {} active @ {}",
|
|
self.daemon_activity.last_auto_prune_pruned,
|
|
self.daemon_activity.last_auto_prune_active_skipped,
|
|
self.short_timestamp(&last_auto_prune_at.to_rfc3339())
|
|
));
|
|
}
|
|
|
|
if let Some(route_preview) = self.selected_route_preview.as_ref() {
|
|
lines.push(format!("Next route {route_preview}"));
|
|
}
|
|
|
|
if !self.selected_child_sessions.is_empty() {
|
|
lines.push("Delegates".to_string());
|
|
for child in &self.selected_child_sessions {
|
|
let mut child_line = format!(
|
|
"{} {} [{}] | next {}",
|
|
if self.focused_delegate_session_id.as_deref()
|
|
== Some(child.session_id.as_str())
|
|
{
|
|
">>"
|
|
} else {
|
|
"-"
|
|
},
|
|
format_session_id(&child.session_id),
|
|
session_state_label(&child.state),
|
|
delegate_next_action(child)
|
|
);
|
|
if let Some(worktree_health) = child.worktree_health {
|
|
child_line.push_str(&format!(
|
|
" | worktree {}",
|
|
delegate_worktree_health_label(worktree_health)
|
|
));
|
|
}
|
|
child_line.push_str(&format!(
|
|
" | approvals {} | backlog {} | progress {} tok / {} files / {} | task {}",
|
|
child.approval_backlog,
|
|
child.handoff_backlog,
|
|
format_token_count(child.tokens_used),
|
|
child.files_changed,
|
|
format_duration(child.duration_secs),
|
|
child.task_preview
|
|
));
|
|
if let Some(branch) = child.branch.as_ref() {
|
|
child_line.push_str(&format!(" | branch {branch}"));
|
|
}
|
|
lines.push(child_line);
|
|
if let Some(last_output_preview) = child.last_output_preview.as_ref() {
|
|
lines.push(format!(" last output {last_output_preview}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(worktree) = session.worktree.as_ref() {
|
|
lines.push(format!(
|
|
"Branch {} | Base {}",
|
|
worktree.branch, worktree.base_branch
|
|
));
|
|
lines.push(format!("Worktree {}", worktree.path.display()));
|
|
if let Some(diff_summary) = self.selected_diff_summary.as_ref() {
|
|
lines.push(format!("Diff {diff_summary}"));
|
|
}
|
|
if !self.selected_diff_preview.is_empty() {
|
|
lines.push("Changed files".to_string());
|
|
for entry in &self.selected_diff_preview {
|
|
lines.push(format!("- {entry}"));
|
|
}
|
|
}
|
|
if let Some(merge_readiness) = self.selected_merge_readiness.as_ref() {
|
|
lines.push(merge_readiness.summary.clone());
|
|
for conflict in merge_readiness.conflicts.iter().take(3) {
|
|
lines.push(format!("- conflict {conflict}"));
|
|
}
|
|
}
|
|
if let Ok(merge_queue) = manager::build_merge_queue(&self.db) {
|
|
let entry = merge_queue
|
|
.ready_entries
|
|
.iter()
|
|
.chain(merge_queue.blocked_entries.iter())
|
|
.find(|entry| entry.session_id == session.id);
|
|
if let Some(entry) = entry {
|
|
lines.push("Merge queue".to_string());
|
|
if let Some(position) = entry.queue_position {
|
|
lines.push(format!(
|
|
"- ready #{} | {}",
|
|
position, entry.suggested_action
|
|
));
|
|
} else {
|
|
lines.push(format!("- blocked | {}", entry.suggested_action));
|
|
}
|
|
for blocker in entry.blocked_by.iter().take(2) {
|
|
lines.push(format!(
|
|
" blocker {} [{}] | {}",
|
|
format_session_id(&blocker.session_id),
|
|
blocker.branch,
|
|
blocker.summary
|
|
));
|
|
for conflict in blocker.conflicts.iter().take(3) {
|
|
lines.push(format!(" conflict {conflict}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
lines.push(format!(
|
|
"Tokens {} total | In {} | Out {}",
|
|
format_token_count(metrics.tokens_used),
|
|
format_token_count(metrics.input_tokens),
|
|
format_token_count(metrics.output_tokens),
|
|
));
|
|
lines.push(format!(
|
|
"Tools {} | Files {}",
|
|
metrics.tool_calls, metrics.files_changed,
|
|
));
|
|
let recent_file_activity = self
|
|
.db
|
|
.list_file_activity(&session.id, 5)
|
|
.unwrap_or_default();
|
|
if !recent_file_activity.is_empty() {
|
|
lines.push("Recent file activity".to_string());
|
|
for entry in recent_file_activity {
|
|
lines.push(format!(
|
|
"- {} {}",
|
|
self.short_timestamp(&entry.timestamp.to_rfc3339()),
|
|
file_activity_summary(&entry)
|
|
));
|
|
for detail in file_activity_patch_lines(&entry, 2) {
|
|
lines.push(format!(" {}", detail));
|
|
}
|
|
}
|
|
}
|
|
let file_overlaps = self
|
|
.db
|
|
.list_file_overlaps(&session.id, 3)
|
|
.unwrap_or_default();
|
|
if !file_overlaps.is_empty() {
|
|
lines.push("Potential overlaps".to_string());
|
|
for overlap in file_overlaps {
|
|
lines.push(format!(
|
|
"- {}",
|
|
file_overlap_summary(
|
|
&overlap,
|
|
&self.short_timestamp(&overlap.timestamp.to_rfc3339())
|
|
)
|
|
));
|
|
}
|
|
}
|
|
lines.push(format!(
|
|
"Cost ${:.4} | Duration {}s",
|
|
metrics.cost_usd, metrics.duration_secs
|
|
));
|
|
|
|
if let Some(last_output) = self.selected_output_lines().last() {
|
|
lines.push(format!(
|
|
"Last output {}",
|
|
truncate_for_dashboard(&last_output.text, 96)
|
|
));
|
|
}
|
|
|
|
lines.push(String::new());
|
|
if self.selected_messages.is_empty() {
|
|
lines.push("Message inbox clear".to_string());
|
|
} else {
|
|
lines.push("Recent messages:".to_string());
|
|
let recent = self
|
|
.selected_messages
|
|
.iter()
|
|
.rev()
|
|
.take(3)
|
|
.collect::<Vec<_>>();
|
|
for message in recent.into_iter().rev() {
|
|
lines.push(format!(
|
|
"- {} {} -> {} | {}",
|
|
self.short_timestamp(&message.timestamp.to_rfc3339()),
|
|
format_session_id(&message.from_session),
|
|
format_session_id(&message.to_session),
|
|
comms::preview(&message.msg_type, &message.content)
|
|
));
|
|
}
|
|
}
|
|
|
|
let attention_items = self.attention_queue_items(3);
|
|
if attention_items.is_empty() {
|
|
lines.push(String::new());
|
|
lines.push("Attention queue clear".to_string());
|
|
} else {
|
|
lines.push(String::new());
|
|
lines.push("Needs attention:".to_string());
|
|
lines.extend(attention_items);
|
|
}
|
|
|
|
lines.join("\n")
|
|
} else {
|
|
"No metrics available".to_string()
|
|
}
|
|
}
|
|
|
|
fn aggregate_cost_summary(&self) -> (String, Style) {
|
|
let aggregate = self.aggregate_usage();
|
|
let thresholds = self.cfg.effective_budget_alert_thresholds();
|
|
let mut text = if self.cfg.cost_budget_usd > 0.0 {
|
|
format!(
|
|
"Aggregate cost {} / {}",
|
|
format_currency(aggregate.total_cost_usd),
|
|
format_currency(self.cfg.cost_budget_usd),
|
|
)
|
|
} else {
|
|
format!(
|
|
"Aggregate cost {} (no budget)",
|
|
format_currency(aggregate.total_cost_usd)
|
|
)
|
|
};
|
|
|
|
if let Some(summary_suffix) = aggregate.overall_state.summary_suffix(thresholds) {
|
|
text.push_str(" | ");
|
|
text.push_str(&summary_suffix);
|
|
}
|
|
|
|
(text, aggregate.overall_state.style())
|
|
}
|
|
|
|
fn attention_queue_items(&self, limit: usize) -> Vec<String> {
|
|
let mut items = Vec::new();
|
|
let suppress_inbox_attention = self
|
|
.daemon_activity
|
|
.stabilized_after_recovery_at()
|
|
.is_some();
|
|
|
|
for session in &self.sessions {
|
|
if self.worktree_health_by_session.get(&session.id).copied()
|
|
== Some(worktree::WorktreeHealth::Conflicted)
|
|
{
|
|
items.push(format!(
|
|
"- Conflicted worktree {} | {}",
|
|
format_session_id(&session.id),
|
|
truncate_for_dashboard(&session.task, 48)
|
|
));
|
|
}
|
|
|
|
let handoff_backlog = self
|
|
.handoff_backlog_counts
|
|
.get(&session.id)
|
|
.copied()
|
|
.unwrap_or(0);
|
|
if handoff_backlog > 0 && !suppress_inbox_attention {
|
|
items.push(format!(
|
|
"- Backlog {} | {} handoff(s) | {}",
|
|
format_session_id(&session.id),
|
|
handoff_backlog,
|
|
truncate_for_dashboard(&session.task, 40)
|
|
));
|
|
}
|
|
|
|
if matches!(
|
|
session.state,
|
|
SessionState::Failed | SessionState::Stopped | SessionState::Pending
|
|
) {
|
|
items.push(format!(
|
|
"- {} {} | {}",
|
|
session_state_label(&session.state),
|
|
format_session_id(&session.id),
|
|
truncate_for_dashboard(&session.task, 48)
|
|
));
|
|
}
|
|
|
|
if items.len() >= limit {
|
|
break;
|
|
}
|
|
}
|
|
|
|
items.truncate(limit);
|
|
items
|
|
}
|
|
|
|
fn set_operator_note(&mut self, note: String) {
|
|
self.operator_note = Some(note);
|
|
}
|
|
|
|
fn active_session_count(&self) -> usize {
|
|
self.sessions
|
|
.iter()
|
|
.filter(|session| {
|
|
matches!(
|
|
session.state,
|
|
SessionState::Pending
|
|
| SessionState::Running
|
|
| SessionState::Idle
|
|
| SessionState::Stale
|
|
)
|
|
})
|
|
.count()
|
|
}
|
|
|
|
fn refresh_after_spawn(&mut self, select_session_id: Option<&str>) {
|
|
self.refresh();
|
|
self.sync_selection_by_id(select_session_id);
|
|
self.reset_output_view();
|
|
self.reset_metrics_view();
|
|
self.sync_selected_output();
|
|
self.sync_selected_diff();
|
|
self.sync_selected_messages();
|
|
self.sync_selected_lineage();
|
|
self.refresh_logs();
|
|
}
|
|
|
|
fn new_session_task(&self) -> String {
|
|
self.sessions
|
|
.get(self.selected_session)
|
|
.map(|session| {
|
|
format!(
|
|
"Follow up on {}: {}",
|
|
format_session_id(&session.id),
|
|
truncate_for_dashboard(&session.task, 96)
|
|
)
|
|
})
|
|
.unwrap_or_else(|| "New ECC 2.0 session".to_string())
|
|
}
|
|
|
|
fn spawn_prompt_seed(&self) -> String {
|
|
format!("give me 2 agents working on {}", self.new_session_task())
|
|
}
|
|
|
|
fn build_spawn_plan(&self, input: &str) -> Result<SpawnPlan, String> {
|
|
let request = parse_spawn_request(input)?;
|
|
let available_slots = self
|
|
.cfg
|
|
.max_parallel_sessions
|
|
.saturating_sub(self.active_session_count());
|
|
|
|
if available_slots == 0 {
|
|
return Err(format!(
|
|
"cannot queue sessions: active session limit reached ({})",
|
|
self.cfg.max_parallel_sessions
|
|
));
|
|
}
|
|
|
|
Ok(SpawnPlan {
|
|
requested_count: request.requested_count,
|
|
spawn_count: request.requested_count.min(available_slots),
|
|
task: request.task,
|
|
})
|
|
}
|
|
|
|
fn pane_areas(&self, area: Rect) -> PaneAreas {
|
|
let detail_panes = self.visible_detail_panes();
|
|
match self.cfg.pane_layout {
|
|
PaneLayout::Horizontal => {
|
|
let columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(self.primary_constraints())
|
|
.split(area);
|
|
let mut pane_areas = PaneAreas {
|
|
sessions: columns[0],
|
|
output: None,
|
|
metrics: None,
|
|
log: None,
|
|
};
|
|
for (pane, rect) in horizontal_detail_layout(columns[1], &detail_panes) {
|
|
pane_areas.assign(pane, rect);
|
|
}
|
|
pane_areas
|
|
}
|
|
PaneLayout::Vertical => {
|
|
let rows = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(self.primary_constraints())
|
|
.split(area);
|
|
let mut pane_areas = PaneAreas {
|
|
sessions: rows[0],
|
|
output: None,
|
|
metrics: None,
|
|
log: None,
|
|
};
|
|
for (pane, rect) in vertical_detail_layout(rows[1], &detail_panes) {
|
|
pane_areas.assign(pane, rect);
|
|
}
|
|
pane_areas
|
|
}
|
|
PaneLayout::Grid => {
|
|
if detail_panes.len() < 3 {
|
|
let columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(self.primary_constraints())
|
|
.split(area);
|
|
let mut pane_areas = PaneAreas {
|
|
sessions: columns[0],
|
|
output: None,
|
|
metrics: None,
|
|
log: None,
|
|
};
|
|
for (pane, rect) in horizontal_detail_layout(columns[1], &detail_panes) {
|
|
pane_areas.assign(pane, rect);
|
|
}
|
|
pane_areas
|
|
} else {
|
|
let rows = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(self.primary_constraints())
|
|
.split(area);
|
|
let top_columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(self.primary_constraints())
|
|
.split(rows[0]);
|
|
let bottom_columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(self.primary_constraints())
|
|
.split(rows[1]);
|
|
|
|
PaneAreas {
|
|
sessions: top_columns[0],
|
|
output: Some(top_columns[1]),
|
|
metrics: Some(bottom_columns[0]),
|
|
log: Some(bottom_columns[1]),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn primary_constraints(&self) -> [Constraint; 2] {
|
|
[
|
|
Constraint::Percentage(self.pane_size_percent),
|
|
Constraint::Percentage(100 - self.pane_size_percent),
|
|
]
|
|
}
|
|
|
|
fn visible_panes(&self) -> Vec<Pane> {
|
|
self.layout_panes()
|
|
.into_iter()
|
|
.filter(|pane| !self.collapsed_panes.contains(pane))
|
|
.collect()
|
|
}
|
|
|
|
fn visible_detail_panes(&self) -> Vec<Pane> {
|
|
self.visible_panes()
|
|
.into_iter()
|
|
.filter(|pane| *pane != Pane::Sessions)
|
|
.collect()
|
|
}
|
|
|
|
fn layout_panes(&self) -> Vec<Pane> {
|
|
match self.cfg.pane_layout {
|
|
PaneLayout::Grid => vec![Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Log],
|
|
PaneLayout::Horizontal | PaneLayout::Vertical => {
|
|
vec![Pane::Sessions, Pane::Output, Pane::Metrics]
|
|
}
|
|
}
|
|
}
|
|
|
|
fn selected_pane_index(&self) -> usize {
|
|
self.visible_panes()
|
|
.iter()
|
|
.position(|pane| *pane == self.selected_pane)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn pane_border_style(&self, pane: Pane) -> Style {
|
|
if self.selected_pane == pane {
|
|
Style::default().fg(self.theme_palette().accent)
|
|
} else {
|
|
Style::default()
|
|
}
|
|
}
|
|
|
|
fn layout_label(&self) -> &'static str {
|
|
match self.cfg.pane_layout {
|
|
PaneLayout::Horizontal => "horizontal",
|
|
PaneLayout::Vertical => "vertical",
|
|
PaneLayout::Grid => "grid",
|
|
}
|
|
}
|
|
|
|
fn theme_label(&self) -> &'static str {
|
|
match self.cfg.theme {
|
|
Theme::Dark => "dark",
|
|
Theme::Light => "light",
|
|
}
|
|
}
|
|
|
|
fn theme_palette(&self) -> ThemePalette {
|
|
match self.cfg.theme {
|
|
Theme::Dark => ThemePalette {
|
|
accent: Color::Cyan,
|
|
row_highlight_bg: Color::DarkGray,
|
|
muted: Color::DarkGray,
|
|
help_border: Color::Yellow,
|
|
},
|
|
Theme::Light => ThemePalette {
|
|
accent: Color::Blue,
|
|
row_highlight_bg: Color::Gray,
|
|
muted: Color::Black,
|
|
help_border: Color::Blue,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn log_field<'a>(&self, value: &'a str) -> &'a str {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
"n/a"
|
|
} else {
|
|
trimmed
|
|
}
|
|
}
|
|
|
|
fn short_timestamp(&self, timestamp: &str) -> String {
|
|
chrono::DateTime::parse_from_rfc3339(timestamp)
|
|
.map(|value| value.format("%H:%M:%S").to_string())
|
|
.unwrap_or_else(|_| timestamp.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn aggregate_cost_summary_text(&self) -> String {
|
|
self.aggregate_cost_summary().0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn selected_output_text(&self) -> String {
|
|
self.selected_output_lines()
|
|
.iter()
|
|
.map(|line| line.text.clone())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn rendered_output_text(&mut self, width: u16, height: u16) -> String {
|
|
let backend = ratatui::backend::TestBackend::new(width, height);
|
|
let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
|
|
terminal.draw(|frame| self.render(frame)).expect("draw");
|
|
terminal
|
|
.backend()
|
|
.buffer()
|
|
.content()
|
|
.iter()
|
|
.map(|cell| cell.symbol())
|
|
.collect::<String>()
|
|
}
|
|
}
|
|
|
|
impl Pane {
|
|
fn title(self) -> &'static str {
|
|
match self {
|
|
Pane::Sessions => "Sessions",
|
|
Pane::Output => "Output",
|
|
Pane::Metrics => "Metrics",
|
|
Pane::Log => "Log",
|
|
}
|
|
}
|
|
|
|
fn from_shortcut(slot: usize) -> Option<Self> {
|
|
match slot {
|
|
1 => Some(Self::Sessions),
|
|
2 => Some(Self::Output),
|
|
3 => Some(Self::Metrics),
|
|
4 => Some(Self::Log),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn sort_key(self) -> u8 {
|
|
match self {
|
|
Self::Sessions => 1,
|
|
Self::Output => 2,
|
|
Self::Metrics => 3,
|
|
Self::Log => 4,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pane_rect(pane_areas: &PaneAreas, pane: Pane) -> Option<Rect> {
|
|
match pane {
|
|
Pane::Sessions => Some(pane_areas.sessions),
|
|
Pane::Output => pane_areas.output,
|
|
Pane::Metrics => pane_areas.metrics,
|
|
Pane::Log => pane_areas.log,
|
|
}
|
|
}
|
|
|
|
fn pane_center(rect: Rect) -> (i16, i16) {
|
|
(
|
|
rect.x as i16 + rect.width as i16 / 2,
|
|
rect.y as i16 + rect.height as i16 / 2,
|
|
)
|
|
}
|
|
|
|
impl OutputFilter {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::All => Self::ErrorsOnly,
|
|
Self::ErrorsOnly => Self::ToolCallsOnly,
|
|
Self::ToolCallsOnly => Self::FileChangesOnly,
|
|
Self::FileChangesOnly => Self::All,
|
|
}
|
|
}
|
|
|
|
fn matches(self, line: &OutputLine) -> bool {
|
|
match self {
|
|
OutputFilter::All => true,
|
|
OutputFilter::ErrorsOnly => line.stream == OutputStream::Stderr,
|
|
OutputFilter::ToolCallsOnly => looks_like_tool_call(&line.text),
|
|
OutputFilter::FileChangesOnly => looks_like_file_change(&line.text),
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
OutputFilter::All => "all",
|
|
OutputFilter::ErrorsOnly => "errors",
|
|
OutputFilter::ToolCallsOnly => "tool calls",
|
|
OutputFilter::FileChangesOnly => "file changes",
|
|
}
|
|
}
|
|
|
|
fn title_suffix(self) -> &'static str {
|
|
match self {
|
|
OutputFilter::All => "",
|
|
OutputFilter::ErrorsOnly => " errors",
|
|
OutputFilter::ToolCallsOnly => " tool calls",
|
|
OutputFilter::FileChangesOnly => " file changes",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn looks_like_tool_call(text: &str) -> bool {
|
|
let lower = text.trim().to_ascii_lowercase();
|
|
if lower.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
const TOOL_PREFIXES: &[&str] = &[
|
|
"tool ",
|
|
"tool:",
|
|
"[tool",
|
|
"tool call",
|
|
"calling tool",
|
|
"running tool",
|
|
"invoking tool",
|
|
"using tool",
|
|
"read(",
|
|
"write(",
|
|
"edit(",
|
|
"multi_edit(",
|
|
"bash(",
|
|
"grep(",
|
|
"glob(",
|
|
"search(",
|
|
"ls(",
|
|
"apply_patch(",
|
|
];
|
|
|
|
TOOL_PREFIXES.iter().any(|prefix| lower.starts_with(prefix))
|
|
}
|
|
|
|
fn parse_spawn_request(input: &str) -> Result<SpawnRequest, String> {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("spawn request cannot be empty".to_string());
|
|
}
|
|
|
|
let count = Regex::new(r"\b([1-9]\d*)\b")
|
|
.expect("spawn count regex")
|
|
.captures(trimmed)
|
|
.and_then(|captures| captures.get(1))
|
|
.and_then(|count| count.as_str().parse::<usize>().ok())
|
|
.unwrap_or(1);
|
|
|
|
let task = extract_spawn_task(trimmed);
|
|
if task.is_empty() {
|
|
return Err("spawn request must include a task description".to_string());
|
|
}
|
|
|
|
Ok(SpawnRequest {
|
|
requested_count: count,
|
|
task,
|
|
})
|
|
}
|
|
|
|
fn extract_spawn_task(input: &str) -> String {
|
|
let trimmed = input.trim();
|
|
let lower = trimmed.to_ascii_lowercase();
|
|
|
|
for marker in ["working on ", "work on ", "for ", ":"] {
|
|
if let Some(start) = lower.find(marker) {
|
|
let task = trimmed[start + marker.len()..]
|
|
.trim_matches(|ch: char| ch.is_whitespace() || ch == ':' || ch == '-');
|
|
if !task.is_empty() {
|
|
return task.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
let stripped =
|
|
Regex::new(r"(?i)^\s*(give me|spawn|queue|start|launch)\s+\d+\s+(agents?|sessions?)\s*")
|
|
.expect("spawn command regex")
|
|
.replace(trimmed, "");
|
|
let stripped = stripped.trim_matches(|ch: char| ch.is_whitespace() || ch == ':' || ch == '-');
|
|
if !stripped.is_empty() && stripped != trimmed {
|
|
return stripped.to_string();
|
|
}
|
|
|
|
trimmed.to_string()
|
|
}
|
|
|
|
fn expand_spawn_tasks(task: &str, count: usize) -> Vec<String> {
|
|
if count <= 1 {
|
|
return vec![task.to_string()];
|
|
}
|
|
|
|
(0..count)
|
|
.map(|index| format!("{task} [{}/{}]", index + 1, count))
|
|
.collect()
|
|
}
|
|
|
|
fn build_spawn_note(plan: &SpawnPlan, created_count: usize, queued_count: usize) -> String {
|
|
let task = truncate_for_dashboard(&plan.task, 72);
|
|
let mut note = if plan.spawn_count < plan.requested_count {
|
|
format!(
|
|
"spawned {created_count} session(s) for {task} (requested {}, capped at {})",
|
|
plan.requested_count, plan.spawn_count
|
|
)
|
|
} else {
|
|
format!("spawned {created_count} session(s) for {task}")
|
|
};
|
|
|
|
if queued_count > 0 {
|
|
note.push_str(&format!(" | {queued_count} pending worktree slot"));
|
|
}
|
|
|
|
note
|
|
}
|
|
|
|
fn post_spawn_selection_id(
|
|
source_session_id: Option<&str>,
|
|
created_ids: &[String],
|
|
) -> Option<String> {
|
|
if created_ids.len() > 1 {
|
|
source_session_id
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| created_ids.first().cloned())
|
|
} else {
|
|
created_ids.first().cloned()
|
|
}
|
|
}
|
|
|
|
fn looks_like_file_change(text: &str) -> bool {
|
|
let lower = text.trim().to_ascii_lowercase();
|
|
if lower.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
if lower.contains("applied patch")
|
|
|| lower.contains("patch applied")
|
|
|| lower.starts_with("diff --git ")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const FILE_CHANGE_VERBS: &[&str] = &[
|
|
"updated ",
|
|
"created ",
|
|
"deleted ",
|
|
"renamed ",
|
|
"modified ",
|
|
"wrote ",
|
|
"editing ",
|
|
"edited ",
|
|
"writing ",
|
|
];
|
|
|
|
FILE_CHANGE_VERBS
|
|
.iter()
|
|
.any(|prefix| lower.starts_with(prefix) && contains_path_like_token(text))
|
|
}
|
|
|
|
fn contains_path_like_token(text: &str) -> bool {
|
|
text.split_whitespace().any(|token| {
|
|
let trimmed = token.trim_matches(|ch: char| {
|
|
matches!(
|
|
ch,
|
|
'[' | ']' | '(' | ')' | '{' | '}' | ',' | ':' | ';' | '"' | '\''
|
|
)
|
|
});
|
|
|
|
trimmed.contains('/')
|
|
|| trimmed.contains('\\')
|
|
|| trimmed.starts_with("./")
|
|
|| trimmed.starts_with("../")
|
|
|| trimmed
|
|
.rsplit_once('.')
|
|
.map(|(stem, ext)| {
|
|
!stem.is_empty()
|
|
&& !ext.is_empty()
|
|
&& ext.len() <= 10
|
|
&& ext.chars().all(|ch| ch.is_ascii_alphanumeric())
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
}
|
|
|
|
impl OutputTimeFilter {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::AllTime => Self::Last15Minutes,
|
|
Self::Last15Minutes => Self::LastHour,
|
|
Self::LastHour => Self::Last24Hours,
|
|
Self::Last24Hours => Self::AllTime,
|
|
}
|
|
}
|
|
|
|
fn matches(self, line: &OutputLine) -> bool {
|
|
match self {
|
|
Self::AllTime => true,
|
|
Self::Last15Minutes => line
|
|
.occurred_at()
|
|
.map(|timestamp| self.matches_timestamp(timestamp))
|
|
.unwrap_or(false),
|
|
Self::LastHour => line
|
|
.occurred_at()
|
|
.map(|timestamp| self.matches_timestamp(timestamp))
|
|
.unwrap_or(false),
|
|
Self::Last24Hours => line
|
|
.occurred_at()
|
|
.map(|timestamp| self.matches_timestamp(timestamp))
|
|
.unwrap_or(false),
|
|
}
|
|
}
|
|
|
|
fn matches_timestamp(self, timestamp: chrono::DateTime<Utc>) -> bool {
|
|
match self {
|
|
Self::AllTime => true,
|
|
Self::Last15Minutes => timestamp >= Utc::now() - Duration::minutes(15),
|
|
Self::LastHour => timestamp >= Utc::now() - Duration::hours(1),
|
|
Self::Last24Hours => timestamp >= Utc::now() - Duration::hours(24),
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::AllTime => "all time",
|
|
Self::Last15Minutes => "last 15m",
|
|
Self::LastHour => "last 1h",
|
|
Self::Last24Hours => "last 24h",
|
|
}
|
|
}
|
|
|
|
fn title_suffix(self) -> &'static str {
|
|
match self {
|
|
Self::AllTime => "",
|
|
Self::Last15Minutes => " last 15m",
|
|
Self::LastHour => " last 1h",
|
|
Self::Last24Hours => " last 24h",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DiffViewMode {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Split => "split",
|
|
Self::Unified => "unified",
|
|
}
|
|
}
|
|
|
|
fn title_suffix(self) -> &'static str {
|
|
match self {
|
|
Self::Split => " split",
|
|
Self::Unified => " unified",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TimelineEventFilter {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::All => Self::Lifecycle,
|
|
Self::Lifecycle => Self::Messages,
|
|
Self::Messages => Self::ToolCalls,
|
|
Self::ToolCalls => Self::FileChanges,
|
|
Self::FileChanges => Self::All,
|
|
}
|
|
}
|
|
|
|
fn matches(self, event_type: TimelineEventType) -> bool {
|
|
match self {
|
|
Self::All => true,
|
|
Self::Lifecycle => event_type == TimelineEventType::Lifecycle,
|
|
Self::Messages => event_type == TimelineEventType::Message,
|
|
Self::ToolCalls => event_type == TimelineEventType::ToolCall,
|
|
Self::FileChanges => event_type == TimelineEventType::FileChange,
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::All => "all events",
|
|
Self::Lifecycle => "lifecycle",
|
|
Self::Messages => "messages",
|
|
Self::ToolCalls => "tool calls",
|
|
Self::FileChanges => "file changes",
|
|
}
|
|
}
|
|
|
|
fn title_suffix(self) -> &'static str {
|
|
match self {
|
|
Self::All => "",
|
|
Self::Lifecycle => " lifecycle",
|
|
Self::Messages => " messages",
|
|
Self::ToolCalls => " tool calls",
|
|
Self::FileChanges => " file changes",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TimelineEventType {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Lifecycle => "lifecycle",
|
|
Self::Message => "message",
|
|
Self::ToolCall => "tool",
|
|
Self::FileChange => "file-change",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_rfc3339_to_utc(value: &str) -> Option<chrono::DateTime<Utc>> {
|
|
chrono::DateTime::parse_from_rfc3339(value)
|
|
.ok()
|
|
.map(|timestamp| timestamp.with_timezone(&Utc))
|
|
}
|
|
|
|
impl SearchScope {
|
|
fn next(self) -> Self {
|
|
match self {
|
|
Self::SelectedSession => Self::AllSessions,
|
|
Self::AllSessions => Self::SelectedSession,
|
|
}
|
|
}
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::SelectedSession => "selected session",
|
|
Self::AllSessions => "all sessions",
|
|
}
|
|
}
|
|
|
|
fn title_suffix(self) -> &'static str {
|
|
match self {
|
|
Self::SelectedSession => "",
|
|
Self::AllSessions => " all sessions",
|
|
}
|
|
}
|
|
|
|
fn matches(self, selected_session_id: Option<&str>, session_id: &str) -> bool {
|
|
match self {
|
|
Self::SelectedSession => selected_session_id == Some(session_id),
|
|
Self::AllSessions => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SearchAgentFilter {
|
|
fn matches(self, selected_agent_type: Option<&str>, session_agent_type: &str) -> bool {
|
|
match self {
|
|
Self::AllAgents => true,
|
|
Self::SelectedAgentType => selected_agent_type == Some(session_agent_type),
|
|
}
|
|
}
|
|
|
|
fn label(self, selected_agent_type: &str) -> String {
|
|
match self {
|
|
Self::AllAgents => "all agents".to_string(),
|
|
Self::SelectedAgentType => format!("agent {}", selected_agent_type),
|
|
}
|
|
}
|
|
|
|
fn title_suffix(self, selected_agent_type: &str) -> String {
|
|
match self {
|
|
Self::AllAgents => String::new(),
|
|
Self::SelectedAgentType => format!(" {}", self.label(selected_agent_type)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SessionSummary {
|
|
fn from_sessions(
|
|
sessions: &[Session],
|
|
unread_message_counts: &HashMap<String, usize>,
|
|
worktree_health_by_session: &HashMap<String, worktree::WorktreeHealth>,
|
|
suppress_inbox_attention: bool,
|
|
) -> Self {
|
|
let projects = sessions
|
|
.iter()
|
|
.map(|session| session.project.as_str())
|
|
.collect::<HashSet<_>>()
|
|
.len();
|
|
let task_groups = sessions
|
|
.iter()
|
|
.map(|session| (session.project.as_str(), session.task_group.as_str()))
|
|
.collect::<HashSet<_>>()
|
|
.len();
|
|
sessions.iter().fold(
|
|
Self {
|
|
total: sessions.len(),
|
|
projects,
|
|
task_groups,
|
|
unread_messages: if suppress_inbox_attention {
|
|
0
|
|
} else {
|
|
unread_message_counts.values().sum()
|
|
},
|
|
inbox_sessions: if suppress_inbox_attention {
|
|
0
|
|
} else {
|
|
unread_message_counts
|
|
.values()
|
|
.filter(|count| **count > 0)
|
|
.count()
|
|
},
|
|
..Self::default()
|
|
},
|
|
|mut summary, session| {
|
|
match session.state {
|
|
SessionState::Pending => summary.pending += 1,
|
|
SessionState::Running => summary.running += 1,
|
|
SessionState::Idle => summary.idle += 1,
|
|
SessionState::Stale => summary.stale += 1,
|
|
SessionState::Completed => summary.completed += 1,
|
|
SessionState::Failed => summary.failed += 1,
|
|
SessionState::Stopped => summary.stopped += 1,
|
|
}
|
|
match worktree_health_by_session.get(&session.id).copied() {
|
|
Some(worktree::WorktreeHealth::Conflicted) => {
|
|
summary.conflicted_worktrees += 1;
|
|
}
|
|
Some(worktree::WorktreeHealth::InProgress) => {
|
|
summary.in_progress_worktrees += 1;
|
|
}
|
|
Some(worktree::WorktreeHealth::Clear) | None => {}
|
|
}
|
|
summary
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
fn session_row(
|
|
session: &Session,
|
|
project_label: Option<String>,
|
|
task_group_label: Option<String>,
|
|
approval_requests: usize,
|
|
unread_messages: usize,
|
|
) -> Row<'static> {
|
|
let state_label = session_state_label(&session.state);
|
|
let state_color = session_state_color(&session.state);
|
|
Row::new(vec![
|
|
Cell::from(format_session_id(&session.id)),
|
|
Cell::from(project_label.unwrap_or_default()),
|
|
Cell::from(task_group_label.unwrap_or_default()),
|
|
Cell::from(session.agent_type.clone()),
|
|
Cell::from(state_label).style(
|
|
Style::default()
|
|
.fg(state_color)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Cell::from(session_branch(session)),
|
|
Cell::from(if approval_requests == 0 {
|
|
"-".to_string()
|
|
} else {
|
|
approval_requests.to_string()
|
|
})
|
|
.style(if approval_requests == 0 {
|
|
Style::default()
|
|
} else {
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD)
|
|
}),
|
|
Cell::from(if unread_messages == 0 {
|
|
"-".to_string()
|
|
} else {
|
|
unread_messages.to_string()
|
|
})
|
|
.style(if unread_messages == 0 {
|
|
Style::default()
|
|
} else {
|
|
Style::default()
|
|
.fg(Color::Magenta)
|
|
.add_modifier(Modifier::BOLD)
|
|
}),
|
|
Cell::from(session.metrics.tokens_used.to_string()),
|
|
Cell::from(session.metrics.tool_calls.to_string()),
|
|
Cell::from(session.metrics.files_changed.to_string()),
|
|
Cell::from(format_duration(session.metrics.duration_secs)),
|
|
])
|
|
}
|
|
|
|
fn sort_sessions_for_display(sessions: &mut [Session]) {
|
|
sessions.sort_by(|left, right| {
|
|
left.project
|
|
.cmp(&right.project)
|
|
.then_with(|| left.task_group.cmp(&right.task_group))
|
|
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
|
.then_with(|| left.id.cmp(&right.id))
|
|
});
|
|
}
|
|
|
|
fn summary_line(summary: &SessionSummary) -> Line<'static> {
|
|
let mut spans = vec![
|
|
Span::styled(
|
|
format!("Total {} ", summary.total),
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
),
|
|
summary_span("Projects", summary.projects, Color::Cyan),
|
|
summary_span("Groups", summary.task_groups, Color::Magenta),
|
|
summary_span("Running", summary.running, Color::Green),
|
|
summary_span("Idle", summary.idle, Color::Yellow),
|
|
summary_span("Stale", summary.stale, Color::LightRed),
|
|
summary_span("Completed", summary.completed, Color::Blue),
|
|
summary_span("Failed", summary.failed, Color::Red),
|
|
summary_span("Stopped", summary.stopped, Color::DarkGray),
|
|
summary_span("Pending", summary.pending, Color::Reset),
|
|
];
|
|
|
|
if summary.conflicted_worktrees > 0 {
|
|
spans.push(summary_span(
|
|
"Conflicts",
|
|
summary.conflicted_worktrees,
|
|
Color::Red,
|
|
));
|
|
}
|
|
|
|
if summary.in_progress_worktrees > 0 {
|
|
spans.push(summary_span(
|
|
"Worktrees",
|
|
summary.in_progress_worktrees,
|
|
Color::Cyan,
|
|
));
|
|
}
|
|
|
|
Line::from(spans)
|
|
}
|
|
|
|
fn summary_span(label: &str, value: usize, color: Color) -> Span<'static> {
|
|
Span::styled(
|
|
format!("{label} {value} "),
|
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
|
)
|
|
}
|
|
|
|
fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'static> {
|
|
if summary.failed == 0
|
|
&& summary.stopped == 0
|
|
&& summary.pending == 0
|
|
&& summary.stale == 0
|
|
&& summary.unread_messages == 0
|
|
&& summary.conflicted_worktrees == 0
|
|
{
|
|
return Line::from(vec![
|
|
Span::styled(
|
|
"Attention queue clear",
|
|
Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(if stabilized {
|
|
" stabilized backlog absorbed"
|
|
} else {
|
|
" no failed, stopped, or pending sessions"
|
|
}),
|
|
]);
|
|
}
|
|
|
|
let mut spans = vec![Span::styled(
|
|
"Attention queue ",
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
)];
|
|
|
|
if summary.conflicted_worktrees > 0 {
|
|
spans.push(summary_span(
|
|
"Conflicts",
|
|
summary.conflicted_worktrees,
|
|
Color::Red,
|
|
));
|
|
}
|
|
|
|
spans.extend([
|
|
summary_span("Stale", summary.stale, Color::LightRed),
|
|
summary_span("Backlog", summary.unread_messages, Color::Magenta),
|
|
summary_span("Failed", summary.failed, Color::Red),
|
|
summary_span("Stopped", summary.stopped, Color::DarkGray),
|
|
summary_span("Pending", summary.pending, Color::Yellow),
|
|
]);
|
|
|
|
Line::from(spans)
|
|
}
|
|
|
|
fn approval_queue_line(approval_queue_counts: &HashMap<String, usize>) -> Line<'static> {
|
|
let pending_sessions = approval_queue_counts.len();
|
|
let pending_items: usize = approval_queue_counts.values().sum();
|
|
|
|
if pending_items == 0 {
|
|
return Line::from(vec![
|
|
Span::styled(
|
|
"Approval queue clear",
|
|
Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(" no unanswered queries or conflicts"),
|
|
]);
|
|
}
|
|
|
|
Line::from(vec![
|
|
Span::styled(
|
|
"Approval queue ",
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
summary_span("Pending", pending_items, Color::Yellow),
|
|
summary_span("Sessions", pending_sessions, Color::Yellow),
|
|
])
|
|
}
|
|
|
|
fn approval_queue_preview_line(messages: &[SessionMessage]) -> Option<Line<'static>> {
|
|
let message = messages.first()?;
|
|
let preview = truncate_for_dashboard(&comms::preview(&message.msg_type, &message.content), 72);
|
|
|
|
Some(Line::from(vec![
|
|
Span::raw("- "),
|
|
Span::styled(
|
|
format_session_id(&message.to_session),
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(" | "),
|
|
Span::raw(preview),
|
|
]))
|
|
}
|
|
|
|
fn truncate_for_dashboard(value: &str, max_chars: usize) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.chars().count() <= max_chars {
|
|
return trimmed.to_string();
|
|
}
|
|
|
|
let truncated: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect();
|
|
format!("{truncated}…")
|
|
}
|
|
|
|
fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 {
|
|
let configured = match layout {
|
|
PaneLayout::Horizontal | PaneLayout::Vertical => cfg.linear_pane_size_percent,
|
|
PaneLayout::Grid => cfg.grid_pane_size_percent,
|
|
};
|
|
|
|
configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT)
|
|
}
|
|
|
|
fn recommended_spawn_layout(live_session_count: usize) -> PaneLayout {
|
|
if live_session_count >= 3 {
|
|
PaneLayout::Grid
|
|
} else {
|
|
PaneLayout::Vertical
|
|
}
|
|
}
|
|
|
|
fn pane_layout_name(layout: PaneLayout) -> &'static str {
|
|
match layout {
|
|
PaneLayout::Horizontal => "horizontal",
|
|
PaneLayout::Vertical => "vertical",
|
|
PaneLayout::Grid => "grid",
|
|
}
|
|
}
|
|
|
|
fn horizontal_detail_layout(area: Rect, panes: &[Pane]) -> Vec<(Pane, Rect)> {
|
|
match panes {
|
|
[] => Vec::new(),
|
|
[pane] => vec![(*pane, area)],
|
|
[first, second] => {
|
|
let rows = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(OUTPUT_PANE_PERCENT),
|
|
Constraint::Percentage(100 - OUTPUT_PANE_PERCENT),
|
|
])
|
|
.split(area);
|
|
vec![(*first, rows[0]), (*second, rows[1])]
|
|
}
|
|
_ => unreachable!("horizontal layouts support at most two detail panes"),
|
|
}
|
|
}
|
|
|
|
fn vertical_detail_layout(area: Rect, panes: &[Pane]) -> Vec<(Pane, Rect)> {
|
|
match panes {
|
|
[] => Vec::new(),
|
|
[pane] => vec![(*pane, area)],
|
|
[first, second] => {
|
|
let columns = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(OUTPUT_PANE_PERCENT),
|
|
Constraint::Percentage(100 - OUTPUT_PANE_PERCENT),
|
|
])
|
|
.split(area);
|
|
vec![(*first, columns[0]), (*second, columns[1])]
|
|
}
|
|
_ => unreachable!("vertical layouts support at most two detail panes"),
|
|
}
|
|
}
|
|
|
|
fn compile_search_regex(query: &str) -> Result<Regex, regex::Error> {
|
|
Regex::new(query)
|
|
}
|
|
|
|
fn highlight_output_line(
|
|
text: &str,
|
|
query: &str,
|
|
is_current_match: bool,
|
|
palette: ThemePalette,
|
|
) -> Line<'static> {
|
|
if query.is_empty() {
|
|
return Line::from(text.to_string());
|
|
}
|
|
|
|
let Ok(regex) = compile_search_regex(query) else {
|
|
return Line::from(text.to_string());
|
|
};
|
|
|
|
let mut spans = Vec::new();
|
|
let mut cursor = 0;
|
|
for matched in regex.find_iter(text) {
|
|
let start = matched.start();
|
|
let end = matched.end();
|
|
|
|
if start > cursor {
|
|
spans.push(Span::raw(text[cursor..start].to_string()));
|
|
}
|
|
|
|
let match_style = if is_current_match {
|
|
Style::default()
|
|
.bg(palette.accent)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().bg(Color::Yellow).fg(Color::Black)
|
|
};
|
|
spans.push(Span::styled(text[start..end].to_string(), match_style));
|
|
cursor = end;
|
|
}
|
|
|
|
if cursor < text.len() {
|
|
spans.push(Span::raw(text[cursor..].to_string()));
|
|
}
|
|
|
|
if spans.is_empty() {
|
|
Line::from(text.to_string())
|
|
} else {
|
|
Line::from(spans)
|
|
}
|
|
}
|
|
|
|
fn build_worktree_diff_columns(patch: &str, palette: ThemePalette) -> WorktreeDiffColumns {
|
|
let mut removals = Vec::new();
|
|
let mut additions = Vec::new();
|
|
let mut hunk_offsets = Vec::new();
|
|
let mut pending_removals = Vec::new();
|
|
let mut pending_additions = Vec::new();
|
|
|
|
for line in patch.lines() {
|
|
if is_diff_removal_line(line) {
|
|
pending_removals.push(line[1..].to_string());
|
|
continue;
|
|
}
|
|
|
|
if is_diff_addition_line(line) {
|
|
pending_additions.push(line[1..].to_string());
|
|
continue;
|
|
}
|
|
|
|
flush_split_diff_change_block(
|
|
&mut removals,
|
|
&mut additions,
|
|
&mut pending_removals,
|
|
&mut pending_additions,
|
|
palette,
|
|
);
|
|
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with("@@") {
|
|
hunk_offsets.push(removals.len().max(additions.len()));
|
|
}
|
|
|
|
let styled_line = if line.starts_with(' ') {
|
|
styled_diff_context_line(line, palette)
|
|
} else {
|
|
styled_diff_meta_line(split_diff_display_line(line), palette)
|
|
};
|
|
removals.push(styled_line.clone());
|
|
additions.push(styled_line);
|
|
}
|
|
|
|
flush_split_diff_change_block(
|
|
&mut removals,
|
|
&mut additions,
|
|
&mut pending_removals,
|
|
&mut pending_additions,
|
|
palette,
|
|
);
|
|
|
|
WorktreeDiffColumns {
|
|
removals: if removals.is_empty() {
|
|
Text::from("No removals in this bounded preview.")
|
|
} else {
|
|
Text::from(removals)
|
|
},
|
|
additions: if additions.is_empty() {
|
|
Text::from("No additions in this bounded preview.")
|
|
} else {
|
|
Text::from(additions)
|
|
},
|
|
hunk_offsets,
|
|
}
|
|
}
|
|
|
|
fn build_unified_diff_text(patch: &str, palette: ThemePalette) -> Text<'static> {
|
|
let mut lines = Vec::new();
|
|
let mut pending_removals = Vec::new();
|
|
let mut pending_additions = Vec::new();
|
|
|
|
for line in patch.lines() {
|
|
if is_diff_removal_line(line) {
|
|
pending_removals.push(line[1..].to_string());
|
|
continue;
|
|
}
|
|
|
|
if is_diff_addition_line(line) {
|
|
pending_additions.push(line[1..].to_string());
|
|
continue;
|
|
}
|
|
|
|
flush_unified_diff_change_block(
|
|
&mut lines,
|
|
&mut pending_removals,
|
|
&mut pending_additions,
|
|
palette,
|
|
);
|
|
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
lines.push(if line.starts_with(' ') {
|
|
styled_diff_context_line(line, palette)
|
|
} else {
|
|
styled_diff_meta_line(line, palette)
|
|
});
|
|
}
|
|
|
|
flush_unified_diff_change_block(
|
|
&mut lines,
|
|
&mut pending_removals,
|
|
&mut pending_additions,
|
|
palette,
|
|
);
|
|
|
|
Text::from(lines)
|
|
}
|
|
|
|
fn build_unified_diff_hunk_offsets(patch: &str) -> Vec<usize> {
|
|
let mut offsets = Vec::new();
|
|
let mut rendered_index = 0usize;
|
|
let mut pending_removals = 0usize;
|
|
let mut pending_additions = 0usize;
|
|
|
|
for line in patch.lines() {
|
|
if is_diff_removal_line(line) {
|
|
pending_removals += 1;
|
|
continue;
|
|
}
|
|
|
|
if is_diff_addition_line(line) {
|
|
pending_additions += 1;
|
|
continue;
|
|
}
|
|
|
|
if pending_removals > 0 || pending_additions > 0 {
|
|
rendered_index += pending_removals + pending_additions;
|
|
pending_removals = 0;
|
|
pending_additions = 0;
|
|
}
|
|
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with("@@") {
|
|
offsets.push(rendered_index);
|
|
}
|
|
rendered_index += 1;
|
|
}
|
|
|
|
offsets
|
|
}
|
|
|
|
fn flush_split_diff_change_block(
|
|
removals: &mut Vec<Line<'static>>,
|
|
additions: &mut Vec<Line<'static>>,
|
|
pending_removals: &mut Vec<String>,
|
|
pending_additions: &mut Vec<String>,
|
|
palette: ThemePalette,
|
|
) {
|
|
let pair_count = pending_removals.len().max(pending_additions.len());
|
|
for index in 0..pair_count {
|
|
match (pending_removals.get(index), pending_additions.get(index)) {
|
|
(Some(removal), Some(addition)) => {
|
|
let (removal_mask, addition_mask) =
|
|
diff_word_change_masks(removal.as_str(), addition.as_str());
|
|
removals.push(styled_diff_change_line(
|
|
'-',
|
|
removal,
|
|
&removal_mask,
|
|
diff_removal_style(palette),
|
|
diff_removal_word_style(),
|
|
));
|
|
additions.push(styled_diff_change_line(
|
|
'+',
|
|
addition,
|
|
&addition_mask,
|
|
diff_addition_style(palette),
|
|
diff_addition_word_style(),
|
|
));
|
|
}
|
|
(Some(removal), None) => {
|
|
removals.push(styled_diff_change_line(
|
|
'-',
|
|
removal,
|
|
&vec![false; tokenize_diff_words(removal).len()],
|
|
diff_removal_style(palette),
|
|
diff_removal_word_style(),
|
|
));
|
|
additions.push(Line::from(""));
|
|
}
|
|
(None, Some(addition)) => {
|
|
removals.push(Line::from(""));
|
|
additions.push(styled_diff_change_line(
|
|
'+',
|
|
addition,
|
|
&vec![false; tokenize_diff_words(addition).len()],
|
|
diff_addition_style(palette),
|
|
diff_addition_word_style(),
|
|
));
|
|
}
|
|
(None, None) => {}
|
|
}
|
|
}
|
|
|
|
pending_removals.clear();
|
|
pending_additions.clear();
|
|
}
|
|
|
|
fn flush_unified_diff_change_block(
|
|
lines: &mut Vec<Line<'static>>,
|
|
pending_removals: &mut Vec<String>,
|
|
pending_additions: &mut Vec<String>,
|
|
palette: ThemePalette,
|
|
) {
|
|
let pair_count = pending_removals.len().max(pending_additions.len());
|
|
for index in 0..pair_count {
|
|
match (pending_removals.get(index), pending_additions.get(index)) {
|
|
(Some(removal), Some(addition)) => {
|
|
let (removal_mask, addition_mask) =
|
|
diff_word_change_masks(removal.as_str(), addition.as_str());
|
|
lines.push(styled_diff_change_line(
|
|
'-',
|
|
removal,
|
|
&removal_mask,
|
|
diff_removal_style(palette),
|
|
diff_removal_word_style(),
|
|
));
|
|
lines.push(styled_diff_change_line(
|
|
'+',
|
|
addition,
|
|
&addition_mask,
|
|
diff_addition_style(palette),
|
|
diff_addition_word_style(),
|
|
));
|
|
}
|
|
(Some(removal), None) => lines.push(styled_diff_change_line(
|
|
'-',
|
|
removal,
|
|
&vec![false; tokenize_diff_words(removal).len()],
|
|
diff_removal_style(palette),
|
|
diff_removal_word_style(),
|
|
)),
|
|
(None, Some(addition)) => lines.push(styled_diff_change_line(
|
|
'+',
|
|
addition,
|
|
&vec![false; tokenize_diff_words(addition).len()],
|
|
diff_addition_style(palette),
|
|
diff_addition_word_style(),
|
|
)),
|
|
(None, None) => {}
|
|
}
|
|
}
|
|
|
|
pending_removals.clear();
|
|
pending_additions.clear();
|
|
}
|
|
|
|
fn split_diff_display_line(line: &str) -> String {
|
|
if line.starts_with("--- ") && !line.starts_with("--- a/") {
|
|
return line.to_string();
|
|
}
|
|
|
|
if let Some(path) = line.strip_prefix("--- a/") {
|
|
return format!("File {path}");
|
|
}
|
|
|
|
if let Some(path) = line.strip_prefix("+++ b/") {
|
|
return format!("File {path}");
|
|
}
|
|
|
|
line.to_string()
|
|
}
|
|
|
|
fn is_diff_removal_line(line: &str) -> bool {
|
|
line.starts_with('-') && !line.starts_with("--- ")
|
|
}
|
|
|
|
fn is_diff_addition_line(line: &str) -> bool {
|
|
line.starts_with('+') && !line.starts_with("+++ ")
|
|
}
|
|
|
|
fn styled_diff_meta_line(text: impl Into<String>, palette: ThemePalette) -> Line<'static> {
|
|
Line::from(vec![Span::styled(text.into(), diff_meta_style(palette))])
|
|
}
|
|
|
|
fn styled_diff_context_line(text: &str, palette: ThemePalette) -> Line<'static> {
|
|
Line::from(vec![Span::styled(
|
|
text.to_string(),
|
|
diff_context_style(palette),
|
|
)])
|
|
}
|
|
|
|
fn styled_diff_change_line(
|
|
prefix: char,
|
|
body: &str,
|
|
change_mask: &[bool],
|
|
base_style: Style,
|
|
changed_style: Style,
|
|
) -> Line<'static> {
|
|
let tokens = tokenize_diff_words(body);
|
|
let mut spans = vec![Span::styled(
|
|
prefix.to_string(),
|
|
base_style.add_modifier(Modifier::BOLD),
|
|
)];
|
|
|
|
for (index, token) in tokens.into_iter().enumerate() {
|
|
let style = if change_mask.get(index).copied().unwrap_or(false) {
|
|
changed_style
|
|
} else {
|
|
base_style
|
|
};
|
|
spans.push(Span::styled(token, style));
|
|
}
|
|
|
|
Line::from(spans)
|
|
}
|
|
|
|
fn tokenize_diff_words(text: &str) -> Vec<String> {
|
|
if text.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut tokens = Vec::new();
|
|
let mut current = String::new();
|
|
let mut current_is_whitespace: Option<bool> = None;
|
|
|
|
for ch in text.chars() {
|
|
let is_whitespace = ch.is_whitespace();
|
|
match current_is_whitespace {
|
|
Some(state) if state == is_whitespace => current.push(ch),
|
|
Some(_) => {
|
|
tokens.push(std::mem::take(&mut current));
|
|
current.push(ch);
|
|
current_is_whitespace = Some(is_whitespace);
|
|
}
|
|
None => {
|
|
current.push(ch);
|
|
current_is_whitespace = Some(is_whitespace);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !current.is_empty() {
|
|
tokens.push(current);
|
|
}
|
|
|
|
tokens
|
|
}
|
|
|
|
fn diff_word_change_masks(left: &str, right: &str) -> (Vec<bool>, Vec<bool>) {
|
|
let left_tokens = tokenize_diff_words(left);
|
|
let right_tokens = tokenize_diff_words(right);
|
|
let left_len = left_tokens.len();
|
|
let right_len = right_tokens.len();
|
|
let mut lcs = vec![vec![0usize; right_len + 1]; left_len + 1];
|
|
|
|
for left_index in (0..left_len).rev() {
|
|
for right_index in (0..right_len).rev() {
|
|
lcs[left_index][right_index] = if left_tokens[left_index] == right_tokens[right_index] {
|
|
lcs[left_index + 1][right_index + 1] + 1
|
|
} else {
|
|
lcs[left_index + 1][right_index].max(lcs[left_index][right_index + 1])
|
|
};
|
|
}
|
|
}
|
|
|
|
let mut left_changed = vec![true; left_len];
|
|
let mut right_changed = vec![true; right_len];
|
|
let (mut left_index, mut right_index) = (0usize, 0usize);
|
|
while left_index < left_len && right_index < right_len {
|
|
if left_tokens[left_index] == right_tokens[right_index] {
|
|
left_changed[left_index] = false;
|
|
right_changed[right_index] = false;
|
|
left_index += 1;
|
|
right_index += 1;
|
|
} else if lcs[left_index + 1][right_index] >= lcs[left_index][right_index + 1] {
|
|
left_index += 1;
|
|
} else {
|
|
right_index += 1;
|
|
}
|
|
}
|
|
|
|
(left_changed, right_changed)
|
|
}
|
|
|
|
fn diff_meta_style(palette: ThemePalette) -> Style {
|
|
Style::default()
|
|
.fg(palette.accent)
|
|
.add_modifier(Modifier::BOLD)
|
|
}
|
|
|
|
fn diff_context_style(palette: ThemePalette) -> Style {
|
|
Style::default().fg(palette.muted)
|
|
}
|
|
|
|
fn diff_removal_style(palette: ThemePalette) -> Style {
|
|
let color = match palette.accent {
|
|
Color::Blue => Color::Red,
|
|
_ => Color::LightRed,
|
|
};
|
|
Style::default().fg(color)
|
|
}
|
|
|
|
fn diff_addition_style(palette: ThemePalette) -> Style {
|
|
let color = match palette.accent {
|
|
Color::Blue => Color::Green,
|
|
_ => Color::LightGreen,
|
|
};
|
|
Style::default().fg(color)
|
|
}
|
|
|
|
fn diff_removal_word_style() -> Style {
|
|
Style::default()
|
|
.bg(Color::Red)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
}
|
|
|
|
fn diff_addition_word_style() -> Style {
|
|
Style::default()
|
|
.bg(Color::Green)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD)
|
|
}
|
|
|
|
fn session_state_label(state: &SessionState) -> &'static str {
|
|
match state {
|
|
SessionState::Pending => "Pending",
|
|
SessionState::Running => "Running",
|
|
SessionState::Idle => "Idle",
|
|
SessionState::Stale => "Stale",
|
|
SessionState::Completed => "Completed",
|
|
SessionState::Failed => "Failed",
|
|
SessionState::Stopped => "Stopped",
|
|
}
|
|
}
|
|
|
|
fn session_state_color(state: &SessionState) -> Color {
|
|
match state {
|
|
SessionState::Running => Color::Green,
|
|
SessionState::Idle => Color::Yellow,
|
|
SessionState::Stale => Color::LightRed,
|
|
SessionState::Failed => Color::Red,
|
|
SessionState::Stopped => Color::DarkGray,
|
|
SessionState::Completed => Color::Blue,
|
|
SessionState::Pending => Color::Reset,
|
|
}
|
|
}
|
|
|
|
fn file_activity_summary(entry: &FileActivityEntry) -> String {
|
|
let mut summary = format!(
|
|
"{} {}",
|
|
file_activity_verb(entry.action.clone()),
|
|
truncate_for_dashboard(&entry.path, 72)
|
|
);
|
|
|
|
if let Some(diff_preview) = entry.diff_preview.as_ref() {
|
|
summary.push_str(" | ");
|
|
summary.push_str(&truncate_for_dashboard(diff_preview, 56));
|
|
}
|
|
|
|
summary
|
|
}
|
|
|
|
fn file_activity_patch_lines(entry: &FileActivityEntry, max_lines: usize) -> Vec<String> {
|
|
entry
|
|
.patch_preview
|
|
.as_deref()
|
|
.map(|patch| {
|
|
patch
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty() && *line != "@@" && *line != "+" && *line != "-")
|
|
.take(max_lines)
|
|
.map(|line| truncate_for_dashboard(line, 72))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String {
|
|
format!(
|
|
"{} {} | {} {} as {} | {}",
|
|
file_activity_verb(entry.current_action.clone()),
|
|
truncate_for_dashboard(&entry.path, 48),
|
|
entry.other_session_state,
|
|
format_session_id(&entry.other_session_id),
|
|
file_activity_verb(entry.other_action.clone()),
|
|
timestamp
|
|
)
|
|
}
|
|
|
|
fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec<String> {
|
|
let mut lines = Vec::new();
|
|
if !entry.trigger_summary.trim().is_empty() {
|
|
lines.push(format!(
|
|
"why {}",
|
|
truncate_for_dashboard(&entry.trigger_summary, 72)
|
|
));
|
|
}
|
|
if entry.input_params_json.trim() != "{}" {
|
|
lines.push(format!(
|
|
"params {}",
|
|
truncate_for_dashboard(&entry.input_params_json, 72)
|
|
));
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
|
|
match action {
|
|
crate::session::FileActivityAction::Read => "read",
|
|
crate::session::FileActivityAction::Create => "create",
|
|
crate::session::FileActivityAction::Modify => "modify",
|
|
crate::session::FileActivityAction::Move => "move",
|
|
crate::session::FileActivityAction::Delete => "delete",
|
|
crate::session::FileActivityAction::Touch => "touch",
|
|
}
|
|
}
|
|
|
|
fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> String {
|
|
if !outcome.auto_terminated_sessions.is_empty() {
|
|
return format!(
|
|
"stale heartbeat detected | auto-terminated {} session(s)",
|
|
outcome.auto_terminated_sessions.len()
|
|
);
|
|
}
|
|
|
|
format!(
|
|
"stale heartbeat detected | flagged {} session(s) for attention",
|
|
outcome.stale_sessions.len()
|
|
)
|
|
}
|
|
|
|
fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String {
|
|
let cause = match (outcome.token_budget_exceeded, outcome.cost_budget_exceeded) {
|
|
(true, true) => "token and cost budgets exceeded",
|
|
(true, false) => "token budget exceeded",
|
|
(false, true) => "cost budget exceeded",
|
|
(false, false) => "budget exceeded",
|
|
};
|
|
|
|
format!(
|
|
"{cause} | auto-paused {} active session(s)",
|
|
outcome.paused_sessions.len()
|
|
)
|
|
}
|
|
|
|
fn format_session_id(id: &str) -> String {
|
|
id.chars().take(8).collect()
|
|
}
|
|
|
|
fn build_conflict_protocol(
|
|
session_id: &str,
|
|
worktree: &crate::session::WorktreeInfo,
|
|
merge_readiness: &worktree::MergeReadiness,
|
|
) -> Option<String> {
|
|
if merge_readiness.status != worktree::MergeReadinessStatus::Conflicted {
|
|
return None;
|
|
}
|
|
|
|
let mut lines = vec![
|
|
format!("Conflict protocol for {}", format_session_id(session_id)),
|
|
format!("Worktree {}", worktree.path.display()),
|
|
format!("Branch {} (base {})", worktree.branch, worktree.base_branch),
|
|
merge_readiness.summary.clone(),
|
|
];
|
|
|
|
if !merge_readiness.conflicts.is_empty() {
|
|
lines.push("Conflicts".to_string());
|
|
for conflict in &merge_readiness.conflicts {
|
|
lines.push(format!("- {conflict}"));
|
|
}
|
|
}
|
|
|
|
lines.push("Resolution steps".to_string());
|
|
lines.push(format!(
|
|
"1. Inspect current patch: ecc worktree-status {session_id} --patch"
|
|
));
|
|
lines.push(format!("2. Open worktree: cd {}", worktree.path.display()));
|
|
lines.push("3. Resolve conflicts and stage files: git add <paths>".to_string());
|
|
lines.push(format!(
|
|
"4. Commit the resolution on {}: git commit",
|
|
worktree.branch
|
|
));
|
|
lines.push(format!(
|
|
"5. Re-check readiness: ecc worktree-status {session_id} --check"
|
|
));
|
|
lines.push(format!(
|
|
"6. Merge when clear: ecc merge-worktree {session_id}"
|
|
));
|
|
|
|
Some(lines.join("\n"))
|
|
}
|
|
|
|
fn assignment_action_label(action: manager::AssignmentAction) -> &'static str {
|
|
match action {
|
|
manager::AssignmentAction::Spawned => "spawned",
|
|
manager::AssignmentAction::ReusedIdle => "reused idle",
|
|
manager::AssignmentAction::ReusedActive => "reused active",
|
|
manager::AssignmentAction::DeferredSaturated => "deferred saturated",
|
|
}
|
|
}
|
|
|
|
fn delegate_worktree_health_label(health: worktree::WorktreeHealth) -> &'static str {
|
|
match health {
|
|
worktree::WorktreeHealth::Clear => "clear",
|
|
worktree::WorktreeHealth::InProgress => "in progress",
|
|
worktree::WorktreeHealth::Conflicted => "conflicted",
|
|
}
|
|
}
|
|
|
|
fn delegate_next_action(delegate: &DelegatedChildSummary) -> &'static str {
|
|
if delegate.worktree_health == Some(worktree::WorktreeHealth::Conflicted) {
|
|
return "resolve conflict";
|
|
}
|
|
if delegate.approval_backlog > 0 {
|
|
return "review approvals";
|
|
}
|
|
if delegate.handoff_backlog > 0 && delegate.state == SessionState::Idle {
|
|
return "process handoff";
|
|
}
|
|
if delegate.handoff_backlog > 0 {
|
|
return "drain backlog";
|
|
}
|
|
if delegate.worktree_health == Some(worktree::WorktreeHealth::InProgress) {
|
|
return "finish worktree changes";
|
|
}
|
|
match delegate.state {
|
|
SessionState::Pending => "wait for startup",
|
|
SessionState::Running => "let it run",
|
|
SessionState::Idle => "assign next task",
|
|
SessionState::Stale => "inspect stale heartbeat",
|
|
SessionState::Failed => "inspect failure",
|
|
SessionState::Stopped => "resume or reassign",
|
|
SessionState::Completed => "merge or cleanup",
|
|
}
|
|
}
|
|
|
|
fn delegate_attention_priority(delegate: &DelegatedChildSummary) -> u8 {
|
|
if delegate.worktree_health == Some(worktree::WorktreeHealth::Conflicted) {
|
|
return 0;
|
|
}
|
|
if delegate.approval_backlog > 0 {
|
|
return 1;
|
|
}
|
|
if matches!(
|
|
delegate.state,
|
|
SessionState::Stale | SessionState::Failed | SessionState::Stopped
|
|
) {
|
|
return 2;
|
|
}
|
|
if delegate.handoff_backlog > 0 {
|
|
return 3;
|
|
}
|
|
if delegate.worktree_health == Some(worktree::WorktreeHealth::InProgress) {
|
|
return 4;
|
|
}
|
|
match delegate.state {
|
|
SessionState::Pending => 5,
|
|
SessionState::Running => 6,
|
|
SessionState::Idle => 7,
|
|
SessionState::Completed => 8,
|
|
SessionState::Stale | SessionState::Failed | SessionState::Stopped => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn session_branch(session: &Session) -> String {
|
|
session
|
|
.worktree
|
|
.as_ref()
|
|
.map(|worktree| worktree.branch.clone())
|
|
.unwrap_or_else(|| "-".to_string())
|
|
}
|
|
|
|
fn format_duration(duration_secs: u64) -> String {
|
|
let hours = duration_secs / 3600;
|
|
let minutes = (duration_secs % 3600) / 60;
|
|
let seconds = duration_secs % 60;
|
|
format!("{hours:02}:{minutes:02}:{seconds:02}")
|
|
}
|
|
|
|
fn metrics_file_signature(path: &std::path::Path) -> Option<(u64, u128)> {
|
|
let metadata = std::fs::metadata(path).ok()?;
|
|
let modified = metadata
|
|
.modified()
|
|
.ok()?
|
|
.duration_since(UNIX_EPOCH)
|
|
.ok()?
|
|
.as_nanos();
|
|
Some((metadata.len(), modified))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use anyhow::{Context, Result};
|
|
use chrono::Utc;
|
|
use ratatui::{backend::TestBackend, Terminal};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use uuid::Uuid;
|
|
|
|
use super::*;
|
|
use crate::config::{Config, PaneLayout, Theme};
|
|
|
|
#[test]
|
|
fn render_sessions_shows_summary_headers_and_selected_row() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session(
|
|
"run-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("feat/run"),
|
|
128,
|
|
15,
|
|
),
|
|
sample_session(
|
|
"done-87654321",
|
|
"reviewer",
|
|
SessionState::Completed,
|
|
Some("release/v1"),
|
|
2048,
|
|
125,
|
|
),
|
|
],
|
|
1,
|
|
);
|
|
dashboard.approval_queue_counts = HashMap::from([(String::from("run-12345678"), 2usize)]);
|
|
dashboard.approval_queue_preview = vec![SessionMessage {
|
|
id: 1,
|
|
from_session: "lead-12345678".to_string(),
|
|
to_session: "run-12345678".to_string(),
|
|
content: "{\"question\":\"Need approval to continue\"}".to_string(),
|
|
msg_type: "query".to_string(),
|
|
read: false,
|
|
timestamp: Utc::now(),
|
|
}];
|
|
|
|
let rendered = render_dashboard_text(dashboard, 220, 24);
|
|
assert!(rendered.contains("ID"));
|
|
assert!(rendered.contains("Project"));
|
|
assert!(rendered.contains("Group"));
|
|
assert!(rendered.contains("Branch"));
|
|
assert!(rendered.contains("Total 2"));
|
|
assert!(rendered.contains("Running 1"));
|
|
assert!(rendered.contains("Completed 1"));
|
|
assert!(rendered.contains("Approval queue"));
|
|
assert!(rendered.contains("done-876"));
|
|
}
|
|
|
|
#[test]
|
|
fn approval_queue_preview_line_uses_target_session_and_preview() {
|
|
let line = approval_queue_preview_line(&[SessionMessage {
|
|
id: 1,
|
|
from_session: "lead-12345678".to_string(),
|
|
to_session: "run-12345678".to_string(),
|
|
content: "{\"question\":\"Need approval to continue\"}".to_string(),
|
|
msg_type: "query".to_string(),
|
|
read: false,
|
|
timestamp: Utc::now(),
|
|
}])
|
|
.expect("approval preview line");
|
|
|
|
let rendered = line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>();
|
|
assert!(rendered.contains("run-123"));
|
|
assert!(rendered.contains("query"));
|
|
}
|
|
|
|
#[test]
|
|
fn sync_selected_messages_refreshes_approval_queue_after_marking_read() {
|
|
let sessions = vec![
|
|
sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
),
|
|
sample_session(
|
|
"worker-123456",
|
|
"reviewer",
|
|
SessionState::Idle,
|
|
Some("ecc/worker"),
|
|
64,
|
|
5,
|
|
),
|
|
];
|
|
let mut dashboard = test_dashboard(sessions, 1);
|
|
for session in &dashboard.sessions {
|
|
dashboard.db.insert_session(session).unwrap();
|
|
}
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-123456",
|
|
"{\"question\":\"Need operator input\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap();
|
|
|
|
dashboard.sync_selected_messages();
|
|
|
|
assert_eq!(dashboard.approval_queue_counts.get("worker-123456"), None);
|
|
assert!(dashboard.approval_queue_preview.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn focus_next_approval_target_selects_oldest_unread_target() {
|
|
let sessions = vec![
|
|
sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
),
|
|
sample_session(
|
|
"worker-a",
|
|
"reviewer",
|
|
SessionState::Idle,
|
|
Some("ecc/worker-a"),
|
|
64,
|
|
5,
|
|
),
|
|
sample_session(
|
|
"worker-b",
|
|
"reviewer",
|
|
SessionState::Idle,
|
|
Some("ecc/worker-b"),
|
|
64,
|
|
5,
|
|
),
|
|
];
|
|
let mut dashboard = test_dashboard(sessions, 0);
|
|
for session in &dashboard.sessions {
|
|
dashboard.db.insert_session(session).unwrap();
|
|
}
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-b",
|
|
"{\"question\":\"Need approval on B\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-a",
|
|
"{\"question\":\"Need approval on A\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard.sync_approval_queue();
|
|
|
|
dashboard.focus_next_approval_target();
|
|
|
|
assert_eq!(dashboard.selected_session_id(), Some("worker-b"));
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("focused approval target worker-b")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_next_approval_target_cycles_distinct_targets() {
|
|
let sessions = vec![
|
|
sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
),
|
|
sample_session(
|
|
"worker-a",
|
|
"reviewer",
|
|
SessionState::Idle,
|
|
Some("ecc/worker-a"),
|
|
64,
|
|
5,
|
|
),
|
|
sample_session(
|
|
"worker-b",
|
|
"reviewer",
|
|
SessionState::Idle,
|
|
Some("ecc/worker-b"),
|
|
64,
|
|
5,
|
|
),
|
|
];
|
|
let mut dashboard = test_dashboard(sessions, 1);
|
|
for session in &dashboard.sessions {
|
|
dashboard.db.insert_session(session).unwrap();
|
|
}
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-a",
|
|
"{\"question\":\"Need approval on A\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-a",
|
|
"{\"question\":\"Need another approval on A\"}",
|
|
"conflict",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-b",
|
|
"{\"question\":\"Need approval on B\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard.sync_approval_queue();
|
|
|
|
dashboard.focus_next_approval_target();
|
|
|
|
assert_eq!(dashboard.selected_session_id(), Some("worker-b"));
|
|
assert_eq!(dashboard.approval_queue_counts.get("worker-a"), Some(&2));
|
|
assert_eq!(dashboard.approval_queue_counts.get("worker-b"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_next_approval_target_reports_clear_queue() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
|
|
dashboard.focus_next_approval_target();
|
|
|
|
assert_eq!(dashboard.selected_session_id(), Some("lead-12345678"));
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("approval queue clear")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
),
|
|
sample_session(
|
|
"failed-87654321",
|
|
"reviewer",
|
|
SessionState::Failed,
|
|
Some("ecc/failed"),
|
|
64,
|
|
5,
|
|
),
|
|
],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "last useful output")],
|
|
);
|
|
dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string());
|
|
dashboard.selected_diff_preview = vec![
|
|
"Branch M src/main.rs".to_string(),
|
|
"Working ?? notes.txt".to_string(),
|
|
];
|
|
dashboard.selected_merge_readiness = Some(worktree::MergeReadiness {
|
|
status: worktree::MergeReadinessStatus::Conflicted,
|
|
summary: "Merge blocked by 1 conflict(s): src/main.rs".to_string(),
|
|
conflicts: vec!["src/main.rs".to_string()],
|
|
});
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Branch ecc/focus | Base main"));
|
|
assert!(text.contains("Worktree /tmp/ecc/focus"));
|
|
assert!(text.contains("Diff 1 file changed, 2 insertions(+)"));
|
|
assert!(text.contains("Changed files"));
|
|
assert!(text.contains("- Branch M src/main.rs"));
|
|
assert!(text.contains("- Working ?? notes.txt"));
|
|
assert!(text.contains("Merge blocked by 1 conflict(s): src/main.rs"));
|
|
assert!(text.contains("- conflict src/main.rs"));
|
|
assert!(text.contains("Tokens 512 total | In 384 | Out 128"));
|
|
assert!(text.contains("Last output last useful output"));
|
|
assert!(text.contains("Needs attention:"));
|
|
assert!(text.contains("Failed failed-8 | Render dashboard rows"));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_output_mode_switches_to_worktree_diff_preview() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.selected_diff_summary = Some("1 file changed".to_string());
|
|
dashboard.selected_diff_patch = Some(
|
|
"--- Branch diff vs main ---\ndiff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1 @@\n-old line\n+new line".to_string(),
|
|
);
|
|
|
|
dashboard.toggle_output_mode();
|
|
|
|
assert_eq!(dashboard.output_mode, OutputMode::WorktreeDiff);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("showing selected worktree diff")
|
|
);
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("Diff"));
|
|
assert!(rendered.contains("Removals"));
|
|
assert!(rendered.contains("Additions"));
|
|
assert!(rendered.contains("-old line"));
|
|
assert!(rendered.contains("+new line"));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_git_status_mode_renders_selected_worktree_status() -> Result<()> {
|
|
let root = std::env::temp_dir().join(format!("ecc2-git-status-{}", Uuid::new_v4()));
|
|
init_git_repo(&root)?;
|
|
fs::write(root.join("README.md"), "hello from git status\n")?;
|
|
|
|
let mut session = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
session.working_dir = root.clone();
|
|
session.worktree = Some(WorktreeInfo {
|
|
path: root.clone(),
|
|
branch: "main".to_string(),
|
|
base_branch: "main".to_string(),
|
|
});
|
|
let mut dashboard = test_dashboard(vec![session], 0);
|
|
|
|
dashboard.toggle_git_status_mode();
|
|
|
|
assert_eq!(dashboard.output_mode, OutputMode::GitStatus);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("showing selected worktree git status")
|
|
);
|
|
assert_eq!(dashboard.output_title(), " Git status staged:0 unstaged:1 1/1 ");
|
|
let rendered = dashboard.rendered_output_text(180, 20);
|
|
assert!(rendered.contains("Git status"));
|
|
assert!(rendered.contains("README.md"));
|
|
|
|
let _ = fs::remove_dir_all(root);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn begin_commit_prompt_opens_commit_input_for_staged_entries() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.output_mode = OutputMode::GitStatus;
|
|
dashboard.selected_git_status_entries = vec![worktree::GitStatusEntry {
|
|
path: "README.md".to_string(),
|
|
display_path: "README.md".to_string(),
|
|
index_status: 'M',
|
|
worktree_status: ' ',
|
|
staged: true,
|
|
unstaged: false,
|
|
untracked: false,
|
|
conflicted: false,
|
|
}];
|
|
|
|
dashboard.begin_commit_prompt();
|
|
|
|
assert_eq!(dashboard.commit_input.as_deref(), Some(""));
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("commit mode | type a message and press Enter")
|
|
);
|
|
let rendered = render_dashboard_text(dashboard, 180, 20);
|
|
assert!(rendered.contains("commit>_"));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_diff_view_mode_switches_to_unified_rendering() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
let patch = "--- Branch diff vs main ---\n\
|
|
diff --git a/src/lib.rs b/src/lib.rs\n\
|
|
@@ -1 +1 @@\n\
|
|
-old line\n\
|
|
+new line"
|
|
.to_string();
|
|
dashboard.selected_diff_summary = Some("1 file changed".to_string());
|
|
dashboard.selected_diff_patch = Some(patch.clone());
|
|
dashboard.selected_diff_hunk_offsets_split =
|
|
build_worktree_diff_columns(&patch, dashboard.theme_palette()).hunk_offsets;
|
|
dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch);
|
|
dashboard.toggle_output_mode();
|
|
|
|
dashboard.toggle_diff_view_mode();
|
|
|
|
assert_eq!(dashboard.diff_view_mode, DiffViewMode::Unified);
|
|
assert_eq!(dashboard.output_title(), " Diff unified 1/1 ");
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("diff view set to unified")
|
|
);
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("Diff unified 1/1"));
|
|
assert!(rendered.contains("@@ -1 +1 @@"));
|
|
assert!(rendered.contains("-old line"));
|
|
assert!(rendered.contains("+new line"));
|
|
assert!(!rendered.contains("Removals"));
|
|
assert!(!rendered.contains("Additions"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_hunk_navigation_updates_scroll_offset_and_wraps() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
let patch = "--- Branch diff vs main ---\n\
|
|
diff --git a/src/lib.rs b/src/lib.rs\n\
|
|
@@ -1 +1 @@\n\
|
|
-old line\n\
|
|
+new line\n\
|
|
@@ -5 +5 @@\n\
|
|
-second old\n\
|
|
+second new"
|
|
.to_string();
|
|
dashboard.selected_diff_patch = Some(patch.clone());
|
|
let split_offsets =
|
|
build_worktree_diff_columns(&patch, dashboard.theme_palette()).hunk_offsets;
|
|
dashboard.selected_diff_hunk_offsets_split = split_offsets.clone();
|
|
dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch);
|
|
dashboard.output_mode = OutputMode::WorktreeDiff;
|
|
|
|
dashboard.next_diff_hunk();
|
|
assert_eq!(dashboard.selected_diff_hunk, 1);
|
|
assert_eq!(dashboard.output_scroll_offset, split_offsets[1]);
|
|
assert_eq!(dashboard.output_title(), " Diff split 2/2 ");
|
|
assert_eq!(dashboard.operator_note.as_deref(), Some("diff hunk 2/2"));
|
|
|
|
dashboard.next_diff_hunk();
|
|
assert_eq!(dashboard.selected_diff_hunk, 0);
|
|
assert_eq!(dashboard.output_scroll_offset, split_offsets[0]);
|
|
assert_eq!(dashboard.output_title(), " Diff split 1/2 ");
|
|
assert_eq!(dashboard.operator_note.as_deref(), Some("diff hunk 1/2"));
|
|
|
|
dashboard.prev_diff_hunk();
|
|
assert_eq!(dashboard.selected_diff_hunk, 1);
|
|
assert_eq!(dashboard.output_scroll_offset, split_offsets[1]);
|
|
assert_eq!(dashboard.operator_note.as_deref(), Some("diff hunk 2/2"));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_timeline_mode_renders_selected_session_events() {
|
|
let now = Utc::now();
|
|
let mut session = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
session.created_at = now - chrono::Duration::hours(2);
|
|
session.updated_at = now - chrono::Duration::minutes(5);
|
|
session.metrics.files_changed = 3;
|
|
|
|
let mut dashboard = test_dashboard(vec![session.clone()], 0);
|
|
dashboard.db.insert_session(&session).unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"focus-12345678",
|
|
"{\"question\":\"Need review\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.insert_tool_log(
|
|
"focus-12345678",
|
|
"bash",
|
|
"cargo test -q",
|
|
"{\"command\":\"cargo test -q\"}",
|
|
"ok",
|
|
"stabilize planner session",
|
|
240,
|
|
0.2,
|
|
&(now - chrono::Duration::minutes(3)).to_rfc3339(),
|
|
)
|
|
.unwrap();
|
|
|
|
dashboard.toggle_timeline_mode();
|
|
|
|
assert_eq!(dashboard.output_mode, OutputMode::Timeline);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("showing selected session timeline")
|
|
);
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("Timeline"));
|
|
assert!(rendered.contains("created session as planner"));
|
|
assert!(rendered.contains("received query lead-123"));
|
|
assert!(rendered.contains("tool bash"));
|
|
assert!(rendered.contains("why stabilize planner session"));
|
|
assert!(rendered.contains("params {\"command\":\"cargo test -q\"}"));
|
|
assert!(rendered.contains("files touched 3"));
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_timeline_event_filter_limits_rendered_events() {
|
|
let now = Utc::now();
|
|
let mut session = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
session.created_at = now - chrono::Duration::hours(2);
|
|
session.updated_at = now - chrono::Duration::minutes(5);
|
|
session.metrics.files_changed = 1;
|
|
|
|
let mut dashboard = test_dashboard(vec![session.clone()], 0);
|
|
dashboard.db.insert_session(&session).unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"focus-12345678",
|
|
"{\"question\":\"Need review\"}",
|
|
"query",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.insert_tool_log(
|
|
"focus-12345678",
|
|
"bash",
|
|
"cargo test -q",
|
|
"{}",
|
|
"ok",
|
|
"",
|
|
240,
|
|
0.2,
|
|
&(now - chrono::Duration::minutes(3)).to_rfc3339(),
|
|
)
|
|
.unwrap();
|
|
dashboard.toggle_timeline_mode();
|
|
|
|
dashboard.cycle_timeline_event_filter();
|
|
dashboard.cycle_timeline_event_filter();
|
|
|
|
assert_eq!(
|
|
dashboard.timeline_event_filter,
|
|
TimelineEventFilter::Messages
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("timeline filter set to messages")
|
|
);
|
|
assert_eq!(dashboard.output_title(), " Timeline messages ");
|
|
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("received query lead-123"));
|
|
assert!(!rendered.contains("tool bash"));
|
|
assert!(!rendered.contains("files touched 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn timeline_and_metrics_render_recent_file_activity_details() -> Result<()> {
|
|
let root = std::env::temp_dir().join(format!("ecc2-file-activity-{}", Uuid::new_v4()));
|
|
fs::create_dir_all(&root)?;
|
|
let now = Utc::now();
|
|
let mut session = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
session.created_at = now - chrono::Duration::hours(2);
|
|
session.updated_at = now - chrono::Duration::minutes(5);
|
|
|
|
let mut dashboard = test_dashboard(vec![session.clone()], 0);
|
|
dashboard.db.insert_session(&session)?;
|
|
|
|
let metrics_path = root.join("tool-usage.jsonl");
|
|
fs::write(
|
|
&metrics_path,
|
|
concat!(
|
|
"{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
|
"{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ # ECC 2.0\",\"patch_preview\":\"+ # ECC 2.0\\n+ \\n+ A richer dashboard\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
|
),
|
|
)?;
|
|
dashboard.db.sync_tool_activity_metrics(&metrics_path)?;
|
|
dashboard.sync_from_store();
|
|
|
|
dashboard.toggle_timeline_mode();
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("read src/lib.rs"));
|
|
assert!(rendered.contains("create README.md"));
|
|
assert!(rendered.contains("+ # ECC 2.0"));
|
|
assert!(rendered.contains("+ A richer dashboard"));
|
|
assert!(!rendered.contains("files touched 2"));
|
|
|
|
let metrics_text = dashboard.selected_session_metrics_text();
|
|
assert!(metrics_text.contains("Recent file activity"));
|
|
assert!(metrics_text.contains("create README.md"));
|
|
assert!(metrics_text.contains("+ # ECC 2.0"));
|
|
assert!(metrics_text.contains("+ A richer dashboard"));
|
|
assert!(metrics_text.contains("read src/lib.rs"));
|
|
|
|
let _ = fs::remove_dir_all(root);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_text_surfaces_file_activity_overlaps() -> Result<()> {
|
|
let root = std::env::temp_dir().join(format!("ecc2-file-overlaps-{}", Uuid::new_v4()));
|
|
fs::create_dir_all(&root)?;
|
|
let now = Utc::now();
|
|
let mut focus = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
focus.created_at = now - chrono::Duration::hours(1);
|
|
focus.updated_at = now - chrono::Duration::minutes(3);
|
|
|
|
let mut delegate = sample_session(
|
|
"delegate-87654321",
|
|
"coder",
|
|
SessionState::Idle,
|
|
Some("ecc/delegate"),
|
|
256,
|
|
12,
|
|
);
|
|
delegate.created_at = now - chrono::Duration::minutes(50);
|
|
delegate.updated_at = now - chrono::Duration::minutes(2);
|
|
|
|
let mut dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0);
|
|
dashboard.db.insert_session(&focus)?;
|
|
dashboard.db.insert_session(&delegate)?;
|
|
|
|
let metrics_path = root.join("tool-usage.jsonl");
|
|
fs::write(
|
|
&metrics_path,
|
|
concat!(
|
|
"{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
|
"{\"id\":\"evt-2\",\"session_id\":\"delegate-87654321\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
|
),
|
|
)?;
|
|
dashboard.db.sync_tool_activity_metrics(&metrics_path)?;
|
|
dashboard.sync_from_store();
|
|
|
|
let metrics_text = dashboard.selected_session_metrics_text();
|
|
assert!(metrics_text.contains("Potential overlaps"));
|
|
assert!(metrics_text.contains("modify src/lib.rs"));
|
|
assert!(metrics_text.contains("idle delegate"));
|
|
assert!(metrics_text.contains("as modify"));
|
|
|
|
let _ = fs::remove_dir_all(root);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn timeline_time_filter_hides_old_events() {
|
|
let now = Utc::now();
|
|
let mut session = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
session.created_at = now - chrono::Duration::hours(3);
|
|
session.updated_at = now - chrono::Duration::hours(2);
|
|
|
|
let mut dashboard = test_dashboard(vec![session.clone()], 0);
|
|
dashboard.db.insert_session(&session).unwrap();
|
|
dashboard
|
|
.db
|
|
.insert_tool_log(
|
|
"focus-12345678",
|
|
"bash",
|
|
"cargo test -q",
|
|
"{}",
|
|
"ok",
|
|
"",
|
|
240,
|
|
0.2,
|
|
&(now - chrono::Duration::minutes(3)).to_rfc3339(),
|
|
)
|
|
.unwrap();
|
|
dashboard.toggle_timeline_mode();
|
|
|
|
dashboard.cycle_output_time_filter();
|
|
dashboard.cycle_output_time_filter();
|
|
|
|
assert_eq!(dashboard.output_time_filter, OutputTimeFilter::LastHour);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("timeline range set to last 1h")
|
|
);
|
|
assert_eq!(dashboard.output_title(), " Timeline last 1h ");
|
|
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("tool bash"));
|
|
assert!(!rendered.contains("created session as planner"));
|
|
assert!(!rendered.contains("state running"));
|
|
}
|
|
|
|
#[test]
|
|
fn timeline_scope_all_sessions_renders_cross_session_events() {
|
|
let now = Utc::now();
|
|
let mut focus = sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
);
|
|
focus.created_at = now - chrono::Duration::hours(2);
|
|
focus.updated_at = now - chrono::Duration::minutes(5);
|
|
|
|
let mut review = sample_session(
|
|
"review-87654321",
|
|
"reviewer",
|
|
SessionState::Idle,
|
|
Some("ecc/review"),
|
|
256,
|
|
12,
|
|
);
|
|
review.created_at = now - chrono::Duration::hours(1);
|
|
review.updated_at = now - chrono::Duration::minutes(3);
|
|
review.metrics.files_changed = 2;
|
|
|
|
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
|
|
dashboard.db.insert_session(&focus).unwrap();
|
|
dashboard.db.insert_session(&review).unwrap();
|
|
dashboard
|
|
.db
|
|
.insert_tool_log(
|
|
"focus-12345678",
|
|
"bash",
|
|
"cargo test -q",
|
|
"{}",
|
|
"ok",
|
|
"",
|
|
240,
|
|
0.2,
|
|
&(now - chrono::Duration::minutes(4)).to_rfc3339(),
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.insert_tool_log(
|
|
"review-87654321",
|
|
"git",
|
|
"git status --short",
|
|
"{}",
|
|
"ok",
|
|
"",
|
|
120,
|
|
0.1,
|
|
&(now - chrono::Duration::minutes(2)).to_rfc3339(),
|
|
)
|
|
.unwrap();
|
|
dashboard.toggle_timeline_mode();
|
|
|
|
dashboard.toggle_search_scope();
|
|
|
|
assert_eq!(dashboard.timeline_scope, SearchScope::AllSessions);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("timeline scope set to all sessions")
|
|
);
|
|
assert_eq!(dashboard.output_title(), " Timeline all sessions ");
|
|
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("focus-12"));
|
|
assert!(rendered.contains("review-8"));
|
|
assert!(rendered.contains("tool bash"));
|
|
assert!(rendered.contains("tool git"));
|
|
}
|
|
|
|
#[test]
|
|
fn worktree_diff_columns_split_removed_and_added_lines() {
|
|
let patch = "\
|
|
--- Branch diff vs main ---
|
|
diff --git a/src/lib.rs b/src/lib.rs
|
|
@@ -1,2 +1,2 @@
|
|
-old line
|
|
context
|
|
+new line
|
|
|
|
--- Working tree diff ---
|
|
diff --git a/src/next.rs b/src/next.rs
|
|
@@ -3 +3 @@
|
|
-bye
|
|
+hello";
|
|
|
|
let palette = test_dashboard(Vec::new(), 0).theme_palette();
|
|
let columns = build_worktree_diff_columns(patch, palette);
|
|
let removals = text_plain_text(&columns.removals);
|
|
let additions = text_plain_text(&columns.additions);
|
|
assert!(removals.contains("Branch diff vs main"));
|
|
assert!(removals.contains("-old line"));
|
|
assert!(removals.contains("-bye"));
|
|
assert!(additions.contains("Working tree diff"));
|
|
assert!(additions.contains("+new line"));
|
|
assert!(additions.contains("+hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn split_diff_highlights_changed_words() {
|
|
let palette = test_dashboard(Vec::new(), 0).theme_palette();
|
|
let patch = "\
|
|
diff --git a/src/lib.rs b/src/lib.rs
|
|
@@ -1 +1 @@
|
|
-old line
|
|
+new line";
|
|
|
|
let columns = build_worktree_diff_columns(patch, palette);
|
|
let removal = columns
|
|
.removals
|
|
.lines
|
|
.iter()
|
|
.find(|line| line_plain_text(line) == "-old line")
|
|
.expect("removal line");
|
|
let addition = columns
|
|
.additions
|
|
.lines
|
|
.iter()
|
|
.find(|line| line_plain_text(line) == "+new line")
|
|
.expect("addition line");
|
|
|
|
assert_eq!(removal.spans[1].content.as_ref(), "old");
|
|
assert_eq!(removal.spans[1].style, diff_removal_word_style());
|
|
assert_eq!(removal.spans[2].content.as_ref(), " ");
|
|
assert_eq!(removal.spans[2].style, diff_removal_style(palette));
|
|
assert_eq!(addition.spans[1].content.as_ref(), "new");
|
|
assert_eq!(addition.spans[1].style, diff_addition_word_style());
|
|
}
|
|
|
|
#[test]
|
|
fn unified_diff_highlights_changed_words() {
|
|
let palette = test_dashboard(Vec::new(), 0).theme_palette();
|
|
let patch = "\
|
|
diff --git a/src/lib.rs b/src/lib.rs
|
|
@@ -1 +1 @@
|
|
-old line
|
|
+new line";
|
|
|
|
let text = build_unified_diff_text(patch, palette);
|
|
let removal = text
|
|
.lines
|
|
.iter()
|
|
.find(|line| line_plain_text(line) == "-old line")
|
|
.expect("removal line");
|
|
let addition = text
|
|
.lines
|
|
.iter()
|
|
.find(|line| line_plain_text(line) == "+new line")
|
|
.expect("addition line");
|
|
|
|
assert_eq!(removal.spans[1].content.as_ref(), "old");
|
|
assert_eq!(removal.spans[1].style, diff_removal_word_style());
|
|
assert_eq!(addition.spans[1].content.as_ref(), "new");
|
|
assert_eq!(addition.spans[1].style, diff_addition_word_style());
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_conflict_protocol_mode_switches_to_protocol_view() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.selected_merge_readiness = Some(worktree::MergeReadiness {
|
|
status: worktree::MergeReadinessStatus::Conflicted,
|
|
summary: "Merge blocked by 1 conflict(s): src/main.rs".to_string(),
|
|
conflicts: vec!["src/main.rs".to_string()],
|
|
});
|
|
dashboard.selected_conflict_protocol = Some(
|
|
"Conflict protocol for focus-12\nResolution steps\n1. Inspect current patch: ecc worktree-status focus-12345678 --patch"
|
|
.to_string(),
|
|
);
|
|
|
|
dashboard.toggle_conflict_protocol_mode();
|
|
|
|
assert_eq!(dashboard.output_mode, OutputMode::ConflictProtocol);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("showing worktree conflict protocol")
|
|
);
|
|
let rendered = dashboard.rendered_output_text(180, 30);
|
|
assert!(rendered.contains("Conflict Protocol"));
|
|
assert!(rendered.contains("Resolution steps"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_includes_team_capacity_summary() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.selected_team_summary = Some(TeamSummary {
|
|
total: 3,
|
|
idle: 1,
|
|
running: 1,
|
|
pending: 1,
|
|
stale: 0,
|
|
failed: 0,
|
|
stopped: 0,
|
|
});
|
|
dashboard.global_handoff_backlog_leads = 2;
|
|
dashboard.global_handoff_backlog_messages = 5;
|
|
dashboard.selected_route_preview = Some("reuse idle worker-1".to_string());
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Team 3/8 | idle 1 | running 1 | pending 1 | failed 0 | stopped 0"));
|
|
assert!(text.contains(
|
|
"Global handoff backlog 2 lead(s) / 5 handoff(s) | Auto-dispatch off @ 5/lead | Auto-worktree on | Auto-merge off"
|
|
));
|
|
assert!(text.contains("Coordination mode dispatch-first"));
|
|
assert!(text.contains("Next route reuse idle worker-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_includes_delegate_task_board() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.selected_child_sessions = vec![DelegatedChildSummary {
|
|
session_id: "delegate-12345678".to_string(),
|
|
state: SessionState::Running,
|
|
worktree_health: Some(worktree::WorktreeHealth::Conflicted),
|
|
approval_backlog: 1,
|
|
handoff_backlog: 2,
|
|
tokens_used: 1_280,
|
|
files_changed: 3,
|
|
duration_secs: 12,
|
|
task_preview: "Implement rust tui delegate board".to_string(),
|
|
branch: Some("ecc/delegate-12345678".to_string()),
|
|
last_output_preview: Some("Investigating pane selection behavior".to_string()),
|
|
}];
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(
|
|
text.contains(
|
|
"- delegate [Running] | next resolve conflict | worktree conflicted | approvals 1 | backlog 2 | progress 1,280 tok / 3 files / 00:00:12 | task Implement rust tui delegate board | branch ecc/delegate-12345678"
|
|
)
|
|
);
|
|
assert!(text.contains(" last output Investigating pane selection behavior"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_marks_focused_delegate_row() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.selected_child_sessions = vec![
|
|
DelegatedChildSummary {
|
|
session_id: "delegate-12345678".to_string(),
|
|
state: SessionState::Running,
|
|
worktree_health: None,
|
|
approval_backlog: 0,
|
|
handoff_backlog: 0,
|
|
tokens_used: 128,
|
|
files_changed: 1,
|
|
duration_secs: 5,
|
|
task_preview: "First delegate".to_string(),
|
|
branch: None,
|
|
last_output_preview: None,
|
|
},
|
|
DelegatedChildSummary {
|
|
session_id: "delegate-22345678".to_string(),
|
|
state: SessionState::Idle,
|
|
worktree_health: Some(worktree::WorktreeHealth::InProgress),
|
|
approval_backlog: 1,
|
|
handoff_backlog: 2,
|
|
tokens_used: 64,
|
|
files_changed: 2,
|
|
duration_secs: 10,
|
|
task_preview: "Second delegate".to_string(),
|
|
branch: Some("ecc/delegate-22345678".to_string()),
|
|
last_output_preview: Some("Waiting on approval".to_string()),
|
|
},
|
|
];
|
|
dashboard.focused_delegate_session_id = Some("delegate-22345678".to_string());
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("- delegate [Running] | next let it run"));
|
|
assert!(text.contains(
|
|
">> delegate [Idle] | next review approvals | worktree in progress | approvals 1 | backlog 2 | progress 64 tok / 2 files / 00:00:10 | task Second delegate | branch ecc/delegate-22345678"
|
|
));
|
|
assert!(text.contains(" last output Waiting on approval"));
|
|
}
|
|
|
|
#[test]
|
|
fn focus_next_delegate_wraps_across_delegate_board() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.selected_child_sessions = vec![
|
|
DelegatedChildSummary {
|
|
session_id: "delegate-12345678".to_string(),
|
|
state: SessionState::Running,
|
|
worktree_health: None,
|
|
approval_backlog: 0,
|
|
handoff_backlog: 0,
|
|
tokens_used: 128,
|
|
files_changed: 1,
|
|
duration_secs: 5,
|
|
task_preview: "First delegate".to_string(),
|
|
branch: None,
|
|
last_output_preview: None,
|
|
},
|
|
DelegatedChildSummary {
|
|
session_id: "delegate-22345678".to_string(),
|
|
state: SessionState::Idle,
|
|
worktree_health: None,
|
|
approval_backlog: 0,
|
|
handoff_backlog: 0,
|
|
tokens_used: 64,
|
|
files_changed: 2,
|
|
duration_secs: 10,
|
|
task_preview: "Second delegate".to_string(),
|
|
branch: None,
|
|
last_output_preview: None,
|
|
},
|
|
];
|
|
dashboard.focused_delegate_session_id = Some("delegate-12345678".to_string());
|
|
|
|
dashboard.focus_next_delegate();
|
|
assert_eq!(
|
|
dashboard.focused_delegate_session_id.as_deref(),
|
|
Some("delegate-22345678")
|
|
);
|
|
|
|
dashboard.focus_next_delegate();
|
|
assert_eq!(
|
|
dashboard.focused_delegate_session_id.as_deref(),
|
|
Some("delegate-12345678")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_focused_delegate_switches_selected_session() {
|
|
let sessions = vec![
|
|
sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
),
|
|
sample_session(
|
|
"delegate-12345678",
|
|
"claude",
|
|
SessionState::Running,
|
|
Some("ecc/delegate"),
|
|
256,
|
|
12,
|
|
),
|
|
];
|
|
let mut dashboard = test_dashboard(sessions, 0);
|
|
dashboard.selected_child_sessions = vec![DelegatedChildSummary {
|
|
session_id: "delegate-12345678".to_string(),
|
|
state: SessionState::Running,
|
|
worktree_health: Some(worktree::WorktreeHealth::InProgress),
|
|
approval_backlog: 1,
|
|
handoff_backlog: 0,
|
|
tokens_used: 256,
|
|
files_changed: 2,
|
|
duration_secs: 12,
|
|
task_preview: "Investigate focused delegate navigation".to_string(),
|
|
branch: Some("ecc/delegate".to_string()),
|
|
last_output_preview: Some("Reviewing lead metrics".to_string()),
|
|
}];
|
|
dashboard.focused_delegate_session_id = Some("delegate-12345678".to_string());
|
|
dashboard.output_follow = false;
|
|
dashboard.output_scroll_offset = 9;
|
|
dashboard.metrics_scroll_offset = 4;
|
|
|
|
dashboard.open_focused_delegate();
|
|
|
|
assert_eq!(dashboard.selected_session_id(), Some("delegate-12345678"));
|
|
assert!(dashboard.output_follow);
|
|
assert_eq!(dashboard.output_scroll_offset, 0);
|
|
assert_eq!(dashboard.metrics_scroll_offset, 0);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("opened delegate delegate")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_shows_worktree_and_auto_merge_policy_state() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.cfg.auto_dispatch_unread_handoffs = true;
|
|
dashboard.cfg.auto_create_worktrees = false;
|
|
dashboard.cfg.auto_merge_ready_worktrees = true;
|
|
dashboard.global_handoff_backlog_leads = 1;
|
|
dashboard.global_handoff_backlog_messages = 2;
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains(
|
|
"Global handoff backlog 1 lead(s) / 2 handoff(s) | Auto-dispatch on @ 5/lead | Auto-worktree off | Auto-merge on"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_auto_worktree_policy_persists_config() {
|
|
let tempdir = std::env::temp_dir().join(format!("ecc2-worktree-policy-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&tempdir).unwrap();
|
|
let previous_home = std::env::var_os("HOME");
|
|
std::env::set_var("HOME", &tempdir);
|
|
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.cfg.auto_create_worktrees = true;
|
|
|
|
dashboard.toggle_auto_worktree_policy();
|
|
|
|
assert!(!dashboard.cfg.auto_create_worktrees);
|
|
let expected_note = format!(
|
|
"default worktree creation disabled | saved to {}",
|
|
crate::config::Config::config_path().display()
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some(expected_note.as_str())
|
|
);
|
|
|
|
let saved = std::fs::read_to_string(crate::config::Config::config_path()).unwrap();
|
|
assert!(saved.contains("auto_create_worktrees = false"));
|
|
|
|
if let Some(home) = previous_home {
|
|
std::env::set_var("HOME", home);
|
|
} else {
|
|
std::env::remove_var("HOME");
|
|
}
|
|
let _ = std::fs::remove_dir_all(tempdir);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_includes_daemon_activity() {
|
|
let now = Utc::now();
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(now),
|
|
last_dispatch_routed: 4,
|
|
last_dispatch_deferred: 2,
|
|
last_dispatch_leads: 2,
|
|
chronic_saturation_streak: 0,
|
|
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
|
|
last_recovery_dispatch_routed: 1,
|
|
last_recovery_dispatch_leads: 1,
|
|
last_rebalance_at: Some(now + chrono::Duration::seconds(2)),
|
|
last_rebalance_rerouted: 1,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: Some(now + chrono::Duration::seconds(3)),
|
|
last_auto_merge_merged: 2,
|
|
last_auto_merge_active_skipped: 1,
|
|
last_auto_merge_conflicted_skipped: 1,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: Some(now + chrono::Duration::seconds(4)),
|
|
last_auto_prune_pruned: 3,
|
|
last_auto_prune_active_skipped: 1,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Coordination mode dispatch-first"));
|
|
assert!(text.contains("Chronic saturation cleared @"));
|
|
assert!(text.contains("Last daemon dispatch 4 routed / 2 deferred across 2 lead(s)"));
|
|
assert!(text.contains("Last daemon recovery dispatch 1 handoff(s) across 1 lead(s)"));
|
|
assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)"));
|
|
assert!(text.contains(
|
|
"Last daemon auto-merge 2 merged / 1 active / 1 conflicted / 0 dirty / 0 failed"
|
|
));
|
|
assert!(text.contains("Last daemon auto-prune 3 pruned / 1 active"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_shows_rebalance_first_mode_when_saturation_is_unrecovered() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(Utc::now()),
|
|
last_dispatch_routed: 0,
|
|
last_dispatch_deferred: 1,
|
|
last_dispatch_leads: 1,
|
|
chronic_saturation_streak: 1,
|
|
last_recovery_dispatch_at: None,
|
|
last_recovery_dispatch_routed: 0,
|
|
last_recovery_dispatch_leads: 0,
|
|
last_rebalance_at: Some(Utc::now()),
|
|
last_rebalance_rerouted: 1,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: None,
|
|
last_auto_merge_merged: 0,
|
|
last_auto_merge_active_skipped: 0,
|
|
last_auto_merge_conflicted_skipped: 0,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: None,
|
|
last_auto_prune_pruned: 0,
|
|
last_auto_prune_active_skipped: 0,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Coordination mode rebalance-first (chronic saturation)"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_shows_rebalance_cooloff_mode_when_saturation_is_chronic() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(Utc::now()),
|
|
last_dispatch_routed: 0,
|
|
last_dispatch_deferred: 3,
|
|
last_dispatch_leads: 1,
|
|
chronic_saturation_streak: 3,
|
|
last_recovery_dispatch_at: None,
|
|
last_recovery_dispatch_routed: 0,
|
|
last_recovery_dispatch_leads: 0,
|
|
last_rebalance_at: Some(Utc::now()),
|
|
last_rebalance_rerouted: 1,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: None,
|
|
last_auto_merge_merged: 0,
|
|
last_auto_merge_active_skipped: 0,
|
|
last_auto_merge_conflicted_skipped: 0,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: None,
|
|
last_auto_prune_pruned: 0,
|
|
last_auto_prune_active_skipped: 0,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Coordination mode rebalance-cooloff (chronic saturation)"));
|
|
assert!(text.contains("Chronic saturation streak 3 cycle(s)"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck(
|
|
) {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(Utc::now()),
|
|
last_dispatch_routed: 0,
|
|
last_dispatch_deferred: 2,
|
|
last_dispatch_leads: 1,
|
|
chronic_saturation_streak: 5,
|
|
last_recovery_dispatch_at: None,
|
|
last_recovery_dispatch_routed: 0,
|
|
last_recovery_dispatch_leads: 0,
|
|
last_rebalance_at: Some(Utc::now()),
|
|
last_rebalance_rerouted: 0,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: None,
|
|
last_auto_merge_merged: 0,
|
|
last_auto_merge_active_skipped: 0,
|
|
last_auto_merge_conflicted_skipped: 0,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: None,
|
|
last_auto_prune_pruned: 0,
|
|
last_auto_prune_active_skipped: 0,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(
|
|
text.contains("Operator escalation recommended: chronic saturation is not clearing")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_session_metrics_text_shows_stabilized_dispatch_mode_after_recovery() {
|
|
let now = Utc::now();
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(now + chrono::Duration::seconds(2)),
|
|
last_dispatch_routed: 2,
|
|
last_dispatch_deferred: 0,
|
|
last_dispatch_leads: 1,
|
|
chronic_saturation_streak: 0,
|
|
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
|
|
last_recovery_dispatch_routed: 1,
|
|
last_recovery_dispatch_leads: 1,
|
|
last_rebalance_at: Some(now),
|
|
last_rebalance_rerouted: 1,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: None,
|
|
last_auto_merge_merged: 0,
|
|
last_auto_merge_active_skipped: 0,
|
|
last_auto_merge_conflicted_skipped: 0,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: None,
|
|
last_auto_prune_pruned: 0,
|
|
last_auto_prune_active_skipped: 0,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Coordination mode dispatch-first (stabilized)"));
|
|
assert!(text.contains("Recovery stabilized @"));
|
|
assert!(!text.contains("Last daemon recovery dispatch"));
|
|
assert!(!text.contains("Last daemon rebalance"));
|
|
}
|
|
|
|
#[test]
|
|
fn attention_queue_suppresses_inbox_pressure_when_stabilized() {
|
|
let now = Utc::now();
|
|
let sessions = vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)];
|
|
let unread = HashMap::from([(String::from("focus-12345678"), 3usize)]);
|
|
let summary = SessionSummary::from_sessions(&sessions, &unread, &HashMap::new(), true);
|
|
|
|
let line = attention_queue_line(&summary, true);
|
|
let rendered = line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>();
|
|
|
|
assert!(rendered.contains("Attention queue clear"));
|
|
assert!(rendered.contains("stabilized backlog absorbed"));
|
|
|
|
let mut dashboard = test_dashboard(sessions, 0);
|
|
dashboard.unread_message_counts = unread;
|
|
dashboard.handoff_backlog_counts =
|
|
HashMap::from([(String::from("focus-12345678"), 3usize)]);
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(now + chrono::Duration::seconds(2)),
|
|
last_dispatch_routed: 2,
|
|
last_dispatch_deferred: 0,
|
|
last_dispatch_leads: 1,
|
|
chronic_saturation_streak: 0,
|
|
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
|
|
last_recovery_dispatch_routed: 1,
|
|
last_recovery_dispatch_leads: 1,
|
|
last_rebalance_at: Some(now),
|
|
last_rebalance_rerouted: 1,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: None,
|
|
last_auto_merge_merged: 0,
|
|
last_auto_merge_active_skipped: 0,
|
|
last_auto_merge_conflicted_skipped: 0,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: None,
|
|
last_auto_prune_pruned: 0,
|
|
last_auto_prune_active_skipped: 0,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Attention queue clear"));
|
|
assert!(!text.contains("Needs attention:"));
|
|
assert!(!text.contains("Backlog focus-12"));
|
|
}
|
|
|
|
#[test]
|
|
fn summary_line_includes_worktree_health_counts() {
|
|
let sessions = vec![
|
|
sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
),
|
|
sample_session(
|
|
"worker-1234567",
|
|
"claude",
|
|
SessionState::Idle,
|
|
Some("ecc/worker"),
|
|
256,
|
|
21,
|
|
),
|
|
];
|
|
let unread = HashMap::new();
|
|
let worktree_health = HashMap::from([
|
|
(
|
|
String::from("focus-12345678"),
|
|
worktree::WorktreeHealth::Conflicted,
|
|
),
|
|
(
|
|
String::from("worker-1234567"),
|
|
worktree::WorktreeHealth::InProgress,
|
|
),
|
|
]);
|
|
|
|
let summary = SessionSummary::from_sessions(&sessions, &unread, &worktree_health, false);
|
|
let rendered = summary_line(&summary)
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>();
|
|
|
|
assert!(rendered.contains("Conflicts 1"));
|
|
assert!(rendered.contains("Worktrees 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn attention_queue_keeps_conflicted_worktree_pressure_when_stabilized() {
|
|
let now = Utc::now();
|
|
let sessions = vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)];
|
|
let unread = HashMap::from([(String::from("focus-12345678"), 3usize)]);
|
|
let worktree_health = HashMap::from([(
|
|
String::from("focus-12345678"),
|
|
worktree::WorktreeHealth::Conflicted,
|
|
)]);
|
|
|
|
let summary = SessionSummary::from_sessions(&sessions, &unread, &worktree_health, true);
|
|
let rendered = attention_queue_line(&summary, true)
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>();
|
|
|
|
assert!(rendered.contains("Attention queue"));
|
|
assert!(rendered.contains("Conflicts 1"));
|
|
assert!(!rendered.contains("Attention queue clear"));
|
|
|
|
let mut dashboard = test_dashboard(sessions, 0);
|
|
dashboard.unread_message_counts = unread;
|
|
dashboard.handoff_backlog_counts =
|
|
HashMap::from([(String::from("focus-12345678"), 3usize)]);
|
|
dashboard.worktree_health_by_session = worktree_health;
|
|
dashboard.daemon_activity = DaemonActivity {
|
|
last_dispatch_at: Some(now + chrono::Duration::seconds(2)),
|
|
last_dispatch_routed: 2,
|
|
last_dispatch_deferred: 0,
|
|
last_dispatch_leads: 1,
|
|
chronic_saturation_streak: 0,
|
|
last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)),
|
|
last_recovery_dispatch_routed: 1,
|
|
last_recovery_dispatch_leads: 1,
|
|
last_rebalance_at: Some(now),
|
|
last_rebalance_rerouted: 1,
|
|
last_rebalance_leads: 1,
|
|
last_auto_merge_at: None,
|
|
last_auto_merge_merged: 0,
|
|
last_auto_merge_active_skipped: 0,
|
|
last_auto_merge_conflicted_skipped: 0,
|
|
last_auto_merge_dirty_skipped: 0,
|
|
last_auto_merge_failed: 0,
|
|
last_auto_prune_at: None,
|
|
last_auto_prune_pruned: 0,
|
|
last_auto_prune_active_skipped: 0,
|
|
};
|
|
|
|
let text = dashboard.selected_session_metrics_text();
|
|
assert!(text.contains("Needs attention:"));
|
|
assert!(text.contains("Conflicted worktree focus-12"));
|
|
assert!(!text.contains("Backlog focus-12"));
|
|
}
|
|
|
|
#[test]
|
|
fn route_preview_ignores_non_handoff_inbox_noise() {
|
|
let lead = sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
);
|
|
let idle_worker = sample_session(
|
|
"idle-worker",
|
|
"planner",
|
|
SessionState::Idle,
|
|
Some("ecc/idle"),
|
|
128,
|
|
12,
|
|
);
|
|
|
|
let mut dashboard = test_dashboard(vec![lead.clone(), idle_worker.clone()], 0);
|
|
dashboard.db.insert_session(&lead).unwrap();
|
|
dashboard.db.insert_session(&idle_worker).unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message("lead-12345678", "idle-worker", "FYI status update", "info")
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"idle-worker",
|
|
"{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
dashboard.db.mark_messages_read("idle-worker").unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message("lead-12345678", "idle-worker", "FYI status update", "info")
|
|
.unwrap();
|
|
|
|
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap();
|
|
dashboard.sync_selected_lineage();
|
|
|
|
assert_eq!(
|
|
dashboard.selected_route_preview.as_deref(),
|
|
Some("reuse idle idle-wor")
|
|
);
|
|
assert_eq!(dashboard.selected_child_sessions.len(), 1);
|
|
assert_eq!(dashboard.selected_child_sessions[0].handoff_backlog, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_selected_lineage_populates_delegate_task_and_output_previews() {
|
|
let lead = sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
);
|
|
let mut child = sample_session(
|
|
"worker-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/worker"),
|
|
128,
|
|
12,
|
|
);
|
|
child.task = "Implement delegate metrics board for ECC 2.0".to_string();
|
|
|
|
let mut dashboard = test_dashboard(vec![lead.clone(), child.clone()], 0);
|
|
dashboard.db.insert_session(&lead).unwrap();
|
|
dashboard.db.insert_session(&child).unwrap();
|
|
dashboard
|
|
.db
|
|
.update_metrics("worker-12345678", &child.metrics)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-12345678",
|
|
"{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.append_output_line(
|
|
"worker-12345678",
|
|
OutputStream::Stdout,
|
|
"Reviewing delegate metrics board layout",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.approval_queue_counts
|
|
.insert("worker-12345678".into(), 2);
|
|
dashboard.worktree_health_by_session.insert(
|
|
"worker-12345678".into(),
|
|
worktree::WorktreeHealth::InProgress,
|
|
);
|
|
|
|
dashboard.sync_selected_lineage();
|
|
|
|
assert_eq!(dashboard.selected_child_sessions.len(), 1);
|
|
assert_eq!(
|
|
dashboard.selected_child_sessions[0].worktree_health,
|
|
Some(worktree::WorktreeHealth::InProgress)
|
|
);
|
|
assert_eq!(dashboard.selected_child_sessions[0].approval_backlog, 2);
|
|
assert_eq!(dashboard.selected_child_sessions[0].tokens_used, 128);
|
|
assert_eq!(dashboard.selected_child_sessions[0].files_changed, 2);
|
|
assert_eq!(dashboard.selected_child_sessions[0].duration_secs, 12);
|
|
assert_eq!(
|
|
dashboard.selected_child_sessions[0].task_preview,
|
|
"Implement delegate metrics board for EC…"
|
|
);
|
|
assert_eq!(
|
|
dashboard.selected_child_sessions[0].branch.as_deref(),
|
|
Some("ecc/worker")
|
|
);
|
|
assert_eq!(
|
|
dashboard.selected_child_sessions[0]
|
|
.last_output_preview
|
|
.as_deref(),
|
|
Some("Reviewing delegate metrics board layout")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_selected_lineage_prioritizes_conflicted_delegate_rows() {
|
|
let lead = sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
);
|
|
let conflicted = sample_session(
|
|
"worker-conflict",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/conflict"),
|
|
128,
|
|
12,
|
|
);
|
|
let idle = sample_session(
|
|
"worker-idle",
|
|
"planner",
|
|
SessionState::Idle,
|
|
Some("ecc/idle"),
|
|
64,
|
|
6,
|
|
);
|
|
|
|
let mut dashboard = test_dashboard(vec![lead.clone(), conflicted.clone(), idle.clone()], 0);
|
|
dashboard.db.insert_session(&lead).unwrap();
|
|
dashboard.db.insert_session(&conflicted).unwrap();
|
|
dashboard.db.insert_session(&idle).unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-conflict",
|
|
"{\"task\":\"Handle conflict\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-idle",
|
|
"{\"task\":\"Idle follow-up\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
dashboard.worktree_health_by_session.insert(
|
|
"worker-conflict".into(),
|
|
worktree::WorktreeHealth::Conflicted,
|
|
);
|
|
|
|
dashboard.sync_selected_lineage();
|
|
|
|
assert_eq!(dashboard.selected_child_sessions.len(), 2);
|
|
assert_eq!(
|
|
dashboard.selected_child_sessions[0].session_id,
|
|
"worker-conflict"
|
|
);
|
|
assert_eq!(
|
|
dashboard.selected_child_sessions[0].worktree_health,
|
|
Some(worktree::WorktreeHealth::Conflicted)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_selected_lineage_preserves_focused_delegate_by_session_id() {
|
|
let lead = sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
);
|
|
let conflicted = sample_session(
|
|
"worker-conflict",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/conflict"),
|
|
128,
|
|
12,
|
|
);
|
|
let idle = sample_session(
|
|
"worker-idle",
|
|
"planner",
|
|
SessionState::Idle,
|
|
Some("ecc/idle"),
|
|
64,
|
|
6,
|
|
);
|
|
|
|
let mut dashboard = test_dashboard(vec![lead.clone(), conflicted.clone(), idle.clone()], 0);
|
|
dashboard.db.insert_session(&lead).unwrap();
|
|
dashboard.db.insert_session(&conflicted).unwrap();
|
|
dashboard.db.insert_session(&idle).unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-conflict",
|
|
"{\"task\":\"Handle conflict\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
"worker-idle",
|
|
"{\"task\":\"Idle follow-up\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
dashboard.sync_selected_lineage();
|
|
dashboard.focused_delegate_session_id = Some("worker-idle".to_string());
|
|
dashboard.worktree_health_by_session.insert(
|
|
"worker-conflict".into(),
|
|
worktree::WorktreeHealth::Conflicted,
|
|
);
|
|
|
|
dashboard.sync_selected_lineage();
|
|
|
|
assert_eq!(
|
|
dashboard.focused_delegate_session_id.as_deref(),
|
|
Some("worker-idle")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_selected_lineage_keeps_all_delegate_rows() {
|
|
let lead = sample_session(
|
|
"lead-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/lead"),
|
|
512,
|
|
42,
|
|
);
|
|
|
|
let mut sessions = vec![lead.clone()];
|
|
let mut dashboard = test_dashboard(vec![lead.clone()], 0);
|
|
dashboard.db.insert_session(&lead).unwrap();
|
|
|
|
for index in 0..5 {
|
|
let child_id = format!("worker-{index}");
|
|
let child = sample_session(
|
|
&child_id,
|
|
"planner",
|
|
SessionState::Running,
|
|
Some(&format!("ecc/{child_id}")),
|
|
64,
|
|
6,
|
|
);
|
|
sessions.push(child.clone());
|
|
dashboard.db.insert_session(&child).unwrap();
|
|
dashboard
|
|
.db
|
|
.send_message(
|
|
"lead-12345678",
|
|
&child_id,
|
|
"{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}",
|
|
"task_handoff",
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
dashboard.sessions = sessions;
|
|
dashboard.sync_selected_lineage();
|
|
|
|
assert_eq!(dashboard.selected_child_sessions.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_cost_summary_mentions_total_cost() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.cost_budget_usd = 10.0;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sessions = vec![budget_session("sess-1", 3_500, 8.25)];
|
|
|
|
assert_eq!(
|
|
dashboard.aggregate_cost_summary_text(),
|
|
"Aggregate cost $8.25 / $10.00 | Budget alert 75%"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_cost_summary_mentions_fifty_percent_alert() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.cost_budget_usd = 10.0;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sessions = vec![budget_session("sess-1", 1_000, 5.0)];
|
|
|
|
assert_eq!(
|
|
dashboard.aggregate_cost_summary_text(),
|
|
"Aggregate cost $5.00 / $10.00 | Budget alert 50%"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_cost_summary_uses_custom_threshold_labels() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.cost_budget_usd = 10.0;
|
|
cfg.budget_alert_thresholds = crate::config::BudgetAlertThresholds {
|
|
advisory: 0.40,
|
|
warning: 0.70,
|
|
critical: 0.85,
|
|
};
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sessions = vec![budget_session("sess-1", 1_000, 7.0)];
|
|
|
|
assert_eq!(
|
|
dashboard.aggregate_cost_summary_text(),
|
|
"Aggregate cost $7.00 / $10.00 | Budget alert 70%"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_cost_summary_mentions_ninety_percent_alert() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.cost_budget_usd = 10.0;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sessions = vec![budget_session("sess-1", 1_000, 9.0)];
|
|
|
|
assert_eq!(
|
|
dashboard.aggregate_cost_summary_text(),
|
|
"Aggregate cost $9.00 / $10.00 | Budget alert 90%"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_budget_alerts_sets_operator_note_when_threshold_is_crossed() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.token_budget = 1_000;
|
|
cfg.cost_budget_usd = 10.0;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sessions = vec![budget_session("sess-1", 760, 2.0)];
|
|
dashboard.last_budget_alert_state = BudgetState::Alert50;
|
|
|
|
dashboard.sync_budget_alerts();
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("Budget alert 75% | tokens 760 / 1,000 | cost $2.00 / $10.00")
|
|
);
|
|
assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_budget_alerts_uses_custom_threshold_labels() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.token_budget = 1_000;
|
|
cfg.cost_budget_usd = 10.0;
|
|
cfg.budget_alert_thresholds = crate::config::BudgetAlertThresholds {
|
|
advisory: 0.40,
|
|
warning: 0.70,
|
|
critical: 0.85,
|
|
};
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sessions = vec![budget_session("sess-1", 710, 2.0)];
|
|
dashboard.last_budget_alert_state = BudgetState::Alert50;
|
|
|
|
dashboard.sync_budget_alerts();
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("Budget alert 70% | tokens 710 / 1,000 | cost $2.00 / $10.00")
|
|
);
|
|
assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_auto_pauses_over_budget_sessions_and_sets_operator_note() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.token_budget = 100;
|
|
cfg.cost_budget_usd = 0.0;
|
|
|
|
db.insert_session(&budget_session("sess-1", 120, 0.0))
|
|
.expect("insert session");
|
|
db.update_metrics(
|
|
"sess-1",
|
|
&SessionMetrics {
|
|
input_tokens: 90,
|
|
output_tokens: 30,
|
|
tokens_used: 120,
|
|
tool_calls: 0,
|
|
files_changed: 0,
|
|
duration_secs: 0,
|
|
cost_usd: 0.0,
|
|
},
|
|
)
|
|
.expect("persist metrics");
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.refresh();
|
|
|
|
assert_eq!(dashboard.sessions.len(), 1);
|
|
assert_eq!(dashboard.sessions[0].state, SessionState::Stopped);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("token budget exceeded | auto-paused 1 active session(s)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_syncs_tool_activity_metrics_from_hook_file() {
|
|
let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4()));
|
|
fs::create_dir_all(tempdir.join("metrics")).unwrap();
|
|
let db_path = tempdir.join("state.db");
|
|
let db = StateStore::open(&db_path).unwrap();
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "sess-1".to_string(),
|
|
task: "sync activity".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})
|
|
.unwrap();
|
|
|
|
let mut cfg = Config::default();
|
|
cfg.db_path = db_path;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
fs::write(
|
|
tempdir.join("metrics").join("tool-usage.jsonl"),
|
|
"{\"id\":\"evt-1\",\"session_id\":\"sess-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read README.md\",\"output_summary\":\"ok\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
|
)
|
|
.unwrap();
|
|
|
|
dashboard.refresh();
|
|
|
|
assert_eq!(dashboard.sessions.len(), 1);
|
|
assert_eq!(dashboard.sessions[0].metrics.tool_calls, 1);
|
|
assert_eq!(dashboard.sessions[0].metrics.files_changed, 1);
|
|
|
|
let _ = fs::remove_dir_all(tempdir);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_flags_stale_sessions_and_sets_operator_note() {
|
|
let db = StateStore::open(Path::new(":memory:")).unwrap();
|
|
let mut cfg = Config::default();
|
|
cfg.session_timeout_secs = 60;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "stale-1".to_string(),
|
|
task: "stale session".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: Some(4242),
|
|
worktree: None,
|
|
created_at: now - Duration::minutes(5),
|
|
updated_at: now - Duration::minutes(5),
|
|
last_heartbeat_at: now - Duration::minutes(5),
|
|
metrics: SessionMetrics::default(),
|
|
})
|
|
.unwrap();
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.refresh();
|
|
|
|
assert_eq!(dashboard.sessions.len(), 1);
|
|
assert_eq!(dashboard.sessions[0].state, SessionState::Stale);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("stale heartbeat detected | flagged 1 session(s) for attention")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn new_session_task_uses_selected_session_context() {
|
|
let dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
|
|
assert_eq!(
|
|
dashboard.new_session_task(),
|
|
"Follow up on focus-12: Render dashboard rows"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_session_count_only_counts_live_queue_states() {
|
|
let dashboard = test_dashboard(
|
|
vec![
|
|
sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1),
|
|
sample_session("running-1", "planner", SessionState::Running, None, 1, 1),
|
|
sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1),
|
|
sample_session("failed-1", "planner", SessionState::Failed, None, 1, 1),
|
|
sample_session("stopped-1", "planner", SessionState::Stopped, None, 1, 1),
|
|
sample_session("done-1", "planner", SessionState::Completed, None, 1, 1),
|
|
],
|
|
0,
|
|
);
|
|
|
|
assert_eq!(dashboard.active_session_count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_prompt_seed_uses_selected_session_context() {
|
|
let dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
Some("ecc/focus"),
|
|
512,
|
|
42,
|
|
)],
|
|
0,
|
|
);
|
|
|
|
assert_eq!(
|
|
dashboard.spawn_prompt_seed(),
|
|
"give me 2 agents working on Follow up on focus-12: Render dashboard rows"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_spawn_request_extracts_count_and_task_from_natural_language() {
|
|
let request = parse_spawn_request("give me 10 agents working on stabilize the queue")
|
|
.expect("spawn request should parse");
|
|
|
|
assert_eq!(
|
|
request,
|
|
SpawnRequest {
|
|
requested_count: 10,
|
|
task: "stabilize the queue".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_spawn_request_defaults_to_single_session_without_count() {
|
|
let request = parse_spawn_request("stabilize the queue").expect("spawn request");
|
|
|
|
assert_eq!(
|
|
request,
|
|
SpawnRequest {
|
|
requested_count: 1,
|
|
task: "stabilize the queue".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_spawn_plan_caps_requested_count_to_available_slots() {
|
|
let dashboard = test_dashboard(
|
|
vec![
|
|
sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1),
|
|
sample_session("running-1", "planner", SessionState::Running, None, 1, 1),
|
|
sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1),
|
|
],
|
|
0,
|
|
);
|
|
|
|
let plan = dashboard
|
|
.build_spawn_plan("give me 9 agents working on ship release notes")
|
|
.expect("spawn plan");
|
|
|
|
assert_eq!(
|
|
plan,
|
|
SpawnPlan {
|
|
requested_count: 9,
|
|
spawn_count: 5,
|
|
task: "ship release notes".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn expand_spawn_tasks_suffixes_multi_session_requests() {
|
|
assert_eq!(
|
|
expand_spawn_tasks("stabilize the queue", 3),
|
|
vec![
|
|
"stabilize the queue [1/3]".to_string(),
|
|
"stabilize the queue [2/3]".to_string(),
|
|
"stabilize the queue [3/3]".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_preserves_selected_session_by_id() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "older".to_string(),
|
|
task: "older".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Idle,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
db.insert_session(&Session {
|
|
id: "newer".to_string(),
|
|
task: "newer".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now + chrono::Duration::seconds(1),
|
|
last_heartbeat_at: now + chrono::Duration::seconds(1),
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let mut dashboard = Dashboard::new(db, Config::default());
|
|
dashboard.selected_session = 1;
|
|
dashboard.sync_selection();
|
|
dashboard.refresh();
|
|
|
|
assert_eq!(dashboard.selected_session_id(), Some("older"));
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_scroll_uses_independent_offset() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "session-1".to_string(),
|
|
task: "inspect output".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
for index in 0..6 {
|
|
db.append_output_line("session-1", OutputStream::Stdout, &format!("line {index}"))?;
|
|
}
|
|
|
|
let mut dashboard = Dashboard::new(db, Config::default());
|
|
dashboard.selected_pane = Pane::Output;
|
|
dashboard.refresh();
|
|
dashboard.sync_output_scroll(3);
|
|
dashboard.scroll_up();
|
|
let previous_scroll = dashboard.output_scroll_offset;
|
|
|
|
dashboard.selected_pane = Pane::Metrics;
|
|
dashboard.last_metrics_height = 2;
|
|
dashboard.scroll_up();
|
|
dashboard.scroll_down();
|
|
dashboard.scroll_down();
|
|
|
|
assert_eq!(dashboard.output_scroll_offset, previous_scroll);
|
|
assert_eq!(dashboard.metrics_scroll_offset, 2);
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_loads_selected_session_output_and_follows_tail() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "session-1".to_string(),
|
|
task: "tail output".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
for index in 0..12 {
|
|
db.append_output_line("session-1", OutputStream::Stdout, &format!("line {index}"))?;
|
|
}
|
|
|
|
let mut dashboard = Dashboard::new(db, Config::default());
|
|
dashboard.selected_pane = Pane::Output;
|
|
dashboard.refresh();
|
|
dashboard.sync_output_scroll(4);
|
|
|
|
assert_eq!(dashboard.output_scroll_offset, 8);
|
|
assert!(dashboard.selected_output_text().contains("line 11"));
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn submit_search_tracks_matches_and_sets_navigation_note() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "alpha"),
|
|
test_output_line(OutputStream::Stdout, "beta"),
|
|
test_output_line(OutputStream::Stdout, "alpha tail"),
|
|
],
|
|
);
|
|
dashboard.last_output_height = 2;
|
|
|
|
dashboard.begin_search();
|
|
for ch in "alpha.*".chars() {
|
|
dashboard.push_input_char(ch);
|
|
}
|
|
dashboard.submit_search();
|
|
|
|
assert_eq!(dashboard.search_query.as_deref(), Some("alpha.*"));
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![
|
|
SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
},
|
|
SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 2,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(dashboard.selected_search_match, 0);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("search /alpha.* matched 2 line(s) across 1 session(s) | n/N navigate matches")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_search_match_wraps_and_updates_scroll_offset() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "alpha-1"),
|
|
test_output_line(OutputStream::Stdout, "beta"),
|
|
test_output_line(OutputStream::Stdout, "alpha-2"),
|
|
],
|
|
);
|
|
dashboard.search_query = Some(r"alpha-\d".to_string());
|
|
dashboard.last_output_height = 1;
|
|
dashboard.recompute_search_matches();
|
|
|
|
dashboard.next_search_match();
|
|
assert_eq!(dashboard.selected_search_match, 1);
|
|
assert_eq!(dashboard.output_scroll_offset, 2);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some(r"search /alpha-\d match 2/2 | selected session")
|
|
);
|
|
|
|
dashboard.next_search_match();
|
|
assert_eq!(dashboard.selected_search_match, 0);
|
|
assert_eq!(dashboard.output_scroll_offset, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn submit_search_rejects_invalid_regex_and_keeps_input() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
|
|
dashboard.begin_search();
|
|
for ch in "(".chars() {
|
|
dashboard.push_input_char(ch);
|
|
}
|
|
dashboard.submit_search();
|
|
|
|
assert_eq!(dashboard.search_input.as_deref(), Some("("));
|
|
assert!(dashboard.search_query.is_none());
|
|
assert!(dashboard.search_matches.is_empty());
|
|
assert!(dashboard
|
|
.operator_note
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.starts_with("invalid regex /(:"));
|
|
}
|
|
|
|
#[test]
|
|
fn clear_search_resets_active_query_and_matches() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.search_input = Some("draft".to_string());
|
|
dashboard.search_query = Some("alpha".to_string());
|
|
dashboard.search_matches = vec![
|
|
SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 1,
|
|
},
|
|
SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 3,
|
|
},
|
|
];
|
|
dashboard.selected_search_match = 1;
|
|
|
|
dashboard.clear_search();
|
|
|
|
assert!(dashboard.search_input.is_none());
|
|
assert!(dashboard.search_query.is_none());
|
|
assert!(dashboard.search_matches.is_empty());
|
|
assert_eq!(dashboard.selected_search_match, 0);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("cleared output search")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_output_filter_keeps_only_stderr_lines() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "stdout line"),
|
|
test_output_line(OutputStream::Stderr, "stderr line"),
|
|
],
|
|
);
|
|
|
|
dashboard.toggle_output_filter();
|
|
|
|
assert_eq!(dashboard.output_filter, OutputFilter::ErrorsOnly);
|
|
assert_eq!(dashboard.visible_output_text(), "stderr line");
|
|
assert_eq!(dashboard.output_title(), " Output errors ");
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("output filter set to errors")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_output_filter_cycles_tool_calls_and_file_changes() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "normal output"),
|
|
test_output_line(OutputStream::Stdout, "Read(src/lib.rs)"),
|
|
test_output_line(OutputStream::Stdout, "Updated ecc2/src/tui/dashboard.rs"),
|
|
test_output_line(OutputStream::Stderr, "stderr line"),
|
|
],
|
|
);
|
|
|
|
dashboard.toggle_output_filter();
|
|
assert_eq!(dashboard.output_filter, OutputFilter::ErrorsOnly);
|
|
assert_eq!(dashboard.visible_output_text(), "stderr line");
|
|
|
|
dashboard.toggle_output_filter();
|
|
assert_eq!(dashboard.output_filter, OutputFilter::ToolCallsOnly);
|
|
assert_eq!(dashboard.visible_output_text(), "Read(src/lib.rs)");
|
|
assert_eq!(dashboard.output_title(), " Output tool calls ");
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("output filter set to tool calls")
|
|
);
|
|
|
|
dashboard.toggle_output_filter();
|
|
assert_eq!(dashboard.output_filter, OutputFilter::FileChangesOnly);
|
|
assert_eq!(
|
|
dashboard.visible_output_text(),
|
|
"Updated ecc2/src/tui/dashboard.rs"
|
|
);
|
|
assert_eq!(dashboard.output_title(), " Output file changes ");
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("output filter set to file changes")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_matches_respect_error_only_filter() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "alpha stdout"),
|
|
test_output_line(OutputStream::Stderr, "alpha stderr"),
|
|
test_output_line(OutputStream::Stderr, "beta stderr"),
|
|
],
|
|
);
|
|
dashboard.output_filter = OutputFilter::ErrorsOnly;
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
dashboard.last_output_height = 1;
|
|
|
|
dashboard.recompute_search_matches();
|
|
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
}]
|
|
);
|
|
assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr");
|
|
}
|
|
|
|
#[test]
|
|
fn search_matches_respect_tool_call_filter() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "alpha normal"),
|
|
test_output_line(OutputStream::Stdout, "Read(alpha.rs)"),
|
|
test_output_line(OutputStream::Stdout, "Write(beta.rs)"),
|
|
],
|
|
);
|
|
dashboard.output_filter = OutputFilter::ToolCallsOnly;
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
dashboard.last_output_height = 1;
|
|
|
|
dashboard.recompute_search_matches();
|
|
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
dashboard.visible_output_text(),
|
|
"Read(alpha.rs)\nWrite(beta.rs)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_matches_respect_file_change_filter() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line(OutputStream::Stdout, "alpha normal"),
|
|
test_output_line(OutputStream::Stdout, "Updated alpha.rs"),
|
|
test_output_line(OutputStream::Stdout, "Renamed beta.rs to gamma.rs"),
|
|
],
|
|
);
|
|
dashboard.output_filter = OutputFilter::FileChangesOnly;
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
dashboard.last_output_height = 1;
|
|
|
|
dashboard.recompute_search_matches();
|
|
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
dashboard.visible_output_text(),
|
|
"Updated alpha.rs\nRenamed beta.rs to gamma.rs"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_output_time_filter_keeps_only_recent_lines() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line_minutes_ago(OutputStream::Stdout, "recent line", 5),
|
|
test_output_line_minutes_ago(OutputStream::Stdout, "older line", 45),
|
|
test_output_line_minutes_ago(OutputStream::Stdout, "stale line", 180),
|
|
],
|
|
);
|
|
|
|
dashboard.cycle_output_time_filter();
|
|
|
|
assert_eq!(
|
|
dashboard.output_time_filter,
|
|
OutputTimeFilter::Last15Minutes
|
|
);
|
|
assert_eq!(dashboard.visible_output_text(), "recent line");
|
|
assert_eq!(dashboard.output_title(), " Output last 15m ");
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("output time filter set to last 15m")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_matches_respect_time_filter() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![
|
|
test_output_line_minutes_ago(OutputStream::Stdout, "alpha recent", 10),
|
|
test_output_line_minutes_ago(OutputStream::Stdout, "beta recent", 10),
|
|
test_output_line_minutes_ago(OutputStream::Stdout, "alpha stale", 180),
|
|
],
|
|
);
|
|
dashboard.output_time_filter = OutputTimeFilter::Last15Minutes;
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
dashboard.last_output_height = 1;
|
|
|
|
dashboard.recompute_search_matches();
|
|
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
}]
|
|
);
|
|
assert_eq!(dashboard.visible_output_text(), "alpha recent\nbeta recent");
|
|
}
|
|
|
|
#[test]
|
|
fn search_scope_all_sessions_matches_across_output_buffers() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
sample_session(
|
|
"review-87654321",
|
|
"reviewer",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha local")],
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"review-87654321".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha global")],
|
|
);
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
|
|
dashboard.toggle_search_scope();
|
|
|
|
assert_eq!(dashboard.search_scope, SearchScope::AllSessions);
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![
|
|
SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
},
|
|
SearchMatch {
|
|
session_id: "review-87654321".to_string(),
|
|
line_index: 0,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("search scope set to all sessions | 2 match(es)")
|
|
);
|
|
assert_eq!(
|
|
dashboard.output_title(),
|
|
" Output all sessions /alpha.* 1/2 "
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_search_match_switches_selected_session_in_all_sessions_scope() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
sample_session(
|
|
"review-87654321",
|
|
"reviewer",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha local")],
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"review-87654321".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha global")],
|
|
);
|
|
dashboard.search_scope = SearchScope::AllSessions;
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
dashboard.last_output_height = 1;
|
|
dashboard.recompute_search_matches();
|
|
|
|
dashboard.next_search_match();
|
|
|
|
assert_eq!(dashboard.selected_session_id(), Some("review-87654321"));
|
|
assert_eq!(dashboard.selected_search_match, 1);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("search /alpha.* match 2/2 | all sessions")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_agent_filter_selected_agent_type_limits_global_search() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session(
|
|
"focus-12345678",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
sample_session(
|
|
"planner-2222222",
|
|
"planner",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
sample_session(
|
|
"review-87654321",
|
|
"reviewer",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
),
|
|
],
|
|
0,
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"focus-12345678".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha local")],
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"planner-2222222".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha planner")],
|
|
);
|
|
dashboard.session_output_cache.insert(
|
|
"review-87654321".to_string(),
|
|
vec![test_output_line(OutputStream::Stdout, "alpha reviewer")],
|
|
);
|
|
dashboard.search_scope = SearchScope::AllSessions;
|
|
dashboard.search_query = Some("alpha.*".to_string());
|
|
dashboard.recompute_search_matches();
|
|
|
|
dashboard.toggle_search_agent_filter();
|
|
|
|
assert_eq!(
|
|
dashboard.search_agent_filter,
|
|
SearchAgentFilter::SelectedAgentType
|
|
);
|
|
assert_eq!(
|
|
dashboard.search_matches,
|
|
vec![
|
|
SearchMatch {
|
|
session_id: "focus-12345678".to_string(),
|
|
line_index: 0,
|
|
},
|
|
SearchMatch {
|
|
session_id: "planner-2222222".to_string(),
|
|
line_index: 0,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("search agent filter set to agent planner | 2 match(es)")
|
|
);
|
|
assert_eq!(
|
|
dashboard.output_title(),
|
|
" Output all sessions agent planner /alpha.* 1/2 "
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stop_selected_uses_session_manager_transition() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "running-1".to_string(),
|
|
task: "stop me".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
state: SessionState::Running,
|
|
working_dir: PathBuf::from("/tmp"),
|
|
pid: Some(999_999),
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.stop_selected().await;
|
|
|
|
let session = db
|
|
.get_session("running-1")?
|
|
.expect("session should exist after stop");
|
|
assert_eq!(session.state, SessionState::Stopped);
|
|
assert_eq!(session.pid, None);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_selected_requeues_failed_session() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "failed-1".to_string(),
|
|
task: "resume me".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
state: SessionState::Failed,
|
|
working_dir: PathBuf::from("/tmp/ecc2-resume"),
|
|
pid: None,
|
|
worktree: Some(WorktreeInfo {
|
|
path: PathBuf::from("/tmp/ecc2-resume"),
|
|
branch: "ecc/failed-1".to_string(),
|
|
base_branch: "main".to_string(),
|
|
}),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.resume_selected().await;
|
|
|
|
let session = db
|
|
.get_session("failed-1")?
|
|
.expect("session should exist after resume");
|
|
assert_eq!(session.state, SessionState::Pending);
|
|
assert_eq!(session.pid, None);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cleanup_selected_worktree_clears_session_metadata() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
let worktree_path = std::env::temp_dir().join(format!("ecc2-cleanup-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&worktree_path)?;
|
|
|
|
db.insert_session(&Session {
|
|
id: "stopped-1".to_string(),
|
|
task: "cleanup me".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
state: SessionState::Stopped,
|
|
working_dir: worktree_path.clone(),
|
|
pid: None,
|
|
worktree: Some(WorktreeInfo {
|
|
path: worktree_path.clone(),
|
|
branch: "ecc/stopped-1".to_string(),
|
|
base_branch: "main".to_string(),
|
|
}),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.cleanup_selected_worktree().await;
|
|
|
|
let session = db
|
|
.get_session("stopped-1")?
|
|
.expect("session should exist after cleanup");
|
|
assert!(
|
|
session.worktree.is_none(),
|
|
"worktree metadata should be cleared"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(worktree_path);
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prune_inactive_worktrees_sets_operator_note_when_clear() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "running-1".to_string(),
|
|
task: "keep alive".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.prune_inactive_worktrees().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("no inactive worktrees to prune")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prune_inactive_worktrees_reports_pruned_and_skipped_counts() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
let active_path = std::env::temp_dir().join(format!("ecc2-active-{}", Uuid::new_v4()));
|
|
let stopped_path = std::env::temp_dir().join(format!("ecc2-stopped-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&active_path)?;
|
|
std::fs::create_dir_all(&stopped_path)?;
|
|
|
|
db.insert_session(&Session {
|
|
id: "running-1".to_string(),
|
|
task: "keep worktree".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: active_path.clone(),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: Some(WorktreeInfo {
|
|
path: active_path.clone(),
|
|
branch: "ecc/running-1".to_string(),
|
|
base_branch: "main".to_string(),
|
|
}),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
db.insert_session(&Session {
|
|
id: "stopped-1".to_string(),
|
|
task: "prune me".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: stopped_path.clone(),
|
|
state: SessionState::Stopped,
|
|
pid: None,
|
|
worktree: Some(WorktreeInfo {
|
|
path: stopped_path.clone(),
|
|
branch: "ecc/stopped-1".to_string(),
|
|
base_branch: "main".to_string(),
|
|
}),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.prune_inactive_worktrees().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("pruned 1 inactive worktree(s); skipped 1 active session(s)")
|
|
);
|
|
assert!(db
|
|
.get_session("stopped-1")?
|
|
.expect("stopped session should exist")
|
|
.worktree
|
|
.is_none());
|
|
assert!(db
|
|
.get_session("running-1")?
|
|
.expect("running session should exist")
|
|
.worktree
|
|
.is_some());
|
|
|
|
let _ = std::fs::remove_dir_all(active_path);
|
|
let _ = std::fs::remove_dir_all(stopped_path);
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prune_inactive_worktrees_reports_retained_sessions_within_retention() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
let retained_path = std::env::temp_dir().join(format!("ecc2-retained-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&retained_path)?;
|
|
|
|
db.insert_session(&Session {
|
|
id: "stopped-1".to_string(),
|
|
task: "retain me".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: retained_path.clone(),
|
|
state: SessionState::Stopped,
|
|
pid: None,
|
|
worktree: Some(WorktreeInfo {
|
|
path: retained_path.clone(),
|
|
branch: "ecc/stopped-1".to_string(),
|
|
base_branch: "main".to_string(),
|
|
}),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let mut cfg = Config::default();
|
|
cfg.db_path = db_path.clone();
|
|
cfg.worktree_retention_secs = 3600;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, cfg);
|
|
dashboard.prune_inactive_worktrees().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("deferred 1 inactive worktree(s) within retention")
|
|
);
|
|
assert!(db
|
|
.get_session("stopped-1")?
|
|
.expect("stopped session should exist")
|
|
.worktree
|
|
.is_some());
|
|
|
|
let _ = std::fs::remove_dir_all(retained_path);
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn merge_selected_worktree_sets_operator_note_when_ready() -> Result<()> {
|
|
let tempdir = std::env::temp_dir().join(format!("dashboard-merge-{}", Uuid::new_v4()));
|
|
let repo_root = tempdir.join("repo");
|
|
init_git_repo(&repo_root)?;
|
|
|
|
let cfg = build_config(&tempdir);
|
|
let db = StateStore::open(&cfg.db_path)?;
|
|
let worktree = worktree::create_for_session_in_repo("merge1234", &cfg, &repo_root)?;
|
|
let session_id = "merge1234".to_string();
|
|
let now = Utc::now();
|
|
db.insert_session(&Session {
|
|
id: session_id.clone(),
|
|
task: "merge via dashboard".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: worktree.path.clone(),
|
|
state: SessionState::Completed,
|
|
pid: None,
|
|
worktree: Some(worktree.clone()),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
std::fs::write(worktree.path.join("dashboard.txt"), "dashboard merge\n")?;
|
|
Command::new("git")
|
|
.arg("-C")
|
|
.arg(&worktree.path)
|
|
.args(["add", "dashboard.txt"])
|
|
.status()?;
|
|
Command::new("git")
|
|
.arg("-C")
|
|
.arg(&worktree.path)
|
|
.args(["commit", "-qm", "dashboard work"])
|
|
.status()?;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.sync_selection_by_id(Some(&session_id));
|
|
dashboard.merge_selected_worktree().await;
|
|
|
|
let note = dashboard
|
|
.operator_note
|
|
.clone()
|
|
.context("operator note should be set")?;
|
|
assert!(note.contains("merged ecc/merge1234 into"));
|
|
assert!(note.contains(&format!("for {}", format_session_id(&session_id))));
|
|
|
|
let session = dashboard
|
|
.db
|
|
.get_session(&session_id)?
|
|
.context("merged session should still exist")?;
|
|
assert!(
|
|
session.worktree.is_none(),
|
|
"worktree metadata should be cleared"
|
|
);
|
|
assert!(!worktree.path.exists(), "worktree path should be removed");
|
|
assert_eq!(
|
|
std::fs::read_to_string(repo_root.join("dashboard.txt"))?,
|
|
"dashboard merge\n"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&tempdir);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn merge_ready_worktrees_sets_operator_note_with_skip_summary() -> Result<()> {
|
|
let tempdir =
|
|
std::env::temp_dir().join(format!("dashboard-merge-ready-{}", Uuid::new_v4()));
|
|
let repo_root = tempdir.join("repo");
|
|
init_git_repo(&repo_root)?;
|
|
|
|
let cfg = build_config(&tempdir);
|
|
let db = StateStore::open(&cfg.db_path)?;
|
|
let now = Utc::now();
|
|
|
|
let merged_worktree =
|
|
worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?;
|
|
std::fs::write(
|
|
merged_worktree.path.join("merged.txt"),
|
|
"dashboard bulk merge\n",
|
|
)?;
|
|
Command::new("git")
|
|
.arg("-C")
|
|
.arg(&merged_worktree.path)
|
|
.args(["add", "merged.txt"])
|
|
.status()?;
|
|
Command::new("git")
|
|
.arg("-C")
|
|
.arg(&merged_worktree.path)
|
|
.args(["commit", "-qm", "dashboard bulk merge"])
|
|
.status()?;
|
|
db.insert_session(&Session {
|
|
id: "merge-ready".to_string(),
|
|
task: "merge via dashboard".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: merged_worktree.path.clone(),
|
|
state: SessionState::Completed,
|
|
pid: None,
|
|
worktree: Some(merged_worktree.clone()),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let active_worktree =
|
|
worktree::create_for_session_in_repo("active-ready", &cfg, &repo_root)?;
|
|
db.insert_session(&Session {
|
|
id: "active-ready".to_string(),
|
|
task: "still active".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: active_worktree.path.clone(),
|
|
state: SessionState::Running,
|
|
pid: Some(999),
|
|
worktree: Some(active_worktree.clone()),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let mut dashboard = Dashboard::new(db, cfg);
|
|
dashboard.merge_ready_worktrees().await;
|
|
|
|
let note = dashboard
|
|
.operator_note
|
|
.clone()
|
|
.context("operator note should be set")?;
|
|
assert!(note.contains("merged 1 ready worktree(s)"));
|
|
assert!(note.contains("skipped 1 active"));
|
|
assert!(dashboard
|
|
.db
|
|
.get_session("merge-ready")?
|
|
.context("merged session should still exist")?
|
|
.worktree
|
|
.is_none());
|
|
assert_eq!(
|
|
std::fs::read_to_string(repo_root.join("merged.txt"))?,
|
|
"dashboard bulk merge\n"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&tempdir);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_selected_session_removes_inactive_session() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "done-1".to_string(),
|
|
task: "delete me".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Completed,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.delete_selected_session().await;
|
|
|
|
assert!(
|
|
db.get_session("done-1")?.is_none(),
|
|
"session should be deleted"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_dispatch_backlog_sets_operator_note_when_clear() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "lead-1".to_string(),
|
|
task: "coordinate".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.auto_dispatch_backlog().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("no unread handoff backlog found")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebalance_selected_team_sets_operator_note_when_clear() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "lead-1".to_string(),
|
|
task: "coordinate".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.rebalance_selected_team().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("no delegate backlog needed rebalancing for lead-1")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebalance_all_teams_sets_operator_note_when_clear() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "lead-1".to_string(),
|
|
task: "coordinate".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.rebalance_all_teams().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("no delegate backlog needed global rebalancing")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn coordinate_backlog_sets_operator_note_when_clear() -> Result<()> {
|
|
let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4()));
|
|
let db = StateStore::open(&db_path)?;
|
|
let now = Utc::now();
|
|
|
|
db.insert_session(&Session {
|
|
id: "lead-1".to_string(),
|
|
task: "coordinate".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
working_dir: PathBuf::from("/tmp"),
|
|
state: SessionState::Running,
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics::default(),
|
|
})?;
|
|
|
|
let dashboard_store = StateStore::open(&db_path)?;
|
|
let mut dashboard = Dashboard::new(dashboard_store, Config::default());
|
|
dashboard.coordinate_backlog().await;
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("backlog already clear")
|
|
);
|
|
|
|
let _ = std::fs::remove_file(db_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn grid_layout_renders_four_panes() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![sample_session(
|
|
"grid-1",
|
|
"claude",
|
|
SessionState::Running,
|
|
None,
|
|
1,
|
|
1,
|
|
)],
|
|
0,
|
|
);
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT;
|
|
|
|
let areas = dashboard.pane_areas(Rect::new(0, 0, 100, 40));
|
|
let output_area = areas.output.expect("grid layout should include output");
|
|
let metrics_area = areas.metrics.expect("grid layout should include metrics");
|
|
let log_area = areas.log.expect("grid layout should include a log pane");
|
|
|
|
assert!(output_area.x > areas.sessions.x);
|
|
assert!(metrics_area.y > areas.sessions.y);
|
|
assert!(log_area.x > metrics_area.x);
|
|
}
|
|
|
|
#[test]
|
|
fn collapse_selected_pane_hides_metrics_and_moves_focus() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.selected_pane = Pane::Metrics;
|
|
|
|
dashboard.collapse_selected_pane();
|
|
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
assert_eq!(
|
|
dashboard.visible_panes(),
|
|
vec![Pane::Sessions, Pane::Output]
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("collapsed metrics pane")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collapse_selected_pane_rejects_sessions_and_last_detail_pane() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
|
|
dashboard.collapse_selected_pane();
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("cannot collapse sessions pane")
|
|
);
|
|
|
|
dashboard.selected_pane = Pane::Metrics;
|
|
dashboard.collapse_selected_pane();
|
|
dashboard.selected_pane = Pane::Output;
|
|
dashboard.collapse_selected_pane();
|
|
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("cannot collapse last detail pane")
|
|
);
|
|
assert_eq!(
|
|
dashboard.visible_panes(),
|
|
vec![Pane::Sessions, Pane::Output]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restore_collapsed_panes_restores_hidden_tabs() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.selected_pane = Pane::Metrics;
|
|
dashboard.collapse_selected_pane();
|
|
|
|
dashboard.restore_collapsed_panes();
|
|
|
|
assert_eq!(
|
|
dashboard.visible_panes(),
|
|
vec![Pane::Sessions, Pane::Output, Pane::Metrics]
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("restored 1 collapsed pane(s)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collapsed_grid_reflows_to_horizontal_detail_stack() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT;
|
|
dashboard.selected_pane = Pane::Log;
|
|
dashboard.collapse_selected_pane();
|
|
|
|
let areas = dashboard.pane_areas(Rect::new(0, 0, 100, 40));
|
|
let output_area = areas.output.expect("output should stay visible");
|
|
let metrics_area = areas.metrics.expect("metrics should stay visible");
|
|
|
|
assert!(areas.log.is_none());
|
|
assert_eq!(areas.sessions.height, 40);
|
|
assert_eq!(output_area.width, metrics_area.width);
|
|
assert!(metrics_area.y > output_area.y);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_resize_clamps_to_bounds() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT;
|
|
|
|
for _ in 0..20 {
|
|
dashboard.adjust_pane_size_with_save(5, Path::new("/tmp/ecc2-noop.toml"), |_| Ok(()));
|
|
}
|
|
assert_eq!(dashboard.pane_size_percent, MAX_PANE_SIZE_PERCENT);
|
|
|
|
for _ in 0..40 {
|
|
dashboard.adjust_pane_size_with_save(-5, Path::new("/tmp/ecc2-noop.toml"), |_| Ok(()));
|
|
}
|
|
assert_eq!(dashboard.pane_size_percent, MIN_PANE_SIZE_PERCENT);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_navigation_skips_log_outside_grid_layouts() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.next_pane();
|
|
dashboard.next_pane();
|
|
dashboard.next_pane();
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT;
|
|
dashboard.next_pane();
|
|
dashboard.next_pane();
|
|
dashboard.next_pane();
|
|
assert_eq!(dashboard.selected_pane, Pane::Log);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_pane_number_selects_visible_panes_and_rejects_hidden_targets() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
|
|
dashboard.focus_pane_number(3);
|
|
|
|
assert_eq!(dashboard.selected_pane, Pane::Metrics);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("focused metrics pane")
|
|
);
|
|
|
|
dashboard.focus_pane_number(4);
|
|
|
|
assert_eq!(dashboard.selected_pane, Pane::Metrics);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("log pane is not visible")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn directional_pane_focus_uses_grid_neighbors() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT;
|
|
|
|
dashboard.focus_pane_right();
|
|
assert_eq!(dashboard.selected_pane, Pane::Output);
|
|
|
|
dashboard.focus_pane_down();
|
|
assert_eq!(dashboard.selected_pane, Pane::Log);
|
|
|
|
dashboard.focus_pane_left();
|
|
assert_eq!(dashboard.selected_pane, Pane::Metrics);
|
|
|
|
dashboard.focus_pane_up();
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("focused sessions pane")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_pane_navigation_keys_override_defaults() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_navigation.focus_metrics = "e".to_string();
|
|
dashboard.cfg.pane_navigation.move_left = "a".to_string();
|
|
|
|
assert!(dashboard.handle_pane_navigation_key(KeyEvent::new(
|
|
crossterm::event::KeyCode::Char('e'),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
)));
|
|
assert_eq!(dashboard.selected_pane, Pane::Metrics);
|
|
|
|
assert!(dashboard.handle_pane_navigation_key(KeyEvent::new(
|
|
crossterm::event::KeyCode::Char('a'),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
)));
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_navigation_labels_use_configured_bindings() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_navigation.focus_sessions = "q".to_string();
|
|
dashboard.cfg.pane_navigation.focus_output = "w".to_string();
|
|
dashboard.cfg.pane_navigation.focus_metrics = "e".to_string();
|
|
dashboard.cfg.pane_navigation.focus_log = "r".to_string();
|
|
dashboard.cfg.pane_navigation.move_left = "a".to_string();
|
|
dashboard.cfg.pane_navigation.move_down = "s".to_string();
|
|
dashboard.cfg.pane_navigation.move_up = "w".to_string();
|
|
dashboard.cfg.pane_navigation.move_right = "d".to_string();
|
|
|
|
assert_eq!(dashboard.pane_focus_shortcuts_label(), "q/w/e/r");
|
|
assert_eq!(dashboard.pane_move_shortcuts_label(), "a/s/w/d");
|
|
}
|
|
|
|
#[test]
|
|
fn pane_command_mode_handles_focus_and_cancel() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
|
|
dashboard.begin_pane_command_mode();
|
|
assert!(dashboard.is_pane_command_mode());
|
|
|
|
assert!(dashboard.handle_pane_command_key(KeyEvent::new(
|
|
crossterm::event::KeyCode::Char('3'),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
)));
|
|
assert_eq!(dashboard.selected_pane, Pane::Metrics);
|
|
assert!(!dashboard.is_pane_command_mode());
|
|
|
|
dashboard.begin_pane_command_mode();
|
|
assert!(dashboard.handle_pane_command_key(KeyEvent::new(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
)));
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some("pane command cancelled")
|
|
);
|
|
assert!(!dashboard.is_pane_command_mode());
|
|
}
|
|
|
|
#[test]
|
|
fn pane_command_mode_sets_layout() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_layout = PaneLayout::Horizontal;
|
|
|
|
dashboard.begin_pane_command_mode();
|
|
assert!(dashboard.handle_pane_command_key(KeyEvent::new(
|
|
crossterm::event::KeyCode::Char('g'),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
)));
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid);
|
|
assert!(dashboard
|
|
.operator_note
|
|
.as_deref()
|
|
.is_some_and(|note| note.contains("pane layout set to grid | saved to ")));
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.cfg.linear_pane_size_percent = 44;
|
|
dashboard.cfg.grid_pane_size_percent = 77;
|
|
dashboard.pane_size_percent = 77;
|
|
dashboard.selected_pane = Pane::Log;
|
|
|
|
dashboard.cycle_pane_layout();
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Horizontal);
|
|
assert_eq!(dashboard.pane_size_percent, 44);
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_pane_layout_persists_config() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
let tempdir = std::env::temp_dir().join(format!("ecc2-layout-policy-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&tempdir).unwrap();
|
|
let config_path = tempdir.join("ecc2.toml");
|
|
|
|
dashboard.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save_to_path(&config_path));
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Vertical);
|
|
let expected_note = format!(
|
|
"pane layout set to vertical | saved to {}",
|
|
config_path.display()
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some(expected_note.as_str())
|
|
);
|
|
|
|
let saved = std::fs::read_to_string(&config_path).unwrap();
|
|
let loaded: Config = toml::from_str(&saved).unwrap();
|
|
assert_eq!(loaded.pane_layout, PaneLayout::Vertical);
|
|
let _ = std::fs::remove_dir_all(tempdir);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_resize_persists_linear_setting() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
let tempdir = std::env::temp_dir().join(format!("ecc2-pane-size-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&tempdir).unwrap();
|
|
let config_path = tempdir.join("ecc2.toml");
|
|
|
|
dashboard.adjust_pane_size_with_save(5, &config_path, |cfg| cfg.save_to_path(&config_path));
|
|
|
|
assert_eq!(dashboard.pane_size_percent, 40);
|
|
assert_eq!(dashboard.cfg.linear_pane_size_percent, 40);
|
|
let expected_note = format!(
|
|
"pane size set to 40% for horizontal layout | saved to {}",
|
|
config_path.display()
|
|
);
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some(expected_note.as_str())
|
|
);
|
|
|
|
let saved = std::fs::read_to_string(&config_path).unwrap();
|
|
let loaded: Config = toml::from_str(&saved).unwrap();
|
|
assert_eq!(loaded.linear_pane_size_percent, 40);
|
|
assert_eq!(loaded.grid_pane_size_percent, 50);
|
|
let _ = std::fs::remove_dir_all(tempdir);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_pane_layout_uses_persisted_grid_size() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.pane_layout = PaneLayout::Vertical;
|
|
dashboard.cfg.linear_pane_size_percent = 41;
|
|
dashboard.cfg.grid_pane_size_percent = 63;
|
|
dashboard.pane_size_percent = 41;
|
|
|
|
dashboard.cycle_pane_layout_with_save(Path::new("/tmp/ecc2-noop.toml"), |_| Ok(()));
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid);
|
|
assert_eq!(dashboard.pane_size_percent, 63);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_split_layout_after_spawn_prefers_vertical_for_two_live_sessions() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session("running-1", "planner", SessionState::Running, None, 1, 1),
|
|
sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1),
|
|
],
|
|
0,
|
|
);
|
|
|
|
let note = dashboard.auto_split_layout_after_spawn_with_save(
|
|
2,
|
|
Path::new("/tmp/ecc2-noop.toml"),
|
|
|_| Ok(()),
|
|
);
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Vertical);
|
|
assert_eq!(
|
|
dashboard.pane_size_percent,
|
|
dashboard.cfg.linear_pane_size_percent
|
|
);
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
assert_eq!(
|
|
note.as_deref(),
|
|
Some("auto-split vertical layout for 2 live session(s)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_split_layout_after_spawn_prefers_grid_for_three_live_sessions() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1),
|
|
sample_session("running-1", "planner", SessionState::Running, None, 1, 1),
|
|
sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1),
|
|
],
|
|
1,
|
|
);
|
|
dashboard.selected_pane = Pane::Output;
|
|
|
|
let note = dashboard.auto_split_layout_after_spawn_with_save(
|
|
2,
|
|
Path::new("/tmp/ecc2-noop.toml"),
|
|
|_| Ok(()),
|
|
);
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid);
|
|
assert_eq!(
|
|
dashboard.pane_size_percent,
|
|
dashboard.cfg.grid_pane_size_percent
|
|
);
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
assert_eq!(
|
|
note.as_deref(),
|
|
Some("auto-split grid layout for 3 live session(s)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_split_layout_after_spawn_focuses_sessions_when_layout_already_matches() {
|
|
let mut dashboard = test_dashboard(
|
|
vec![
|
|
sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1),
|
|
sample_session("running-1", "planner", SessionState::Running, None, 1, 1),
|
|
sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1),
|
|
],
|
|
1,
|
|
);
|
|
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
|
dashboard.selected_pane = Pane::Output;
|
|
|
|
let note = dashboard.auto_split_layout_after_spawn_with_save(
|
|
3,
|
|
Path::new("/tmp/ecc2-noop.toml"),
|
|
|_| Ok(()),
|
|
);
|
|
|
|
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid);
|
|
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
|
assert_eq!(
|
|
note.as_deref(),
|
|
Some("auto-focused sessions in grid layout for 3 live session(s)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn post_spawn_selection_prefers_lead_for_multi_spawn() {
|
|
let preferred = post_spawn_selection_id(
|
|
Some("lead-12345678"),
|
|
&["child-a".to_string(), "child-b".to_string()],
|
|
);
|
|
|
|
assert_eq!(preferred.as_deref(), Some("lead-12345678"));
|
|
}
|
|
|
|
#[test]
|
|
fn post_spawn_selection_keeps_single_spawn_on_created_session() {
|
|
let preferred = post_spawn_selection_id(Some("lead-12345678"), &["child-a".to_string()]);
|
|
|
|
assert_eq!(preferred.as_deref(), Some("child-a"));
|
|
}
|
|
|
|
#[test]
|
|
fn post_spawn_selection_falls_back_to_first_created_when_no_lead_exists() {
|
|
let preferred =
|
|
post_spawn_selection_id(None, &["child-a".to_string(), "child-b".to_string()]);
|
|
|
|
assert_eq!(preferred.as_deref(), Some("child-a"));
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_theme_persists_config() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
let tempdir = std::env::temp_dir().join(format!("ecc2-theme-policy-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&tempdir).unwrap();
|
|
let config_path = tempdir.join("ecc2.toml");
|
|
|
|
dashboard.toggle_theme_with_save(&config_path, |cfg| cfg.save_to_path(&config_path));
|
|
|
|
assert_eq!(dashboard.cfg.theme, Theme::Light);
|
|
let expected_note = format!("theme set to light | saved to {}", config_path.display());
|
|
assert_eq!(
|
|
dashboard.operator_note.as_deref(),
|
|
Some(expected_note.as_str())
|
|
);
|
|
|
|
let saved = std::fs::read_to_string(&config_path).unwrap();
|
|
let loaded: Config = toml::from_str(&saved).unwrap();
|
|
assert_eq!(loaded.theme, Theme::Light);
|
|
let _ = std::fs::remove_dir_all(tempdir);
|
|
}
|
|
|
|
#[test]
|
|
fn light_theme_uses_light_palette_accent() {
|
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
|
dashboard.cfg.theme = Theme::Light;
|
|
dashboard.selected_pane = Pane::Sessions;
|
|
|
|
assert_eq!(
|
|
dashboard.pane_border_style(Pane::Sessions),
|
|
Style::default().fg(Color::Blue)
|
|
);
|
|
assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray);
|
|
}
|
|
|
|
fn test_output_line(stream: OutputStream, text: &str) -> OutputLine {
|
|
OutputLine::new(stream, text, Utc::now().to_rfc3339())
|
|
}
|
|
|
|
fn test_output_line_minutes_ago(
|
|
stream: OutputStream,
|
|
text: &str,
|
|
minutes_ago: i64,
|
|
) -> OutputLine {
|
|
OutputLine::new(
|
|
stream,
|
|
text,
|
|
(Utc::now() - chrono::Duration::minutes(minutes_ago)).to_rfc3339(),
|
|
)
|
|
}
|
|
|
|
fn line_plain_text(line: &Line<'_>) -> String {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
}
|
|
|
|
fn text_plain_text(text: &Text<'_>) -> String {
|
|
text.lines
|
|
.iter()
|
|
.map(line_plain_text)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn test_dashboard(sessions: Vec<Session>, selected_session: usize) -> Dashboard {
|
|
let selected_session = selected_session.min(sessions.len().saturating_sub(1));
|
|
let cfg = Config::default();
|
|
let output_store = SessionOutputStore::default();
|
|
let output_rx = output_store.subscribe();
|
|
let mut session_table_state = TableState::default();
|
|
if !sessions.is_empty() {
|
|
session_table_state.select(Some(selected_session));
|
|
}
|
|
|
|
Dashboard {
|
|
db: StateStore::open(Path::new(":memory:")).expect("open test db"),
|
|
pane_size_percent: configured_pane_size(&cfg, cfg.pane_layout),
|
|
cfg,
|
|
output_store,
|
|
output_rx,
|
|
sessions,
|
|
session_output_cache: HashMap::new(),
|
|
unread_message_counts: HashMap::new(),
|
|
approval_queue_counts: HashMap::new(),
|
|
approval_queue_preview: Vec::new(),
|
|
handoff_backlog_counts: HashMap::new(),
|
|
worktree_health_by_session: HashMap::new(),
|
|
global_handoff_backlog_leads: 0,
|
|
global_handoff_backlog_messages: 0,
|
|
daemon_activity: DaemonActivity::default(),
|
|
selected_messages: Vec::new(),
|
|
selected_parent_session: None,
|
|
selected_child_sessions: Vec::new(),
|
|
focused_delegate_session_id: None,
|
|
selected_team_summary: None,
|
|
selected_route_preview: None,
|
|
logs: Vec::new(),
|
|
selected_diff_summary: None,
|
|
selected_diff_preview: Vec::new(),
|
|
selected_diff_patch: None,
|
|
selected_diff_hunk_offsets_unified: Vec::new(),
|
|
selected_diff_hunk_offsets_split: Vec::new(),
|
|
selected_diff_hunk: 0,
|
|
diff_view_mode: DiffViewMode::Split,
|
|
selected_conflict_protocol: None,
|
|
selected_merge_readiness: None,
|
|
selected_git_status_entries: Vec::new(),
|
|
selected_git_status: 0,
|
|
output_mode: OutputMode::SessionOutput,
|
|
output_filter: OutputFilter::All,
|
|
output_time_filter: OutputTimeFilter::AllTime,
|
|
timeline_event_filter: TimelineEventFilter::All,
|
|
timeline_scope: SearchScope::SelectedSession,
|
|
selected_pane: Pane::Sessions,
|
|
selected_session,
|
|
show_help: false,
|
|
operator_note: None,
|
|
pane_command_mode: false,
|
|
output_follow: true,
|
|
output_scroll_offset: 0,
|
|
last_output_height: 0,
|
|
metrics_scroll_offset: 0,
|
|
last_metrics_height: 0,
|
|
collapsed_panes: HashSet::new(),
|
|
search_input: None,
|
|
spawn_input: None,
|
|
commit_input: None,
|
|
search_query: None,
|
|
search_scope: SearchScope::SelectedSession,
|
|
search_agent_filter: SearchAgentFilter::AllAgents,
|
|
search_matches: Vec::new(),
|
|
selected_search_match: 0,
|
|
session_table_state,
|
|
last_cost_metrics_signature: None,
|
|
last_tool_activity_signature: None,
|
|
last_budget_alert_state: BudgetState::Normal,
|
|
}
|
|
}
|
|
|
|
fn build_config(root: &Path) -> Config {
|
|
Config {
|
|
db_path: root.join("state.db"),
|
|
worktree_root: root.join("worktrees"),
|
|
worktree_branch_prefix: "ecc".to_string(),
|
|
max_parallel_sessions: 4,
|
|
max_parallel_worktrees: 4,
|
|
worktree_retention_secs: 0,
|
|
session_timeout_secs: 60,
|
|
heartbeat_interval_secs: 5,
|
|
auto_terminate_stale_sessions: false,
|
|
default_agent: "claude".to_string(),
|
|
auto_dispatch_unread_handoffs: false,
|
|
auto_dispatch_limit_per_session: 5,
|
|
auto_create_worktrees: true,
|
|
auto_merge_ready_worktrees: false,
|
|
cost_budget_usd: 10.0,
|
|
token_budget: 500_000,
|
|
budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS,
|
|
theme: Theme::Dark,
|
|
pane_layout: PaneLayout::Horizontal,
|
|
pane_navigation: Default::default(),
|
|
linear_pane_size_percent: 35,
|
|
grid_pane_size_percent: 50,
|
|
risk_thresholds: Config::RISK_THRESHOLDS,
|
|
}
|
|
}
|
|
|
|
fn init_git_repo(path: &Path) -> Result<()> {
|
|
fs::create_dir_all(path)?;
|
|
run_git(path, &["init", "-q"])?;
|
|
run_git(path, &["config", "user.name", "ECC Tests"])?;
|
|
run_git(path, &["config", "user.email", "ecc-tests@example.com"])?;
|
|
fs::write(path.join("README.md"), "hello\n")?;
|
|
run_git(path, &["add", "README.md"])?;
|
|
run_git(path, &["commit", "-qm", "init"])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn run_git(path: &Path, args: &[&str]) -> Result<()> {
|
|
let output = Command::new("git")
|
|
.arg("-C")
|
|
.arg(path)
|
|
.args(args)
|
|
.output()?;
|
|
if !output.status.success() {
|
|
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn sample_session(
|
|
id: &str,
|
|
agent_type: &str,
|
|
state: SessionState,
|
|
branch: Option<&str>,
|
|
tokens_used: u64,
|
|
duration_secs: u64,
|
|
) -> Session {
|
|
Session {
|
|
id: id.to_string(),
|
|
task: "Render dashboard rows".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: agent_type.to_string(),
|
|
state,
|
|
working_dir: branch
|
|
.map(|branch| PathBuf::from(format!("/tmp/{branch}")))
|
|
.unwrap_or_else(|| PathBuf::from("/tmp")),
|
|
pid: None,
|
|
worktree: branch.map(|branch| WorktreeInfo {
|
|
path: PathBuf::from(format!("/tmp/{branch}")),
|
|
branch: branch.to_string(),
|
|
base_branch: "main".to_string(),
|
|
}),
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
last_heartbeat_at: Utc::now(),
|
|
metrics: SessionMetrics {
|
|
input_tokens: tokens_used.saturating_mul(3) / 4,
|
|
output_tokens: tokens_used / 4,
|
|
tokens_used,
|
|
tool_calls: 4,
|
|
files_changed: 2,
|
|
duration_secs,
|
|
cost_usd: 0.42,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn budget_session(id: &str, tokens_used: u64, cost_usd: f64) -> Session {
|
|
let now = Utc::now();
|
|
Session {
|
|
id: id.to_string(),
|
|
task: "Budget tracking".to_string(),
|
|
project: "workspace".to_string(),
|
|
task_group: "general".to_string(),
|
|
agent_type: "claude".to_string(),
|
|
state: SessionState::Running,
|
|
working_dir: PathBuf::from("/tmp"),
|
|
pid: None,
|
|
worktree: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_heartbeat_at: now,
|
|
metrics: SessionMetrics {
|
|
input_tokens: tokens_used.saturating_mul(3) / 4,
|
|
output_tokens: tokens_used / 4,
|
|
tokens_used,
|
|
tool_calls: 0,
|
|
files_changed: 0,
|
|
duration_secs: 0,
|
|
cost_usd,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn render_dashboard_text(mut dashboard: Dashboard, width: u16, height: u16) -> String {
|
|
let backend = TestBackend::new(width, height);
|
|
let mut terminal = Terminal::new(backend).expect("create terminal");
|
|
|
|
terminal
|
|
.draw(|frame| dashboard.render(frame))
|
|
.expect("render dashboard");
|
|
|
|
let buffer = terminal.backend().buffer();
|
|
buffer
|
|
.content
|
|
.chunks(buffer.area.width as usize)
|
|
.map(|cells| cells.iter().map(|cell| cell.symbol()).collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
}
|