ai-dispatch 9.3.2

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::Instant;

use super::metrics::ProcessMetrics;
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;

#[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,
        }
    }

    fn is_text_view(self) -> bool {
        matches!(self, Self::Prompt | Self::Output)
    }
}

pub struct App {
    pub tasks: Vec<Task>,
    pub events_cache: HashMap<String, Vec<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 multipane_mode: bool,
    pub tree_mode: bool,
    pub tree_selected: usize,
    pub tree_node_count: usize,
    pub wg_creators: HashMap<String, String>,
    pub show_all: bool,
    pub active_pane: usize,
    pub pane_scroll_offsets: Vec<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,
    cached_terminal_milestones: HashMap<String, String>,
}

impl App {
    pub fn new(store: Arc<Store>, options: super::RunOptions) -> Result<Self> {
        let mut app = Self {
            tasks: Vec::new(),
            events_cache: 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,
            multipane_mode: false,
            tree_mode: false,
            tree_selected: 0,
            tree_node_count: 0,
            wg_creators: HashMap::new(),
            show_all: false,
            active_pane: 0,
            pane_scroll_offsets: Vec::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(),
            cached_terminal_milestones: HashMap::new(),
        };
        app.reload_tasks()?;
        Ok(app)
    }

    pub fn tick(&mut self) -> Result<()> {
        self.reload_tasks()?;
        // Keep tree_node_count in sync for key navigation
        if self.tree_mode {
            let count = super::tree_data::build_task_tree_with_creators(&self.tasks, &self.wg_creators).len();
            self.tree_node_count = count;
            if self.tree_selected >= count && count > 0 {
                self.tree_selected = count - 1;
            }
        }
        // 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();
        }
        if self.dashboard_mode {
            self.load_dashboard_events()?;
        }
        if self.multipane_mode {
            self.load_multipane_events()?;
            let count = self.multipane_tasks().len();
            self.pane_scroll_offsets.resize(count, 0);
        }
        if self.detail_mode {
            self.load_selected_events()?;
        }
        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 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 fn scope_label(&self) -> String {
        let scope = if self.show_all && self.task_id_filter.is_none() {
            "all"
        } else {
            "today+active"
        };
        match (self.task_id_filter.as_deref(), self.group_filter.as_deref()) {
            (Some(task_id), Some(group_id)) => format!("task {task_id} | group {group_id}"),
            (Some(task_id), None) => format!("task {task_id}"),
            (None, Some(group_id)) => format!("{scope} | group {group_id}"),
            (None, None) => scope.to_string(),
        }
    }
    pub fn empty_message(&self) -> String { format!("No tasks matched scope: {}", self.scope_label()) }
    fn load_selected_events(&mut self) -> Result<()> {
        let Some(task_id) = self
            .selected_task()
            .map(|task| task.id.as_str().to_string())
        else {
            return Ok(());
        };
        let events = self.store.get_events(&task_id)?;
        self.events_cache.insert(task_id, events);
        Ok(())
    }
    fn load_dashboard_events(&mut self) -> Result<()> {
        for task_id in self
            .tasks
            .iter()
            .filter(|task| {
                matches!(
                    task.status,
                    TaskStatus::Running | TaskStatus::AwaitingInput | TaskStatus::Stalled
                )
            })
            .map(|task| task.id.as_str().to_string())
        {
            self.events_cache
                .insert(task_id.clone(), self.store.get_events(&task_id)?);
        }
        Ok(())
    }
    fn load_multipane_events(&mut self) -> Result<()> {
        let task_ids: Vec<String> = self
            .multipane_tasks()
            .iter()
            .map(|t| t.id.as_str().to_string())
            .collect();
        for task_id in task_ids {
            // Always refresh running tasks, cache completed ones
            let is_running = self.tasks.iter().any(|t| {
                t.id.as_str() == task_id
                    && matches!(
                        t.status,
                        TaskStatus::Running | TaskStatus::AwaitingInput | TaskStatus::Stalled
                    )
            });
            if is_running || !self.events_cache.contains_key(&task_id) {
                self.events_cache
                    .insert(task_id.clone(), self.store.get_events(&task_id)?);
            }
        }
        Ok(())
    }
}

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