ai-dispatch 10.36.0

Multi-AI CLI team orchestrator
// App state for the aid task dashboard TUI.
// Loads task lists from Store and handles keyboard-driven navigation.

use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};

use super::metrics::ProcessMetrics;
use super::stats::{StatsRange, StatsSnapshot};
use crate::store::Store;
use crate::types::{EventKind, Task, TaskEvent, TaskStatus};

#[path = "app_keys.rs"]
mod app_keys;
#[path = "app_tasks.rs"]
mod app_tasks;
#[path = "app_panes.rs"]
mod app_panes;
#[path = "app_navigation.rs"]
mod app_navigation;
#[path = "app_refresh.rs"]
mod app_refresh;
#[path = "stats_app.rs"]
mod stats_app;

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum DetailTab {
    Events,
    Prompt,
    Output,
}

impl DetailTab {
    fn next(self) -> Self {
        match self {
            Self::Events => Self::Prompt,
            Self::Prompt => Self::Output,
            Self::Output => Self::Events,
        }
    }

    fn previous(self) -> Self {
        match self {
            Self::Events => Self::Output,
            Self::Prompt => Self::Events,
            Self::Output => Self::Prompt,
        }
    }
}

pub struct App {
    pub tasks: Vec<Task>,
    pub events_cache: HashMap<String, Vec<TaskEvent>>,
    latest_events: HashMap<String, TaskEvent>,
    pub metrics: HashMap<String, ProcessMetrics>,
    pub milestones: HashMap<String, String>,
    pub selected: usize,
    pub detail_mode: bool,
    pub detail_tab: DetailTab,
    pub detail_scroll: usize,
    pub dashboard_mode: bool,
    pub stats_mode: bool,
    pub legacy_stats_view: bool,
    pub stats_range: StatsRange,
    pub stats: StatsSnapshot,
    pub multipane_mode: bool,
    pub tree_mode: bool,
    pub tree_selected: usize,
    pub tree_node_count: usize,
    pub collapsed_projects: std::collections::HashSet<Option<String>>,
    pub search_mode: bool,
    pub search_query: String,
    pub wg_creators: HashMap<String, String>,
    pub show_all: bool,
    pub active_pane: usize,
    pub pane_scroll_offsets: HashMap<String, usize>,
    pub should_quit: bool,
    task_id_filter: Option<String>,
    group_filter: Option<String>,
    config: crate::config::AidConfig,
    store: Arc<Store>,
    last_metrics_refresh: Instant,
    last_stats_refresh: Instant,
    cached_terminal_milestones: HashMap<String, String>,
    active_pane_task_id: Option<String>,
    last_task_refresh: Instant,
    pub(crate) animation_phase: u8,
}

impl App {
    pub fn new(store: Arc<Store>, options: super::RunOptions) -> Result<Self> {
        let mut app = Self {
            tasks: Vec::new(),
            events_cache: HashMap::new(),
            latest_events: HashMap::new(),
            metrics: HashMap::new(),
            milestones: HashMap::new(),
            selected: 0,
            detail_mode: false,
            detail_tab: DetailTab::Events,
            detail_scroll: 0,
            dashboard_mode: false,
            stats_mode: false,
            legacy_stats_view: false,
            stats_range: StatsRange::AllTime,
            stats: StatsSnapshot::empty(StatsRange::AllTime),
            multipane_mode: false,
            tree_mode: false,
            tree_selected: 0,
            tree_node_count: 0,
            collapsed_projects: std::collections::HashSet::new(),
            search_mode: false,
            search_query: String::new(),
            wg_creators: HashMap::new(),
            show_all: false,
            active_pane: 0,
            pane_scroll_offsets: HashMap::new(),
            should_quit: false,
            task_id_filter: options.task_id,
            group_filter: options.group,
            config: crate::config::load_config().unwrap_or_default(),
            store,
            last_metrics_refresh: Instant::now(),
            last_stats_refresh: Instant::now(),
            cached_terminal_milestones: HashMap::new(),
            active_pane_task_id: None,
            last_task_refresh: Instant::now(),
            animation_phase: 0,
        };
        app.reload_tasks()?;
        Ok(app)
    }

    pub fn tick(&mut self) -> Result<()> {
        let task_refresh_due = self.last_task_refresh.elapsed() >= TASK_REFRESH_INTERVAL;
        if task_refresh_due {
            self.reload_tasks()?;
            self.last_task_refresh = Instant::now();
        }
        if self.has_reasoning_task() {
            self.animation_phase = self.animation_phase.wrapping_add(1) % 3;
        }
        // Only refresh process metrics every 2 seconds (ps fork is expensive)
        if self.last_metrics_refresh.elapsed().as_secs() >= 2 {
            self.metrics = self.load_metrics(&self.tasks);
            self.last_metrics_refresh = Instant::now();
        }
        self.refresh_stats_if_due()?;
        if task_refresh_due && self.dashboard_mode {
            self.load_dashboard_events()?;
        }
        if task_refresh_due && self.multipane_mode {
            self.reconcile_active_pane();
            self.load_multipane_events()?;
            self.clamp_all_pane_scrolls();
        }
        if task_refresh_due && self.detail_mode {
            self.load_selected_events()?;
            self.clamp_detail_scroll();
        }
        Ok(())
    }

    pub fn selected_task(&self) -> Option<&Task> { self.tasks.get(self.selected) }
    pub fn selected_events(&self) -> Vec<TaskEvent> {
        self.selected_task()
            .and_then(|task| self.events_cache.get(task.id.as_str()))
            .cloned()
            .unwrap_or_default()
    }
    pub fn get_metrics(&self, task_id: &str) -> Option<&ProcessMetrics> {
        self.metrics.get(task_id)
    }
    pub fn get_milestone(&self, task_id: &str) -> Option<&str> {
        self.milestones.get(task_id).map(String::as_str)
    }
    pub fn task_activity(&self, task: &Task) -> String {
        let label = super::agent_state::activity_label(
            task.status,
            task.agent,
            task.id.as_str(),
            self.latest_events.get(task.id.as_str()),
        );
        if label.starts_with("THINKING ·") {
            format!("{label} {}", ["·", "··", "···"][self.animation_phase as usize])
        } else {
            label
        }
    }
    pub fn get_failure_reason(&self, task_id: &str) -> Option<String> {
        self.events_cache.get(task_id).and_then(|events| {
            events
                .iter()
                .rev()
                .find(|e| e.event_kind == EventKind::Error)
                .map(|e| e.detail.clone())
        })
    }
    pub fn config(&self) -> &crate::config::AidConfig { &self.config }
    pub fn task_milestones(&self, task_id: &str) -> Vec<String> {
        self.events_cache
            .get(task_id)
            .map(|events| {
                events
                    .iter()
                    .filter(|event| event.event_kind == EventKind::Milestone)
                    .map(|event| event.detail.clone())
                    .collect()
            })
            .unwrap_or_default()
    }
    pub fn multipane_tasks(&self) -> Vec<&Task> {
        let mut tasks: Vec<&Task> = self.tasks.iter().collect();
        tasks.sort_by(|a, b| {
            let running_a = matches!(
                a.status,
                TaskStatus::Running | TaskStatus::AwaitingInput | TaskStatus::Stalled
            );
            let running_b = matches!(
                b.status,
                TaskStatus::Running | TaskStatus::AwaitingInput | TaskStatus::Stalled
            );
            running_b
                .cmp(&running_a)
                .then(b.created_at.cmp(&a.created_at))
        });
        tasks
    }
    pub fn pane_count(&self) -> usize {
        self.multipane_tasks().len().min(6)
    }
    pub(crate) fn pane_scroll_offset(&self, task_id: &str) -> usize {
        self.pane_scroll_offsets.get(task_id).copied().unwrap_or(0)
    }
    fn reconcile_active_pane(&mut self) {
        let tasks = self.multipane_tasks();
        if tasks.is_empty() {
            self.active_pane = 0;
            self.active_pane_task_id = None;
            return;
        }
        let index = self
            .active_pane_task_id
            .as_deref()
            .and_then(|id| tasks.iter().position(|task| task.id.as_str() == id))
            .unwrap_or_else(|| self.active_pane.min(tasks.len() - 1));
        let task_id = tasks[index].id.as_str().to_string();
        self.active_pane = index;
        self.active_pane_task_id = Some(task_id);
    }
    pub fn scope_label(&self) -> String {
        let scope = if self.show_all && self.task_id_filter.is_none() { "all" } else { "today+active" };
        let project = "project:*";
        match (self.task_id_filter.as_deref(), self.group_filter.as_deref()) {
            (Some(t), Some(g)) => format!("{project} | task {t} | group {g}"),
            (Some(t), None) => format!("{project} | task {t}"),
            (None, Some(g)) => format!("{project} | {scope} | group {g}"),
            (None, None) => format!("{project} | {scope}"),
        }
    }
    pub fn empty_message(&self) -> String { format!("No tasks matched scope: {}", self.scope_label()) }
}

const TASK_REFRESH_INTERVAL: Duration = Duration::from_secs(2);

#[cfg(test)]
#[path = "app_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "app_selection_tests.rs"]
mod selection_tests;

#[cfg(test)]
#[path = "app_group_tests.rs"]
mod group_tests;

#[cfg(test)]
#[path = "app_detail_tests.rs"]
mod detail_tests;