//! `a3s top` — a ctop-style monitor for boxes, coding agents, and diagnostics.
//!
//! The first milestone intentionally keeps collectors independent from the TUI
//! layer. That lets the same snapshot model later feed `--json`, remote
//! dashboards, or the lightweight `/top` panel inside `a3s code`.

use std::collections::{HashMap, HashSet};
use std::io::SeekFrom;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use a3s_tui::cmd::{self, Cmd};
use a3s_tui::components::{
    divider_line, CellAlign, Confirm, DataColumn, DataRow, DataTable, DetailPanel, HelpPanel,
    HelpSection, LogView, LogViewState, MenuItem, MenuPanel, Meter, MetricTrend, MultiSelect,
    MultiSelectMsg, SectionHeader, Select, SelectMsg, Sparkline, StatusBar, TabSegment, Tabs, Tree,
    TreeNode, WrappedPrefixBlock,
};
use a3s_tui::event::KeyEvent;
use a3s_tui::keymap::{KeyBinding, Keymap};
use a3s_tui::layout::{Constraint, Layout};
use a3s_tui::style::{Color, Style};
use a3s_tui::{Event, KeyCode, KeyModifiers, Model, ProgramBuilder};
use futures::stream::{self, StreamExt};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::process::Command;

mod collect;
mod view;
pub(crate) use collect::{collect_processes, AgentKind, ProcessRow, Risk};
#[cfg(test)]
use collect::{detect_agent, parse_lsof_cwd, parse_process_line, process_risk};
pub(crate) use view::{process_data_table, render_process_table, ProcessTableView};

const ACCENT: Color = Color::Rgb(122, 162, 247);
const GREEN: Color = Color::Rgb(158, 206, 106);
const YELLOW: Color = Color::Rgb(224, 175, 104);
const RED: Color = Color::Rgb(247, 118, 142);
const CYAN: Color = Color::Rgb(125, 207, 255);
const ORANGE: Color = Color::Rgb(255, 158, 100);
const HISTORY_LIMIT: usize = 30;
const A3S_BOX_INSPECT_LIMIT: usize = 32;
const OBSERVER_EVENT_LIMIT: usize = 200;
const OBSERVER_AUTO_MAX_FILES_PER_AGENT: usize = 8;
const OBSERVER_AUTO_MAX_SCAN_FILES: usize = 512;
const OBSERVER_AUTO_INITIAL_TAIL_BYTES: u64 = 256 * 1024;
const A3S_BOX_PIDS_CONCURRENCY: usize = 4;
const A3S_BOX_PIDS_TIMEOUT: Duration = Duration::from_millis(900);
const AGENTS_TREE_SESSION_LIMIT: usize = 4;
const AGENTS_TREE_PROCESS_LIMIT: usize = 5;
const AGENTS_TREE_EVENT_LIMIT: usize = 4;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tab {
    Agents,
    Containers,
    Sessions,
    Events,
    Processes,
}

impl Tab {
    const PRIMARY: [Tab; 2] = [Tab::Agents, Tab::Containers];
    const ALL: [Tab; 5] = [
        Tab::Agents,
        Tab::Containers,
        Tab::Sessions,
        Tab::Events,
        Tab::Processes,
    ];

    fn primary_index(self) -> usize {
        Self::PRIMARY.iter().position(|t| *t == self).unwrap_or(0)
    }

    fn label(self) -> &'static str {
        match self {
            Tab::Agents => "Agents",
            Tab::Containers => "Containers",
            Tab::Sessions => "Sessions",
            Tab::Events => "Events",
            Tab::Processes => "Processes",
        }
    }

    fn next(self) -> Self {
        Self::PRIMARY[(self.primary_index() + 1) % Self::PRIMARY.len()]
    }

    fn prev(self) -> Self {
        Self::PRIMARY[(self.primary_index() + Self::PRIMARY.len() - 1) % Self::PRIMARY.len()]
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortBy {
    Cpu,
    Mem,
    Net,
    Block,
    Pids,
    State,
    Id,
    Uptime,
    Name,
    Tokens,
}

impl SortBy {
    #[cfg(test)]
    fn next(self) -> Self {
        match self {
            SortBy::Cpu => SortBy::Mem,
            SortBy::Mem => SortBy::Net,
            SortBy::Net => SortBy::Block,
            SortBy::Block => SortBy::Pids,
            SortBy::Pids => SortBy::State,
            SortBy::State => SortBy::Id,
            SortBy::Id => SortBy::Uptime,
            SortBy::Uptime => SortBy::Name,
            SortBy::Name => SortBy::Tokens,
            SortBy::Tokens => SortBy::Cpu,
        }
    }

    fn label(self) -> &'static str {
        match self {
            SortBy::Cpu => "cpu",
            SortBy::Mem => "mem",
            SortBy::Net => "net",
            SortBy::Block => "block",
            SortBy::Pids => "pids",
            SortBy::State => "state",
            SortBy::Id => "id",
            SortBy::Uptime => "uptime",
            SortBy::Name => "name",
            SortBy::Tokens => "tokens",
        }
    }

    fn from_label(label: &str) -> Option<Self> {
        let normalized = label.trim().to_ascii_lowercase();
        match normalized.as_str() {
            "cpu" => Some(SortBy::Cpu),
            "mem" | "mem%" | "mem %" | "mem-pct" | "mem_pct" | "memory" => Some(SortBy::Mem),
            "net" | "network" => Some(SortBy::Net),
            "block" | "blk" | "disk" | "io" => Some(SortBy::Block),
            "pid" | "pids" | "processes" => Some(SortBy::Pids),
            "state" | "status" => Some(SortBy::State),
            "id" | "container-id" | "container_id" => Some(SortBy::Id),
            "uptime" | "up" | "started" => Some(SortBy::Uptime),
            "name" => Some(SortBy::Name),
            "tok" | "token" | "tokens" | "llm" => Some(SortBy::Tokens),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContainerConnector {
    A3sBox,
    Docker,
    RunC,
}

impl ContainerConnector {
    fn label(self) -> &'static str {
        match self {
            ContainerConnector::A3sBox => "a3s-box",
            ContainerConnector::Docker => "docker",
            ContainerConnector::RunC => "runc",
        }
    }

    fn from_label(label: &str) -> Option<Self> {
        match label {
            "a3s-box" | "a3sbox" | "box" => Some(ContainerConnector::A3sBox),
            "docker" => Some(ContainerConnector::Docker),
            "runc" => Some(ContainerConnector::RunC),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RiskFilter {
    All,
    Medium,
    High,
}

impl RiskFilter {
    fn next(self) -> Self {
        match self {
            RiskFilter::All => RiskFilter::Medium,
            RiskFilter::Medium => RiskFilter::High,
            RiskFilter::High => RiskFilter::All,
        }
    }

    fn label(self) -> &'static str {
        match self {
            RiskFilter::All => "all",
            RiskFilter::Medium => "medium",
            RiskFilter::High => "high",
        }
    }

    fn from_label(label: &str) -> Option<Self> {
        match label {
            "all" => Some(RiskFilter::All),
            "medium" | "med" => Some(RiskFilter::Medium),
            "high" => Some(RiskFilter::High),
            _ => None,
        }
    }

    fn matches(self, risk: Risk) -> bool {
        match self {
            RiskFilter::All => true,
            RiskFilter::Medium => matches!(risk, Risk::Medium | Risk::High),
            RiskFilter::High => risk == Risk::High,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KindFilter {
    All,
    Tool,
    Security,
    File,
    Egress,
    Llm,
    Other,
}

impl KindFilter {
    fn next(self) -> Self {
        match self {
            KindFilter::All => KindFilter::Tool,
            KindFilter::Tool => KindFilter::Security,
            KindFilter::Security => KindFilter::File,
            KindFilter::File => KindFilter::Egress,
            KindFilter::Egress => KindFilter::Llm,
            KindFilter::Llm => KindFilter::Other,
            KindFilter::Other => KindFilter::All,
        }
    }

    fn label(self) -> &'static str {
        match self {
            KindFilter::All => "all",
            KindFilter::Tool => "tool",
            KindFilter::Security => "security",
            KindFilter::File => "file",
            KindFilter::Egress => "egress",
            KindFilter::Llm => "llm",
            KindFilter::Other => "other",
        }
    }

    fn from_label(label: &str) -> Option<Self> {
        match label {
            "all" => Some(KindFilter::All),
            "tool" | "tools" | "tool-exec" | "toolexec" => Some(KindFilter::Tool),
            "security" | "security-action" | "securityaction" => Some(KindFilter::Security),
            "file" | "files" => Some(KindFilter::File),
            "egress" | "network" => Some(KindFilter::Egress),
            "llm" | "model" | "tokens" => Some(KindFilter::Llm),
            "other" => Some(KindFilter::Other),
            _ => None,
        }
    }

    fn matches(self, kind: &str) -> bool {
        match self {
            KindFilter::All => true,
            KindFilter::Tool => kind == "ToolExec",
            KindFilter::Security => kind == "SecurityAction",
            KindFilter::File => matches!(kind, "FileAccess" | "FileDelete"),
            KindFilter::Egress => kind == "Egress",
            KindFilter::Llm => is_llm_event_kind(kind),
            KindFilter::Other => {
                !matches!(
                    kind,
                    "ToolExec" | "SecurityAction" | "FileAccess" | "FileDelete" | "Egress"
                ) && !is_llm_event_kind(kind)
            }
        }
    }
}

#[derive(Debug, Clone)]
struct ContainerRow {
    connector: ContainerConnector,
    id: String,
    name: String,
    image: String,
    status: String,
    inspect: ContainerInspect,
    cpu_pct: Option<f32>,
    cpu_count: Option<u32>,
    cpu_usage_total_ns: Option<u64>,
    mem_pct: Option<f32>,
    mem_usage: String,
    net_io: String,
    block_io: String,
    pids: String,
    ports: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ContainerInspect {
    health: String,
    restarts: String,
    restart_policy: String,
    created: String,
    started: String,
    exit: String,
    mounts: String,
    env: String,
    labels: String,
    networks: String,
}

impl Default for ContainerInspect {
    fn default() -> Self {
        Self {
            health: "-".into(),
            restarts: "-".into(),
            restart_policy: "-".into(),
            created: "-".into(),
            started: "-".into(),
            exit: "-".into(),
            mounts: "-".into(),
            env: "-".into(),
            labels: "-".into(),
            networks: "-".into(),
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ContainerStateSummary {
    total: usize,
    running: usize,
    restarting: usize,
    paused: usize,
    exited: usize,
    created: usize,
    dead: usize,
    other: usize,
}

#[derive(Debug, Clone, Default)]
struct RuncStats {
    cpu_usage_total_ns: Option<u64>,
    memory_usage: Option<u64>,
    memory_limit: Option<u64>,
    pids_current: Option<u64>,
    net_rx: u64,
    net_tx: u64,
    block_read: u64,
    block_write: u64,
}

#[derive(Debug, Clone, Default)]
struct MetricHistory {
    cpu: Vec<f32>,
    mem: Vec<f32>,
    net_io_bytes: Vec<f64>,
    block_io_bytes: Vec<f64>,
    cpu_usage_total_ns: Option<u64>,
    raw_sample_at: Option<Instant>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq)]
struct ProcessTreeUsage {
    cpu_pct: f32,
    mem_pct: f32,
    descendants: usize,
}

#[derive(Debug, Clone)]
struct EventRow {
    ts: String,
    source: String,
    session: Option<String>,
    task: Option<String>,
    pid: Option<u32>,
    ppid: Option<u32>,
    kind: String,
    message: String,
    details: Vec<(String, String)>,
    risk: Risk,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct AgentActivity {
    events: usize,
    sessions: usize,
    tools: usize,
    security: usize,
    files: usize,
    egress: usize,
    llm: usize,
    prompt_tokens: u64,
    completion_tokens: u64,
    total_tokens: u64,
    model: String,
    provider: String,
    latency_ms: u64,
    latency_samples: u64,
    ttft_ms: u64,
    ttft_samples: u64,
    req_bytes: u64,
    resp_bytes: u64,
    high_risk: usize,
}

#[derive(Debug, Clone)]
struct AgentTreeGroup {
    agent: AgentKind,
    sessions: Vec<SessionRow>,
    processes: Vec<ProcessRow>,
    events: Vec<EventRow>,
    activity: AgentActivity,
    usage: ProcessTreeUsage,
}

impl AgentTreeGroup {
    fn active(&self) -> bool {
        !self.sessions.is_empty() || !self.processes.is_empty() || !self.events.is_empty()
    }

    fn risk(&self) -> Risk {
        self.processes
            .iter()
            .map(|process| process.risk)
            .chain(self.events.iter().map(|event| event.risk))
            .chain(self.sessions.iter().map(|session| session.risk))
            .max_by_key(|risk| risk_rank(*risk))
            .unwrap_or(Risk::Low)
    }
}

#[derive(Debug, Clone)]
struct SessionRow {
    source: String,
    session: String,
    task: String,
    workspace: String,
    events: usize,
    tools: usize,
    security: usize,
    files: usize,
    egress: usize,
    llm: usize,
    prompt_tokens: u64,
    completion_tokens: u64,
    total_tokens: u64,
    model: String,
    provider: String,
    latency_ms: u64,
    latency_samples: u64,
    ttft_ms: u64,
    ttft_samples: u64,
    req_bytes: u64,
    resp_bytes: u64,
    high_risk: usize,
    risk: Risk,
    last_kind: String,
    last_message: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionFocus {
    source: String,
    session: String,
}

impl SessionFocus {
    fn from_row(row: &SessionRow) -> Self {
        Self {
            source: row.source.clone(),
            session: row.session.clone(),
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct TokenUsageSummary {
    prompt: u64,
    completion: u64,
    total: u64,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct LlmNetworkSummary {
    provider: Option<String>,
    latency_ms: Option<u64>,
    ttft_ms: Option<u64>,
    req_bytes: u64,
    resp_bytes: u64,
}

#[derive(Debug, Clone, Default)]
struct TopSnapshot {
    processes: Vec<ProcessRow>,
    containers: Vec<ContainerRow>,
    events: Vec<EventRow>,
    errors: Vec<String>,
}

#[derive(Debug, Clone, Default)]
struct ObserverState {
    paths: Vec<PathBuf>,
    auto_paths: HashSet<PathBuf>,
    files: HashMap<PathBuf, ObserverFileState>,
    events: Vec<EventRow>,
}

#[derive(Debug, Clone, Default)]
struct ObserverFileState {
    offset: u64,
    pending: String,
    json_events_seen: usize,
}

#[derive(Debug, Clone)]
enum Action {
    KillProcess(u32, String),
    StartContainer(ContainerConnector, String, String),
    StopContainer(ContainerConnector, String, String),
    RestartContainer(ContainerConnector, String, String),
    PauseContainer(ContainerConnector, String, String),
    UnpauseContainer(ContainerConnector, String, String),
    RemoveContainer(ContainerConnector, String, String),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContainerMenuAction {
    Focus,
    Logs,
    ExecShell,
    OpenBrowser,
    Start,
    Stop,
    Restart,
    Pause,
    Unpause,
    Remove,
}

#[derive(Debug, Clone)]
struct ContainerMenuItem {
    action: ContainerMenuAction,
    key: char,
    label: String,
}

#[derive(Debug, Clone)]
enum ExternalAction {
    ContainerShell {
        connector: ContainerConnector,
        id: String,
        name: String,
    },
    OpenBrowser {
        url: String,
        name: String,
    },
}

#[derive(Debug, Clone)]
struct TopConfig {
    show_all_containers: bool,
    show_header: bool,
    reverse_sort: bool,
    sort_by: SortBy,
    risk_filter: RiskFilter,
    kind_filter: KindFilter,
    connector: ContainerConnector,
    filter: String,
    hidden_columns: HashSet<String>,
}

impl Default for TopConfig {
    fn default() -> Self {
        Self {
            show_all_containers: false,
            show_header: true,
            reverse_sort: false,
            sort_by: SortBy::Cpu,
            risk_filter: RiskFilter::All,
            kind_filter: KindFilter::All,
            connector: ContainerConnector::A3sBox,
            filter: String::new(),
            hidden_columns: default_hidden_columns(),
        }
    }
}

fn default_hidden_columns() -> HashSet<String> {
    [
        "agents.pid",
        "agents.cpu_history",
        "agents.mem_history",
        "agents.sessions",
        "agents.session",
        "agents.task",
        "agents.tools",
        "agents.security",
        "agents.files",
        "agents.egress",
        "agents.llm",
        "agents.model",
        "agents.provider",
        "agents.latency",
        "agents.high_risk",
        "agents.children",
        "agents.elapsed",
        "agents.cwd",
        "sessions.task",
        "sessions.tools",
        "sessions.security",
        "sessions.files",
        "sessions.egress",
        "sessions.model",
        "sessions.provider",
        "sessions.latency",
        "sessions.high_risk",
        "processes.ppid",
        "processes.cpu_history",
        "processes.mem_history",
        "processes.elapsed",
        "processes.cwd",
        "containers.cpus",
        "containers.image",
        "containers.mem_usage",
        "containers.ports",
        "containers.health",
        "events.session",
        "events.task",
        "events.pid",
        "events.ppid",
    ]
    .into_iter()
    .map(String::from)
    .collect()
}

#[derive(Debug, Clone)]
struct LogPanel {
    connector: ContainerConnector,
    container_id: String,
    container_name: String,
    text: String,
    scroll: usize,
    timestamps: bool,
    loading: bool,
    refreshing: bool,
    follow: bool,
}

#[derive(Debug, Clone, Default)]
struct ContainerProcessRow {
    pid: String,
    ppid: String,
    cpu_pct: Option<f32>,
    mem_pct: Option<f32>,
    elapsed: String,
    command: String,
}

#[derive(Debug, Clone)]
struct ContainerProcessPanel {
    container_id: String,
    container_name: String,
    rows: Vec<ContainerProcessRow>,
    scroll: usize,
    error: Option<String>,
    loading: bool,
}

#[derive(Debug, Clone)]
struct ColumnChoice {
    id: &'static str,
    label: &'static str,
}

struct ColumnPanel {
    tab: Tab,
    choices: Vec<ColumnChoice>,
    select: MultiSelect,
}

struct SortPanel {
    choices: Vec<SortBy>,
    select: Select,
}

struct ConnectorPanel {
    choices: Vec<ContainerConnector>,
    select: Select,
}

struct ContainerMenu {
    container: ContainerRow,
    items: Vec<ContainerMenuItem>,
    select: Select,
}

#[derive(Debug, Clone)]
enum Msg {
    Term(Event),
    Snapshot {
        connector: ContainerConnector,
        snapshot: TopSnapshot,
        observer: ObserverState,
    },
    Tick,
    ActionDone(String),
    ContainerLogs {
        connector: ContainerConnector,
        id: String,
        name: String,
        timestamps: bool,
        result: Result<String, String>,
    },
    ContainerProcesses {
        id: String,
        name: String,
        result: Result<Vec<ContainerProcessRow>, String>,
    },
    ConfigSaved(Result<PathBuf, String>),
}

impl From<Event> for Msg {
    fn from(event: Event) -> Self {
        Msg::Term(event)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum TopKey {
    Quit,
    Up,
    Down,
    Home,
    End,
    PageUp,
    PageDown,
    NextTab,
    PrevTab,
    Filter,
    Sort,
    TogglePause,
    ToggleAll,
    ToggleHeader,
    ToggleReverse,
    ToggleRiskFilter,
    ToggleKindFilter,
    Detail,
    Open,
    Logs,
    OpenBrowser,
    Columns,
    Connector,
    ExecShell,
    SaveConfig,
    Help,
    Kill,
}

struct TopApp {
    snapshot: TopSnapshot,
    observer: ObserverState,
    runtime_events: Vec<EventRow>,
    history: HashMap<String, MetricHistory>,
    tab: Tab,
    sort_by: SortBy,
    selected: usize,
    scroll: usize,
    filter: String,
    editing_filter: bool,
    filter_before_edit: Option<String>,
    help: bool,
    detail: bool,
    paused: bool,
    show_all_containers: bool,
    show_header: bool,
    reverse_sort: bool,
    risk_filter: RiskFilter,
    kind_filter: KindFilter,
    invert_colors: bool,
    connector: ContainerConnector,
    hidden_columns: HashSet<String>,
    focused_container: Option<String>,
    focused_agent_pid: Option<u32>,
    focused_session: Option<SessionFocus>,
    confirm: Option<Action>,
    log: Option<LogPanel>,
    container_processes: Option<ContainerProcessPanel>,
    column_panel: Option<ColumnPanel>,
    sort_panel: Option<SortPanel>,
    connector_panel: Option<ConnectorPanel>,
    container_menu: Option<ContainerMenu>,
    external_action: Arc<Mutex<Option<ExternalAction>>>,
    note: Option<String>,
    interval: Duration,
    width: u16,
    height: u16,
    last_refresh: Option<Instant>,
    keymap: Keymap<TopKey>,
}

impl TopApp {
    fn new(options: TopOptions) -> Self {
        let (width, height) = a3s_tui::terminal::Terminal::size().unwrap_or((100, 30));
        let mut app = Self {
            snapshot: TopSnapshot::default(),
            observer: ObserverState::default(),
            runtime_events: Vec::new(),
            history: HashMap::new(),
            tab: options.tab,
            sort_by: options.config.sort_by,
            selected: 0,
            scroll: 0,
            filter: options.config.filter,
            editing_filter: false,
            filter_before_edit: None,
            help: options.start_help,
            detail: false,
            paused: false,
            show_all_containers: options.config.show_all_containers,
            show_header: options.config.show_header,
            reverse_sort: options.config.reverse_sort,
            risk_filter: options.config.risk_filter,
            kind_filter: options.config.kind_filter,
            invert_colors: options.invert_colors,
            connector: options.config.connector,
            hidden_columns: options.config.hidden_columns,
            focused_container: options.container_query.clone(),
            focused_agent_pid: None,
            focused_session: None,
            confirm: None,
            log: None,
            container_processes: None,
            column_panel: None,
            sort_panel: None,
            connector_panel: None,
            container_menu: None,
            external_action: options.external_action,
            note: None,
            interval: options.interval,
            width,
            height,
            last_refresh: None,
            keymap: top_keymap(),
        };
        app.ensure_visible_columns();
        app
    }

    fn apply_snapshot(
        &mut self,
        mut snapshot: TopSnapshot,
        observer: ObserverState,
        connector: ContainerConnector,
    ) {
        if self.last_refresh.is_some() {
            push_runtime_events(
                &mut self.runtime_events,
                container_lifecycle_events(
                    connector,
                    &self.snapshot.containers,
                    &snapshot.containers,
                ),
            );
        }
        prepend_runtime_events(&mut snapshot.events, &self.runtime_events);
        self.record_history(&mut snapshot);
        self.snapshot = snapshot;
        self.observer = observer;
        self.last_refresh = Some(Instant::now());
        self.clamp_selection();
    }

    fn reset_position(&mut self) {
        self.selected = 0;
        self.scroll = 0;
        self.detail = false;
    }

    fn visible_len(&self) -> usize {
        match self.tab {
            Tab::Agents if self.focused_agent_pid.is_some() => self.filtered_agents().len(),
            Tab::Agents => self.agent_tree_groups().len(),
            Tab::Sessions if self.focused_session.is_some() => self.focused_session_events().len(),
            Tab::Sessions => self.filtered_sessions().len(),
            Tab::Containers => self.filtered_containers().len(),
            Tab::Processes => self.filtered_processes().len(),
            Tab::Events => self.filtered_events().len(),
        }
    }

    fn visible_height(&self) -> usize {
        let reserved = if self.detail { 12 } else { 5 };
        (self.height as usize).saturating_sub(reserved).max(3)
    }

    fn log_visible_height(&self) -> usize {
        (self.height as usize).saturating_sub(7).max(3)
    }

    fn record_history(&mut self, snapshot: &mut TopSnapshot) {
        let now = Instant::now();
        let mut live_keys = HashSet::new();
        for process in &snapshot.processes {
            let key = process_history_key(process.pid);
            live_keys.insert(key.clone());
            push_history(
                self.history.entry(key).or_default(),
                process.cpu_pct,
                process.mem_pct,
            );
        }
        let agent_tree_usages = snapshot
            .processes
            .iter()
            .filter(|process| process.agent.is_some())
            .map(|process| {
                (
                    agent_tree_history_key(process.pid),
                    process_tree_usage(&snapshot.processes, process.pid),
                )
            })
            .collect::<Vec<_>>();
        for (key, usage) in agent_tree_usages {
            live_keys.insert(key.clone());
            push_history(
                self.history.entry(key).or_default(),
                usage.cpu_pct,
                usage.mem_pct,
            );
        }
        for container in &mut snapshot.containers {
            let key = container_history_key(&container.id);
            live_keys.insert(key.clone());
            let history = self.history.entry(key).or_default();
            let cpu_pct = container.cpu_pct.unwrap_or_else(|| {
                observe_raw_cpu_pct(history, container.cpu_usage_total_ns, now).unwrap_or_default()
            });
            if container.cpu_pct.is_none() && container.cpu_usage_total_ns.is_some() {
                container.cpu_pct = Some(cpu_pct);
            }
            push_history(history, cpu_pct, container.mem_pct.unwrap_or_default());
            push_io_history(
                history,
                container_net_total(container),
                container_block_total(container),
            );
        }
        self.history.retain(|key, _| live_keys.contains(key));
    }

    fn metric_history(&self, key: &str) -> MetricHistory {
        self.history.get(key).cloned().unwrap_or_default()
    }

    fn sparkline(&self, values: &[f32], color: Color) -> String {
        Sparkline::new(values.iter().copied().map(f64::from))
            .width(8)
            .range(0.0, 100.0)
            .fg(color)
            .view()
    }

    fn metric_cell(&self, pct: Option<f32>, values: &[f32], color: Color) -> String {
        MetricTrend::new(pct.map(f64::from), values.iter().copied().map(f64::from))
            .width(15)
            .trend_width(8)
            .range(0.0, 100.0)
            .fg(color)
            .view()
    }

    fn scaled_cpu_metric_cell(
        &self,
        pct: Option<f32>,
        values: &[f32],
        cpus: Option<u32>,
    ) -> String {
        let history = values
            .iter()
            .copied()
            .map(|value| scale_cpu_pct_for_cpus(value, cpus))
            .collect::<Vec<_>>();
        self.metric_cell(
            pct.map(|value| scale_cpu_pct_for_cpus(value, cpus)),
            &history,
            CYAN,
        )
    }

    fn clamp_selection(&mut self) {
        let len = self.visible_len();
        if len == 0 {
            self.selected = 0;
            self.scroll = 0;
            return;
        }
        self.selected = self.selected.min(len - 1);
        let body = self.visible_height();
        if self.selected < self.scroll {
            self.scroll = self.selected;
        } else if self.selected >= self.scroll + body {
            self.scroll = self.selected + 1 - body;
        }
    }

    fn selected_action(&self) -> Option<Action> {
        match self.tab {
            Tab::Agents | Tab::Processes => self
                .current_process()
                .map(|p| Action::KillProcess(p.pid, display_cmd(&p.command))),
            Tab::Containers => self.current_container().map(|c| {
                Action::StopContainer(
                    c.connector,
                    c.id.clone(),
                    format!("{} ({})", c.name, short_id(&c.id)),
                )
            }),
            Tab::Events => None,
            Tab::Sessions => None,
        }
    }

    fn current_process(&self) -> Option<ProcessRow> {
        match self.tab {
            Tab::Agents if self.focused_agent_pid.is_some() => {
                self.filtered_agents().first().cloned()
            }
            Tab::Agents => self
                .current_agent_group()
                .and_then(|group| group.processes.first().cloned()),
            Tab::Processes => self.filtered_processes().get(self.selected).cloned(),
            _ => None,
        }
    }

    fn current_agent_group(&self) -> Option<AgentTreeGroup> {
        if self.tab != Tab::Agents || self.focused_agent_pid.is_some() {
            return None;
        }
        self.agent_tree_groups().get(self.selected).cloned()
    }

    fn current_session(&self) -> Option<SessionRow> {
        (self.tab == Tab::Sessions)
            .then(|| self.filtered_sessions().get(self.selected).cloned())
            .flatten()
    }

    fn current_event(&self) -> Option<EventRow> {
        match self.tab {
            Tab::Events => self.filtered_events().get(self.selected).cloned(),
            Tab::Sessions if self.focused_session.is_some() => {
                self.focused_session_events().get(self.selected).cloned()
            }
            _ => None,
        }
    }

    fn current_container(&self) -> Option<ContainerRow> {
        self.filtered_containers().get(self.selected).cloned()
    }

    fn descendant_count(&self, pid: u32) -> usize {
        self.process_tree_usage(pid).descendants
    }

    fn process_tree_usage(&self, pid: u32) -> ProcessTreeUsage {
        process_tree_usage(&self.snapshot.processes, pid)
    }

    fn process_tree(&self, pid: u32) -> Option<TreeNode> {
        let mut visited = HashSet::new();
        self.process_tree_node(pid, &mut visited)
    }

    fn process_tree_node(&self, pid: u32, visited: &mut HashSet<u32>) -> Option<TreeNode> {
        if !visited.insert(pid) {
            return None;
        }
        let process = self.snapshot.processes.iter().find(|p| p.pid == pid)?;
        let mut children = self
            .snapshot
            .processes
            .iter()
            .filter(|p| p.ppid == pid)
            .collect::<Vec<_>>();
        children.sort_by_key(|p| p.pid);
        let children = children
            .into_iter()
            .filter_map(|child| self.process_tree_node(child.pid, visited))
            .collect::<Vec<_>>();
        let label = format!(
            "{}  cpu {:.1}% mem {:.1}%  {}",
            process.pid,
            process.cpu_pct,
            process.mem_pct,
            display_cmd(&process.command)
        );
        if children.is_empty() {
            Some(TreeNode::leaf(label))
        } else {
            Some(TreeNode::branch(label, children))
        }
    }

    fn recent_agent_events_for_process(&self, process: &ProcessRow) -> Vec<EventRow> {
        if process.agent.is_none() {
            return Vec::new();
        }
        self.snapshot
            .events
            .iter()
            .filter(|event| self.event_matches_agent_process(process, event))
            .take(3)
            .cloned()
            .collect()
    }

    #[cfg(test)]
    fn recent_agent_events(&self, agent: AgentKind) -> Vec<EventRow> {
        self.snapshot
            .events
            .iter()
            .filter(|event| agent_matches_source(agent, &event.source))
            .take(3)
            .cloned()
            .collect()
    }

    #[cfg(test)]
    fn agent_activity(&self, agent: AgentKind) -> AgentActivity {
        self.agent_activity_from_events(
            self.snapshot
                .events
                .iter()
                .filter(|event| agent_matches_source(agent, &event.source)),
        )
    }

    fn agent_activity_for_process(&self, process: &ProcessRow) -> AgentActivity {
        if process.agent.is_none() {
            return AgentActivity::default();
        }
        self.agent_activity_from_events(
            self.snapshot
                .events
                .iter()
                .filter(|event| self.event_matches_agent_process(process, event)),
        )
    }

    fn agent_activity_from_events<'a>(
        &self,
        events: impl IntoIterator<Item = &'a EventRow>,
    ) -> AgentActivity {
        let mut sessions = HashSet::new();
        let mut activity = AgentActivity::default();
        for event in events {
            activity.events += 1;
            if event.kind == "ToolExec" {
                activity.tools += 1;
            }
            if event.kind == "SecurityAction" {
                activity.security += 1;
            }
            if matches!(event.kind.as_str(), "FileAccess" | "FileDelete") {
                activity.files += 1;
            }
            if event.kind == "Egress" {
                activity.egress += 1;
            }
            if is_llm_event_kind(&event.kind) {
                activity.llm += 1;
            }
            if let Some(tokens) = event_token_usage(event) {
                activity.prompt_tokens += tokens.prompt;
                activity.completion_tokens += tokens.completion;
                activity.total_tokens += tokens.total;
            }
            if activity.model.is_empty() {
                if let Some(model) = event_model(event) {
                    activity.model = model;
                }
            }
            if let Some(network) = event_llm_network(event) {
                if activity.provider.is_empty() {
                    if let Some(provider) = network.provider {
                        activity.provider = provider;
                    }
                }
                if let Some(latency_ms) = network.latency_ms {
                    activity.latency_ms += latency_ms;
                    activity.latency_samples += 1;
                }
                if let Some(ttft_ms) = network.ttft_ms {
                    activity.ttft_ms += ttft_ms;
                    activity.ttft_samples += 1;
                }
                activity.req_bytes += network.req_bytes;
                activity.resp_bytes += network.resp_bytes;
            }
            if event.risk == Risk::High {
                activity.high_risk += 1;
            }
            if let Some(session) = event.session.as_ref().or(event.task.as_ref()) {
                sessions.insert(session.clone());
            }
        }
        activity.sessions = sessions.len();
        activity
    }

    fn event_matches_agent_process(&self, process: &ProcessRow, event: &EventRow) -> bool {
        let Some(agent) = process.agent else {
            return false;
        };
        if !agent_matches_source(agent, &event.source) {
            return false;
        }
        if let Some(pid) = event.pid {
            if self.pid_belongs_to_process(process.pid, pid) {
                return true;
            }
        }
        if let Some(ppid) = event.ppid {
            if self.pid_belongs_to_process(process.pid, ppid) {
                return true;
            }
        }
        if event.pid.is_some() || event.ppid.is_some() {
            return false;
        }
        self.event_workspace_matches_process(process, event)
            || self.agent_source_is_unambiguous(agent)
    }

    fn event_workspace_matches_process(&self, process: &ProcessRow, event: &EventRow) -> bool {
        let Some(agent) = process.agent else {
            return false;
        };
        let Some(workspace) = event_workspace(event) else {
            return false;
        };
        if !process
            .cwd
            .as_deref()
            .is_some_and(|cwd| workspace_paths_overlap(cwd, &workspace))
        {
            return false;
        }

        self.snapshot
            .processes
            .iter()
            .filter(|candidate| candidate.agent == Some(agent))
            .filter(|candidate| {
                candidate
                    .cwd
                    .as_deref()
                    .is_some_and(|cwd| workspace_paths_overlap(cwd, &workspace))
            })
            .take(2)
            .count()
            == 1
    }

    fn pid_belongs_to_process(&self, root_pid: u32, pid: u32) -> bool {
        if pid == root_pid {
            return true;
        }
        let mut current = pid;
        let mut visited = HashSet::new();
        while visited.insert(current) {
            let Some(process) = self.snapshot.processes.iter().find(|p| p.pid == current) else {
                return false;
            };
            if process.ppid == root_pid {
                return true;
            }
            if process.ppid == 0 || process.ppid == current {
                return false;
            }
            current = process.ppid;
        }
        false
    }

    fn agent_source_is_unambiguous(&self, agent: AgentKind) -> bool {
        self.snapshot
            .processes
            .iter()
            .filter(|process| process.agent == Some(agent))
            .count()
            == 1
    }

    fn process_matches_risk_filter(&self, process: &ProcessRow) -> bool {
        if self.risk_filter.matches(process.risk) {
            return true;
        }
        self.risk_filter == RiskFilter::High
            && process.agent.is_some()
            && self.agent_activity_for_process(process).high_risk > 0
    }

    fn process_token_total(&self, process: &ProcessRow) -> u64 {
        self.agent_activity_for_process(process).total_tokens
    }

    fn process_cpu_total(&self, process: &ProcessRow) -> f32 {
        if process.agent.is_some() {
            self.process_tree_usage(process.pid).cpu_pct
        } else {
            process.cpu_pct
        }
    }

    fn process_mem_total(&self, process: &ProcessRow) -> f32 {
        if process.agent.is_some() {
            self.process_tree_usage(process.pid).mem_pct
        } else {
            process.mem_pct
        }
    }

    fn sort_process_rows(&self, rows: &mut [ProcessRow]) {
        match self.sort_by {
            SortBy::Cpu => rows.sort_by(|a, b| {
                b.agent
                    .is_some()
                    .cmp(&a.agent.is_some())
                    .then(
                        self.process_cpu_total(b)
                            .partial_cmp(&self.process_cpu_total(a))
                            .unwrap_or(std::cmp::Ordering::Equal),
                    )
                    .then(a.command.cmp(&b.command))
            }),
            SortBy::Mem => rows.sort_by(|a, b| {
                b.agent
                    .is_some()
                    .cmp(&a.agent.is_some())
                    .then(
                        self.process_mem_total(b)
                            .partial_cmp(&self.process_mem_total(a))
                            .unwrap_or(std::cmp::Ordering::Equal),
                    )
                    .then(a.command.cmp(&b.command))
            }),
            SortBy::Net => rows.sort_by(|a, b| {
                self.agent_activity_for_process(b)
                    .egress
                    .cmp(&self.agent_activity_for_process(a).egress)
                    .then(b.agent.is_some().cmp(&a.agent.is_some()))
                    .then(a.command.cmp(&b.command))
            }),
            SortBy::Pids => rows.sort_by(|a, b| {
                self.process_tree_usage(b.pid)
                    .descendants
                    .cmp(&self.process_tree_usage(a.pid).descendants)
                    .then(b.agent.is_some().cmp(&a.agent.is_some()))
                    .then(a.command.cmp(&b.command))
            }),
            SortBy::Id => rows.sort_by(|a, b| a.pid.cmp(&b.pid).then(a.command.cmp(&b.command))),
            SortBy::Block | SortBy::State | SortBy::Uptime | SortBy::Name => {
                rows.sort_by(|a, b| a.command.cmp(&b.command))
            }
            SortBy::Tokens => rows.sort_by(|a, b| {
                self.process_token_total(b)
                    .cmp(&self.process_token_total(a))
                    .then(b.agent.is_some().cmp(&a.agent.is_some()))
                    .then(
                        self.process_cpu_total(b)
                            .partial_cmp(&self.process_cpu_total(a))
                            .unwrap_or(std::cmp::Ordering::Equal),
                    )
                    .then(a.command.cmp(&b.command))
            }),
        }
    }

    fn column_visible(&self, id: &str) -> bool {
        !self.hidden_columns.contains(id)
    }

    fn configured_column(&self, id: &str, column: DataColumn) -> DataColumn {
        if self.column_visible(id) {
            column
        } else {
            column.hidden()
        }
    }

    fn configured_container_column(&self, id: &str, column: DataColumn) -> DataColumn {
        let column = if container_sort_column_id(self.sort_by) == Some(id) {
            column.header_suffix(if self.reverse_sort { "↑" } else { "↓" })
        } else {
            column
        };
        self.configured_column(id, column)
    }

    fn column_choices(&self, tab: Tab) -> Vec<ColumnChoice> {
        match tab {
            Tab::Agents => vec![
                ColumnChoice {
                    id: "agents.pid",
                    label: "PID",
                },
                ColumnChoice {
                    id: "agents.agent",
                    label: "Agent",
                },
                ColumnChoice {
                    id: "agents.cpu",
                    label: "CPU %",
                },
                ColumnChoice {
                    id: "agents.cpu_history",
                    label: "CPU trend",
                },
                ColumnChoice {
                    id: "agents.mem",
                    label: "Memory %",
                },
                ColumnChoice {
                    id: "agents.mem_history",
                    label: "Memory trend",
                },
                ColumnChoice {
                    id: "agents.risk",
                    label: "Risk",
                },
                ColumnChoice {
                    id: "agents.events",
                    label: "Events",
                },
                ColumnChoice {
                    id: "agents.sessions",
                    label: "Sessions",
                },
                ColumnChoice {
                    id: "agents.session",
                    label: "Top session",
                },
                ColumnChoice {
                    id: "agents.task",
                    label: "Top task",
                },
                ColumnChoice {
                    id: "agents.tools",
                    label: "Tools",
                },
                ColumnChoice {
                    id: "agents.security",
                    label: "Security",
                },
                ColumnChoice {
                    id: "agents.files",
                    label: "Files",
                },
                ColumnChoice {
                    id: "agents.egress",
                    label: "Egress",
                },
                ColumnChoice {
                    id: "agents.llm",
                    label: "LLM",
                },
                ColumnChoice {
                    id: "agents.tokens",
                    label: "Tokens",
                },
                ColumnChoice {
                    id: "agents.model",
                    label: "Model",
                },
                ColumnChoice {
                    id: "agents.provider",
                    label: "Provider",
                },
                ColumnChoice {
                    id: "agents.latency",
                    label: "Latency",
                },
                ColumnChoice {
                    id: "agents.high_risk",
                    label: "High-risk",
                },
                ColumnChoice {
                    id: "agents.children",
                    label: "Children",
                },
                ColumnChoice {
                    id: "agents.elapsed",
                    label: "Elapsed",
                },
                ColumnChoice {
                    id: "agents.cwd",
                    label: "Working directory",
                },
                ColumnChoice {
                    id: "agents.command",
                    label: "Command",
                },
            ],
            Tab::Sessions => vec![
                ColumnChoice {
                    id: "sessions.source",
                    label: "Agent",
                },
                ColumnChoice {
                    id: "sessions.session",
                    label: "Session",
                },
                ColumnChoice {
                    id: "sessions.task",
                    label: "Task",
                },
                ColumnChoice {
                    id: "sessions.workspace",
                    label: "Workspace",
                },
                ColumnChoice {
                    id: "sessions.events",
                    label: "Events",
                },
                ColumnChoice {
                    id: "sessions.tools",
                    label: "Tools",
                },
                ColumnChoice {
                    id: "sessions.security",
                    label: "Security",
                },
                ColumnChoice {
                    id: "sessions.files",
                    label: "Files",
                },
                ColumnChoice {
                    id: "sessions.egress",
                    label: "Egress",
                },
                ColumnChoice {
                    id: "sessions.llm",
                    label: "LLM",
                },
                ColumnChoice {
                    id: "sessions.tokens",
                    label: "Tokens",
                },
                ColumnChoice {
                    id: "sessions.model",
                    label: "Model",
                },
                ColumnChoice {
                    id: "sessions.provider",
                    label: "Provider",
                },
                ColumnChoice {
                    id: "sessions.latency",
                    label: "Latency",
                },
                ColumnChoice {
                    id: "sessions.high_risk",
                    label: "High-risk",
                },
                ColumnChoice {
                    id: "sessions.risk",
                    label: "Risk",
                },
                ColumnChoice {
                    id: "sessions.kind",
                    label: "Last kind",
                },
                ColumnChoice {
                    id: "sessions.message",
                    label: "Last message",
                },
            ],
            Tab::Processes => vec![
                ColumnChoice {
                    id: "processes.pid",
                    label: "PID",
                },
                ColumnChoice {
                    id: "processes.ppid",
                    label: "PPID",
                },
                ColumnChoice {
                    id: "processes.cpu",
                    label: "CPU %",
                },
                ColumnChoice {
                    id: "processes.cpu_history",
                    label: "CPU trend",
                },
                ColumnChoice {
                    id: "processes.mem",
                    label: "Memory %",
                },
                ColumnChoice {
                    id: "processes.mem_history",
                    label: "Memory trend",
                },
                ColumnChoice {
                    id: "processes.risk",
                    label: "Risk",
                },
                ColumnChoice {
                    id: "processes.elapsed",
                    label: "Elapsed",
                },
                ColumnChoice {
                    id: "processes.cwd",
                    label: "Working directory",
                },
                ColumnChoice {
                    id: "processes.command",
                    label: "Command",
                },
            ],
            Tab::Containers => vec![
                ColumnChoice {
                    id: "containers.status",
                    label: "Status",
                },
                ColumnChoice {
                    id: "containers.name",
                    label: "Name",
                },
                ColumnChoice {
                    id: "containers.id",
                    label: "CID",
                },
                ColumnChoice {
                    id: "containers.cpu",
                    label: "CPU",
                },
                ColumnChoice {
                    id: "containers.cpus",
                    label: "CPU scaled",
                },
                ColumnChoice {
                    id: "containers.mem",
                    label: "Memory",
                },
                ColumnChoice {
                    id: "containers.net",
                    label: "Net I/O",
                },
                ColumnChoice {
                    id: "containers.block",
                    label: "IO",
                },
                ColumnChoice {
                    id: "containers.pids",
                    label: "PIDs",
                },
                ColumnChoice {
                    id: "containers.uptime",
                    label: "Uptime",
                },
                ColumnChoice {
                    id: "containers.image",
                    label: "Image",
                },
                ColumnChoice {
                    id: "containers.mem_usage",
                    label: "Memory usage",
                },
                ColumnChoice {
                    id: "containers.ports",
                    label: "Ports",
                },
                ColumnChoice {
                    id: "containers.health",
                    label: "Health",
                },
            ],
            Tab::Events => vec![
                ColumnChoice {
                    id: "events.time",
                    label: "Time",
                },
                ColumnChoice {
                    id: "events.source",
                    label: "Source",
                },
                ColumnChoice {
                    id: "events.session",
                    label: "Session",
                },
                ColumnChoice {
                    id: "events.task",
                    label: "Task",
                },
                ColumnChoice {
                    id: "events.pid",
                    label: "PID",
                },
                ColumnChoice {
                    id: "events.ppid",
                    label: "PPID",
                },
                ColumnChoice {
                    id: "events.kind",
                    label: "Kind",
                },
                ColumnChoice {
                    id: "events.risk",
                    label: "Risk",
                },
                ColumnChoice {
                    id: "events.message",
                    label: "Message",
                },
            ],
        }
    }

    fn ensure_visible_columns(&mut self) {
        for tab in Tab::ALL {
            let choices = self.column_choices(tab);
            if !choices.is_empty()
                && choices
                    .iter()
                    .all(|choice| self.hidden_columns.contains(choice.id))
            {
                self.hidden_columns.remove(choices[0].id);
            }
        }
    }

    fn filtered_agents(&self) -> Vec<ProcessRow> {
        let mut rows: Vec<_> = self
            .snapshot
            .processes
            .iter()
            .filter(|p| p.agent.is_some())
            .filter(|p| self.focused_agent_pid.is_none_or(|pid| p.pid == pid))
            .filter(|p| self.process_matches_risk_filter(p))
            .filter(|p| self.agent_matches_filter(p))
            .cloned()
            .collect();
        self.sort_process_rows(&mut rows);
        if self.reverse_sort {
            rows.reverse();
        }
        rows
    }

    fn filtered_processes(&self) -> Vec<ProcessRow> {
        let mut rows: Vec<_> = self
            .snapshot
            .processes
            .iter()
            .filter(|p| self.process_matches_risk_filter(p))
            .filter(|p| {
                self.matches_filter(&p.command)
                    || self.matches_filter(&p.pid.to_string())
                    || p.cwd.as_ref().is_some_and(|cwd| self.matches_filter(cwd))
            })
            .cloned()
            .collect();
        self.sort_process_rows(&mut rows);
        if self.reverse_sort {
            rows.reverse();
        }
        rows
    }

    fn filtered_sessions(&self) -> Vec<SessionRow> {
        let events = self
            .snapshot
            .events
            .iter()
            .filter(|event| self.event_matches_scope_filters(event))
            .cloned()
            .collect::<Vec<_>>();
        let mut rows = session_rows(&events)
            .into_iter()
            .filter(|row| {
                self.matches_filter(&row.source)
                    || self.matches_filter(&row.session)
                    || self.matches_filter(&row.task)
                    || self.matches_filter(&row.workspace)
                    || self.matches_filter(&row.last_kind)
                    || self.matches_filter(&row.last_message)
            })
            .collect::<Vec<_>>();
        sort_sessions(&mut rows, self.sort_by);
        if self.reverse_sort {
            rows.reverse();
        }
        rows
    }

    fn filtered_containers(&self) -> Vec<ContainerRow> {
        let mut rows: Vec<_> = self
            .snapshot
            .containers
            .iter()
            .filter(|c| {
                self.focused_container
                    .as_ref()
                    .is_none_or(|query| container_matches_query(c, query))
                    && (self.matches_filter(&c.name)
                        || self.matches_filter(&c.id)
                        || self.matches_filter(short_id(&c.id))
                        || self.matches_filter(&c.image)
                        || self.matches_filter(&c.status)
                        || self.matches_filter(&c.ports)
                        || self.matches_filter(&c.inspect.health)
                        || self.matches_filter(&c.inspect.restart_policy)
                        || self.matches_filter(&c.inspect.mounts)
                        || self.matches_filter(&c.inspect.labels)
                        || self.matches_filter(&c.inspect.networks))
            })
            .cloned()
            .collect();
        match self.sort_by {
            SortBy::Cpu => rows.sort_by(|a, b| {
                b.cpu_pct
                    .unwrap_or_default()
                    .partial_cmp(&a.cpu_pct.unwrap_or_default())
                    .unwrap_or(std::cmp::Ordering::Equal)
            }),
            SortBy::Mem => rows.sort_by(|a, b| {
                b.mem_pct
                    .unwrap_or_default()
                    .partial_cmp(&a.mem_pct.unwrap_or_default())
                    .unwrap_or(std::cmp::Ordering::Equal)
                    .then(a.name.cmp(&b.name))
            }),
            SortBy::Net => rows.sort_by(|a, b| {
                container_net_total(b)
                    .cmp(&container_net_total(a))
                    .then(a.name.cmp(&b.name))
            }),
            SortBy::Block => rows.sort_by(|a, b| {
                container_block_total(b)
                    .cmp(&container_block_total(a))
                    .then(a.name.cmp(&b.name))
            }),
            SortBy::Pids => rows.sort_by(|a, b| {
                container_pid_count(b)
                    .cmp(&container_pid_count(a))
                    .then(a.name.cmp(&b.name))
            }),
            SortBy::State => rows.sort_by(|a, b| {
                container_state_rank(b)
                    .cmp(&container_state_rank(a))
                    .then(a.name.cmp(&b.name))
            }),
            SortBy::Id => rows.sort_by(|a, b| a.id.cmp(&b.id).then(a.name.cmp(&b.name))),
            SortBy::Uptime => rows.sort_by(|a, b| {
                container_uptime_seconds(b)
                    .cmp(&container_uptime_seconds(a))
                    .then(a.name.cmp(&b.name))
            }),
            SortBy::Name | SortBy::Tokens => rows.sort_by(|a, b| a.name.cmp(&b.name)),
        }
        if self.reverse_sort {
            rows.reverse();
        }
        rows
    }

    fn filtered_events(&self) -> Vec<EventRow> {
        self.snapshot
            .events
            .iter()
            .filter(|event| self.event_matches_scope_filters(event))
            .filter(|e| self.event_matches_filter(e))
            .cloned()
            .collect()
    }

    fn focused_session_events(&self) -> Vec<EventRow> {
        let Some(focus) = &self.focused_session else {
            return Vec::new();
        };
        self.snapshot
            .events
            .iter()
            .filter(|event| session_focus_matches_event(focus, event))
            .filter(|event| self.event_matches_scope_filters(event))
            .filter(|event| self.event_matches_filter(event))
            .cloned()
            .collect()
    }

    fn focused_session_row(&self) -> Option<SessionRow> {
        let focus = self.focused_session.as_ref()?;
        let events = self
            .snapshot
            .events
            .iter()
            .filter(|event| self.event_matches_scope_filters(event))
            .cloned()
            .collect::<Vec<_>>();
        session_rows(&events)
            .into_iter()
            .find(|row| row.source == focus.source && row.session == focus.session)
    }

    fn event_matches_scope_filters(&self, event: &EventRow) -> bool {
        self.risk_filter.matches(event.risk) && self.kind_filter.matches(&event.kind)
    }

    fn event_matches_filter(&self, event: &EventRow) -> bool {
        self.matches_filter(&event.source)
            || event
                .session
                .as_ref()
                .is_some_and(|session| self.matches_filter(session))
            || event
                .task
                .as_ref()
                .is_some_and(|task| self.matches_filter(task))
            || self.matches_filter(&event.kind)
            || self.matches_filter(&event.message)
            || event
                .details
                .iter()
                .any(|(key, value)| self.matches_filter(key) || self.matches_filter(value))
    }

    fn agent_matches_filter(&self, process: &ProcessRow) -> bool {
        if self.filter.is_empty() {
            return true;
        }
        if self.matches_filter(&process.command)
            || self.matches_filter(&process.pid.to_string())
            || process
                .cwd
                .as_ref()
                .is_some_and(|cwd| self.matches_filter(cwd))
            || process
                .agent
                .is_some_and(|agent| self.matches_filter(agent.label()))
        {
            return true;
        }

        let activity = self.agent_activity_for_process(process);
        if self.matches_filter(&activity.model)
            || self.matches_filter(&activity.provider)
            || self.matches_filter(&format_count(activity.total_tokens))
        {
            return true;
        }

        agent_session_rows(self, process).iter().any(|session| {
            self.matches_filter(&session.source)
                || self.matches_filter(&session.session)
                || self.matches_filter(&session.task)
                || self.matches_filter(&session.workspace)
                || self.matches_filter(&session.model)
                || self.matches_filter(&session.provider)
                || self.matches_filter(&session.last_kind)
                || self.matches_filter(&session.last_message)
        }) || self
            .snapshot
            .events
            .iter()
            .filter(|event| self.event_matches_agent_process(process, event))
            .any(|event| self.event_matches_filter(event))
    }

    fn matches_filter(&self, text: &str) -> bool {
        self.filter.is_empty() || text.to_lowercase().contains(&self.filter.to_lowercase())
    }

    fn header(&self) -> String {
        let agents = self
            .snapshot
            .processes
            .iter()
            .filter(|p| p.agent.is_some())
            .count();
        let containers = container_state_summary(&self.snapshot.containers);
        let processes = self.snapshot.processes.len();
        let high_events = self
            .snapshot
            .events
            .iter()
            .filter(|event| event.risk == Risk::High)
            .count();
        let llm_events = self
            .snapshot
            .events
            .iter()
            .filter(|event| is_llm_event_kind(&event.kind))
            .count();
        let total_tokens = self
            .snapshot
            .events
            .iter()
            .filter_map(event_token_usage)
            .map(|tokens| tokens.total)
            .sum::<u64>();
        let title = format!(
            " a3s top  boxes:{}  agents:{agents}  processes:{processes}  events:{} high:{high_events} llm:{llm_events} tok:{} ",
            containers.header_label(),
            self.snapshot.events.len(),
            format_count(total_tokens)
        );
        let right = if self.paused {
            "paused".to_string()
        } else {
            self.last_refresh
                .map(|t| format!("refreshed {:.1}s ago", t.elapsed().as_secs_f32()))
                .unwrap_or_else(|| "loading".to_string())
        };
        StatusBar::new()
            .left(title)
            .right(right)
            .fg(Color::BrightWhite)
            .bg(Color::Rgb(35, 40, 60))
            .bold(true)
            .view(self.width)
    }

    fn tabs(&self) -> String {
        let mut tabs = Tab::PRIMARY.to_vec();
        if !tabs.contains(&self.tab) {
            tabs.push(self.tab);
        }
        let active = tabs.iter().position(|tab| *tab == self.tab).unwrap_or(0);
        let labels = tabs.iter().map(|tab| tab.label()).collect::<Vec<_>>();
        let mut panel = Tabs::new(labels)
            .active_colors(Color::Black, ACCENT)
            .inactive_color(Color::BrightBlack)
            .suffix_color(Color::BrightBlack)
            .gap(1);
        panel.set_active(active);

        let filter = if self.editing_filter {
            format!("/{}_", self.filter)
        } else if self.filter.is_empty() {
            "/ filter".to_string()
        } else {
            format!("/{}", self.filter)
        };
        panel = panel.segment(TabSegment::new(filter));
        if let Some(id) = &self.focused_container {
            let label = self
                .snapshot
                .containers
                .iter()
                .find(|c| c.id == *id || short_id(&c.id) == id)
                .map(|c| c.name.as_str())
                .unwrap_or(id);
            panel = panel
                .segment(TabSegment::new(format!("focus:{}", truncate(label, 20))).color(CYAN));
        }
        if let Some(pid) = self.focused_agent_pid {
            let label = self
                .snapshot
                .processes
                .iter()
                .find(|p| p.pid == pid)
                .and_then(|p| p.agent.map(|agent| agent.label()))
                .unwrap_or("agent");
            panel = panel.segment(TabSegment::new(format!("agent:{label}/{pid}")).color(ACCENT));
        }
        if let Some(focus) = &self.focused_session {
            panel = panel.segment(
                TabSegment::new(format!(
                    "session:{}/{}",
                    focus.source,
                    truncate(&focus.session, 18)
                ))
                .color(ORANGE),
            );
        }
        panel.view(self.width)
    }

    fn table(&self) -> String {
        if self.column_panel.is_some() {
            return self.column_panel_view();
        }
        if self.sort_panel.is_some() {
            return self.sort_panel_view();
        }
        if self.connector_panel.is_some() {
            return self.connector_panel_view();
        }
        if self.log.is_some() {
            return self.logs_view();
        }
        if self.container_menu.is_some() {
            return self.container_menu_view();
        }
        if self.help {
            return self.help_view();
        }
        match self.tab {
            Tab::Agents if self.focused_agent_pid.is_some() => {
                self.agent_focus_view(self.filtered_agents())
            }
            Tab::Agents => self.agents_tree_view(),
            Tab::Sessions if self.focused_session.is_some() => {
                self.session_focus_view(self.focused_session_events())
            }
            Tab::Sessions => self.sessions_table(self.filtered_sessions()),
            Tab::Processes => self.render_process_tab(),
            Tab::Containers if self.focused_container.is_some() => {
                self.container_focus_view(self.filtered_containers())
            }
            Tab::Containers => self.container_table(self.filtered_containers()),
            Tab::Events => self.events_table(self.filtered_events()),
        }
    }

    fn agents_tree_view(&self) -> String {
        let root = self.agents_tree_root();
        Tree::new(root)
            .branch_color(ACCENT)
            .leaf_color(Color::BrightBlack)
            .view(self.width, self.visible_height() + 2)
    }

    fn agents_tree_root(&self) -> TreeNode {
        let groups = self.agent_tree_groups();
        let mut children = groups
            .iter()
            .enumerate()
            .map(|(idx, group)| self.agent_tree_agent_node(group, idx == self.selected))
            .collect::<Vec<_>>();

        if children.is_empty() {
            children.push(TreeNode::leaf(
                "no coding-agent activity matched the current filters",
            ));
        }

        TreeNode::branch(self.agent_tree_summary_label(&groups), children)
    }

    fn agent_tree_groups(&self) -> Vec<AgentTreeGroup> {
        let sessions = self.filtered_sessions();
        let processes = self.filtered_agents();
        let events = self.filtered_events();
        let mut groups = AgentKind::ALL
            .iter()
            .copied()
            .filter_map(|agent| {
                let sessions = sessions
                    .iter()
                    .filter(|session| agent_matches_source(agent, &session.source))
                    .cloned()
                    .collect::<Vec<_>>();
                let processes = processes
                    .iter()
                    .filter(|process| process.agent == Some(agent))
                    .cloned()
                    .collect::<Vec<_>>();
                let events = events
                    .iter()
                    .filter(|event| agent_matches_source(agent, &event.source))
                    .cloned()
                    .collect::<Vec<_>>();
                if !self.filter.is_empty()
                    && sessions.is_empty()
                    && processes.is_empty()
                    && events.is_empty()
                {
                    return None;
                }
                let activity = self.agent_activity_from_events(events.iter());
                let usage = self.agent_tree_usage(&processes);
                Some(AgentTreeGroup {
                    agent,
                    sessions,
                    processes,
                    events,
                    activity,
                    usage,
                })
            })
            .collect::<Vec<_>>();
        groups.sort_by(|a, b| {
            b.active()
                .cmp(&a.active())
                .then(risk_rank(b.risk()).cmp(&risk_rank(a.risk())))
                .then(
                    b.usage
                        .cpu_pct
                        .partial_cmp(&a.usage.cpu_pct)
                        .unwrap_or(std::cmp::Ordering::Equal),
                )
                .then(b.activity.total_tokens.cmp(&a.activity.total_tokens))
                .then(b.events.len().cmp(&a.events.len()))
                .then(agent_order(a.agent).cmp(&agent_order(b.agent)))
        });
        groups
    }

    fn agent_tree_summary_label(&self, groups: &[AgentTreeGroup]) -> String {
        let active = groups.iter().filter(|group| group.active()).count();
        let sessions = groups
            .iter()
            .map(|group| group.sessions.len())
            .sum::<usize>();
        let processes = groups
            .iter()
            .map(|group| self.agent_system_processes(group).len())
            .sum::<usize>();
        let events = groups.iter().map(|group| group.events.len()).sum::<usize>();
        let high = groups
            .iter()
            .map(|group| group.activity.high_risk)
            .sum::<usize>();
        let tokens = groups
            .iter()
            .map(|group| group.activity.total_tokens)
            .sum::<u64>();
        format!(
            "Agents · active {active}/{} · sessions {sessions} · processes {processes} · events {events} · high {high} · tok {}",
            groups.len(),
            format_count(tokens)
        )
    }

    fn agent_tree_agent_node(&self, group: &AgentTreeGroup, selected: bool) -> TreeNode {
        let activity = &group.activity;
        let usage = group.usage;
        let state = agent_tree_state_label(group);
        let system_processes = self.agent_system_processes(group);
        let prefix = if selected { "> " } else { "  " };
        let label = agent_tree_label(
            group.agent,
            format!(
                "{prefix}{} [{state}] · S{} P{} E{} · CPU {:.1}% · MEM {:.1}% · TOK {}",
                group.agent.label(),
                group.sessions.len(),
                system_processes.len(),
                group.events.len(),
                usage.cpu_pct,
                usage.mem_pct,
                format_count(activity.total_tokens)
            ),
        );
        TreeNode::branch(
            label,
            vec![
                self.agent_tree_resources_node(group),
                self.agent_tree_sessions_node(group.agent, &group.sessions),
                self.agent_tree_processes_node(
                    group.agent,
                    &system_processes,
                    &group.processes,
                    selected,
                ),
                self.agent_tree_events_node(group.agent, &group.events),
            ],
        )
    }

    fn agent_tree_resources_node(&self, group: &AgentTreeGroup) -> TreeNode {
        let history = self.agent_group_history(&group.processes);
        let activity = &group.activity;
        let mut children = vec![
            TreeNode::leaf(agent_tree_label(
                group.agent,
                format!(
                    "CPU {:>5.1}%  {}",
                    group.usage.cpu_pct,
                    self.sparkline(&history.cpu, metric_color(group.usage.cpu_pct))
                ),
            )),
            TreeNode::leaf(agent_tree_label(
                group.agent,
                format!(
                    "MEM {:>5.1}%  {}",
                    group.usage.mem_pct,
                    self.sparkline(&history.mem, metric_color(group.usage.mem_pct))
                ),
            )),
            TreeNode::leaf(agent_tree_label(
                group.agent,
                format!("children {}", group.usage.descendants),
            )),
        ];
        let model = display_model(&activity.model);
        let provider = display_provider(&activity.provider);
        let latency = format_avg_ms(activity.latency_ms, activity.latency_samples);
        let ttft = format_avg_ms(activity.ttft_ms, activity.ttft_samples);
        if activity.events > 0 || activity.total_tokens > 0 {
            children.push(TreeNode::leaf(agent_tree_label(group.agent, format!(
                "activity evt {} · tool {} · sec {} · file {} · net {} · llm {} · tok {} · model {} · provider {} · lat {} · ttft {} · high {}",
                activity.events,
                activity.tools,
                activity.security,
                activity.files,
                activity.egress,
                activity.llm,
                format_count(activity.total_tokens),
                model,
                provider,
                latency,
                ttft,
                activity.high_risk
            ))));
        }
        TreeNode::branch(agent_tree_label(group.agent, "Resources"), children)
    }

    fn agent_group_history(&self, processes: &[ProcessRow]) -> MetricHistory {
        let mut history = MetricHistory::default();
        for process in self.agent_group_root_processes(processes) {
            let process_history = self.metric_history(&agent_tree_history_key(process.pid));
            add_aligned_f32_history(&mut history.cpu, &process_history.cpu);
            add_aligned_f32_history(&mut history.mem, &process_history.mem);
        }
        history
    }

    fn agent_tree_usage(&self, processes: &[ProcessRow]) -> ProcessTreeUsage {
        let mut usage = ProcessTreeUsage::default();
        for process in self.agent_group_root_processes(processes) {
            let tree = self.process_tree_usage(process.pid);
            usage.cpu_pct += tree.cpu_pct;
            usage.mem_pct += tree.mem_pct;
            usage.descendants += tree.descendants;
        }
        usage
    }

    fn agent_group_root_processes(&self, processes: &[ProcessRow]) -> Vec<ProcessRow> {
        let agent_pids = processes
            .iter()
            .map(|process| process.pid)
            .collect::<HashSet<_>>();
        processes
            .iter()
            .filter(|process| !self.has_ancestor_in_set(process.pid, &agent_pids))
            .cloned()
            .collect()
    }

    fn has_ancestor_in_set(&self, pid: u32, ancestors: &HashSet<u32>) -> bool {
        let mut current = pid;
        let mut visited = HashSet::new();
        while visited.insert(current) {
            let Some(process) = self
                .snapshot
                .processes
                .iter()
                .find(|process| process.pid == current)
            else {
                return false;
            };
            if ancestors.contains(&process.ppid) {
                return true;
            }
            if process.ppid == 0 || process.ppid == current {
                return false;
            }
            current = process.ppid;
        }
        false
    }

    fn agent_system_processes(&self, group: &AgentTreeGroup) -> Vec<ProcessRow> {
        let mut roots = self.agent_group_root_processes(&group.processes);
        roots.sort_by_key(|process| process.pid);
        let mut rows = Vec::new();
        let mut visited = roots
            .iter()
            .map(|process| process.pid)
            .collect::<HashSet<_>>();
        for root in roots {
            self.collect_agent_system_processes(group.agent, root.pid, &mut visited, &mut rows);
        }
        rows
    }

    fn collect_agent_system_processes(
        &self,
        agent: AgentKind,
        parent_pid: u32,
        visited: &mut HashSet<u32>,
        rows: &mut Vec<ProcessRow>,
    ) {
        let mut children = self
            .snapshot
            .processes
            .iter()
            .filter(|process| process.ppid == parent_pid)
            .cloned()
            .collect::<Vec<_>>();
        children.sort_by_key(|process| process.pid);

        for child in children {
            if !visited.insert(child.pid) {
                continue;
            }
            match child.agent {
                Some(child_agent) if child_agent != agent => continue,
                Some(_) => {
                    self.collect_agent_system_processes(agent, child.pid, visited, rows);
                }
                None => {
                    rows.push(child.clone());
                    self.collect_agent_system_processes(agent, child.pid, visited, rows);
                }
            }
        }
    }

    fn agent_tree_sessions_node(&self, agent: AgentKind, sessions: &[SessionRow]) -> TreeNode {
        let mut children = sessions
            .iter()
            .take(AGENTS_TREE_SESSION_LIMIT)
            .map(|session| {
                let latency = format_avg_ms(session.latency_ms, session.latency_samples);
                TreeNode::leaf(agent_tree_label(agent, format!(
                    "{} · task {} · cwd {} · evt {} · tools {} · llm {} · tok {} · model {} · lat {} · risk {}",
                    session.session,
                    session.task,
                    display_workspace(Some(&session.workspace)),
                    session.events,
                    session.tools,
                    session.llm,
                    format_count(session.total_tokens),
                    display_model(&session.model),
                    latency,
                    session.risk.label()
                )))
            })
            .collect::<Vec<_>>();
        push_agent_more_leaf(
            &mut children,
            sessions.len(),
            AGENTS_TREE_SESSION_LIMIT,
            "sessions",
            agent,
        );
        if children.is_empty() {
            children.push(TreeNode::leaf(agent_tree_label(agent, "no sessions")));
        }
        TreeNode::branch(
            agent_tree_label(agent, format!("Sessions ({})", sessions.len())),
            children,
        )
    }

    fn agent_tree_processes_node(
        &self,
        agent: AgentKind,
        system_processes: &[ProcessRow],
        agent_processes: &[ProcessRow],
        selected_group: bool,
    ) -> TreeNode {
        let mut children = system_processes
            .iter()
            .enumerate()
            .take(AGENTS_TREE_PROCESS_LIMIT)
            .map(|(idx, process)| {
                let marker = if selected_group && idx == 0 {
                    "> "
                } else {
                    "  "
                };
                TreeNode::leaf(agent_tree_label(
                    agent,
                    format!(
                        "{marker}pid {} · ppid {} · CPU {:.1}% · MEM {:.1}% · {}",
                        process.pid,
                        process.ppid,
                        process.cpu_pct,
                        process.mem_pct,
                        display_cmd(&process.command)
                    ),
                ))
            })
            .collect::<Vec<_>>();
        push_agent_more_leaf(
            &mut children,
            system_processes.len(),
            AGENTS_TREE_PROCESS_LIMIT,
            "processes",
            agent,
        );
        if children.is_empty() {
            children.push(TreeNode::leaf(agent_tree_label(
                agent,
                "no system processes from this agent",
            )));
        }
        TreeNode::branch(
            agent_tree_label(
                agent,
                format!(
                    "Processes ({} system · {} agent)",
                    system_processes.len(),
                    agent_processes.len()
                ),
            ),
            children,
        )
    }

    fn agent_tree_events_node(&self, agent: AgentKind, events: &[EventRow]) -> TreeNode {
        let mut children = events
            .iter()
            .take(AGENTS_TREE_EVENT_LIMIT)
            .map(|event| {
                TreeNode::leaf(agent_tree_label(
                    agent,
                    format!(
                        "{} · {} · {} · {} · {}",
                        event.ts,
                        event.kind,
                        event.risk.label(),
                        event_scope_label(event),
                        event.message
                    ),
                ))
            })
            .collect::<Vec<_>>();
        push_agent_more_leaf(
            &mut children,
            events.len(),
            AGENTS_TREE_EVENT_LIMIT,
            "events",
            agent,
        );
        if children.is_empty() {
            children.push(TreeNode::leaf(agent_tree_label(agent, "no recent events")));
        }
        TreeNode::branch(
            agent_tree_label(agent, format!("Events ({})", events.len())),
            children,
        )
    }

    /// Render the Processes tab via the shared process-table renderer, feeding
    /// it this app's live CPU/MEM history for the sparkline columns.
    fn render_process_tab(&self) -> String {
        let rows = self.filtered_processes();
        let history = |pid: u32| {
            let h = self.metric_history(&process_history_key(pid));
            (h.cpu, h.mem)
        };
        render_process_table(
            &rows,
            &ProcessTableView {
                selected: self.selected,
                scroll: self.scroll,
                width: self.width,
                height: self.visible_height() + 2,
                hidden: &self.hidden_columns,
                history: Some(&history),
            },
        )
    }

    fn sessions_table(&self, rows: Vec<SessionRow>) -> String {
        let mut table = DataTable::new(vec![
            self.configured_column("sessions.source", DataColumn::new("AGENT").width(12)),
            self.configured_column("sessions.session", DataColumn::new("SESSION").width(14)),
            self.configured_column("sessions.task", DataColumn::new("TASK").width(12)),
            self.configured_column("sessions.workspace", DataColumn::new("CWD").width(18)),
            self.configured_column(
                "sessions.events",
                DataColumn::new("EVT").width(5).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.tools",
                DataColumn::new("TOOL").width(5).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.security",
                DataColumn::new("SEC").width(5).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.files",
                DataColumn::new("FILE").width(5).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.egress",
                DataColumn::new("NET").width(5).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.llm",
                DataColumn::new("LLM").width(5).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.tokens",
                DataColumn::new("TOK").width(7).align(CellAlign::Right),
            ),
            self.configured_column("sessions.model", DataColumn::new("MODEL").width(14)),
            self.configured_column("sessions.provider", DataColumn::new("PROV").width(10)),
            self.configured_column(
                "sessions.latency",
                DataColumn::new("LAT").width(7).align(CellAlign::Right),
            ),
            self.configured_column(
                "sessions.high_risk",
                DataColumn::new("HIGH").width(5).align(CellAlign::Right),
            ),
            self.configured_column("sessions.risk", DataColumn::new("RISK").width(5)),
            self.configured_column("sessions.kind", DataColumn::new("LAST").width(12)),
            self.configured_column("sessions.message", DataColumn::new("MESSAGE").min_width(18)),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .selected((!rows.is_empty()).then_some(self.selected))
        .scroll(self.scroll)
        .empty("no agent sessions yet - auto-discovery watches Claude, Codex, and A3S Code logs");

        for row in rows {
            table.add_row(
                DataRow::new(vec![
                    row.source,
                    row.session,
                    row.task,
                    row.workspace,
                    row.events.to_string(),
                    row.tools.to_string(),
                    row.security.to_string(),
                    row.files.to_string(),
                    row.egress.to_string(),
                    row.llm.to_string(),
                    format_count(row.total_tokens),
                    display_model(&row.model),
                    display_provider(&row.provider),
                    format_avg_ms(row.latency_ms, row.latency_samples),
                    row.high_risk.to_string(),
                    row.risk.label().to_string(),
                    row.last_kind,
                    row.last_message,
                ])
                .fg(row.risk.color()),
            );
        }
        table.view(self.width, self.visible_height() + 2)
    }

    fn session_focus_view(&self, events: Vec<EventRow>) -> String {
        let width = self.width as usize;
        let Some(focus) = &self.focused_session else {
            return String::new();
        };

        let row = self.focused_session_row();
        let risk = row.as_ref().map(|row| row.risk).unwrap_or(Risk::Low);
        let mut out = Vec::new();
        let mut header =
            SectionHeader::new(format!("session view {} · {}", focus.source, focus.session))
                .title_color(risk.color())
                .metadata_color(Color::BrightBlack)
                .muted_color(Color::BrightBlack)
                .separator_color(Color::BrightBlack);
        if let Some(row) = &row {
            header = header.metadata(format!(
                "task {} · cwd {} · events {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · ttft {} · wire {} / {} · high {} · risk {}",
                row.task,
                row.workspace,
                row.events,
                row.tools,
                row.security,
                row.files,
                row.egress,
                row.llm,
                format_count(row.total_tokens),
                display_model(&row.model),
                display_provider(&row.provider),
                format_avg_ms(row.latency_ms, row.latency_samples),
                format_avg_ms(row.ttft_ms, row.ttft_samples),
                format_bytes(row.req_bytes),
                format_bytes(row.resp_bytes),
                row.high_risk,
                row.risk.label()
            ));
        } else {
            header = header.muted("focused session has no events");
        }
        out.extend(header.view(self.width, 3).lines().map(ToString::to_string));

        let mut table = DataTable::new(vec![
            DataColumn::new("TIME").width(9),
            DataColumn::new("KIND").width(12),
            DataColumn::new("RISK").width(5),
            DataColumn::new("TASK").width(12),
            DataColumn::new("PID").width(7).align(CellAlign::Right),
            DataColumn::new("PPID").width(7).align(CellAlign::Right),
            DataColumn::new("MESSAGE").min_width(18),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .selected((!events.is_empty()).then_some(self.selected))
        .scroll(self.scroll)
        .empty("no events match this session");

        for event in events {
            table.add_row(
                DataRow::new(vec![
                    event.ts,
                    event.kind,
                    event.risk.label().to_string(),
                    event.task.unwrap_or_else(|| "-".to_string()),
                    format_event_pid(event.pid),
                    format_event_pid(event.ppid),
                    event.message,
                ])
                .fg(event.risk.color()),
            );
        }

        let height = self.visible_height() + 2;
        let table_height = height.saturating_sub(out.len()).max(3);
        out.push(table.view(self.width, table_height));
        out.join("\n")
            .lines()
            .take(height)
            .map(|line| pad_line(line, width))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn agent_focus_view(&self, rows: Vec<ProcessRow>) -> String {
        let width = self.width as usize;
        let Some(row) = rows.first() else {
            return Style::new()
                .fg(Color::BrightBlack)
                .italic()
                .render(&pad_plain(" focused agent is no longer present", width));
        };
        let agent = row.agent.unwrap_or(AgentKind::A3sCode);
        let color = agent.color();
        let usage = self.process_tree_usage(row.pid);
        let history = self.metric_history(&agent_tree_history_key(row.pid));
        let activity = self.agent_activity_for_process(row);
        let mut out = Vec::new();

        out.extend(
            SectionHeader::new(format!("agent view {} · pid {}", agent.label(), row.pid))
                .metadata(format!(
                    "ppid {} · elapsed {} · children {} · subtree cpu {:.1}% mem {:.1}% · risk {} · cwd {}",
                    row.ppid,
                    row.elapsed,
                    usage.descendants,
                    usage.cpu_pct,
                    usage.mem_pct,
                    row.risk.label(),
                    display_workspace(row.cwd.as_deref())
                ))
                .title_color(color)
                .metadata_color(Color::BrightBlack)
                .separator_color(Color::BrightBlack)
                .view(self.width, 3)
                .lines()
                .map(ToString::to_string),
        );
        out.push(
            Meter::new(usage.cpu_pct as f64)
                .label("CPU")
                .width(width)
                .fg(metric_color(usage.cpu_pct))
                .view(),
        );
        out.push(self.focus_trend_line("CPU trend", &history.cpu, metric_color(usage.cpu_pct)));
        out.push(
            Meter::new(usage.mem_pct as f64)
                .label("MEM")
                .width(width)
                .fg(metric_color(usage.mem_pct))
                .view(),
        );
        out.push(self.focus_trend_line("MEM trend", &history.mem, metric_color(usage.mem_pct)));
        out.push(divider_line(self.width));
        out.extend(
            WrappedPrefixBlock::new(format!(
                "activity events {} · sessions {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · ttft {} · wire {} / {} · high {}",
                activity.events,
                activity.sessions,
                activity.tools,
                activity.security,
                activity.files,
                activity.egress,
                activity.llm,
                format_count(activity.total_tokens),
                display_model(&activity.model),
                display_provider(&activity.provider),
                format_avg_ms(activity.latency_ms, activity.latency_samples),
                format_avg_ms(activity.ttft_ms, activity.ttft_samples),
                format_bytes(activity.req_bytes),
                format_bytes(activity.resp_bytes),
                activity.high_risk
            ))
            .first_prefix(" ")
            .continuation_prefix(" ")
            .width(width)
            .view()
            .lines()
            .map(ToString::to_string),
        );
        let height = self.visible_height() + 2;
        let recent_events = self.recent_agent_events_for_process(row);
        if !recent_events.is_empty() {
            out.push(divider_line(self.width));
            let event_height = recent_events.len().saturating_add(2).min(5);
            out.extend(
                self.agent_events_table(&recent_events, event_height)
                    .lines()
                    .map(ToString::to_string),
            );
        }
        out.extend(
            WrappedPrefixBlock::new(format!("command {}", row.command))
                .first_prefix(" ")
                .continuation_prefix(" ")
                .width(width)
                .view()
                .lines()
                .map(ToString::to_string),
        );

        let remaining = height.saturating_sub(out.len());
        if remaining > 7 {
            let reserve_tree = self.process_tree(row.pid).map_or(0, |_| 4);
            let session_height = remaining.saturating_sub(1 + reserve_tree).min(7);
            if session_height >= 3 {
                out.push(divider_line(self.width));
                let sessions = agent_session_rows(self, row);
                out.extend(
                    self.agent_sessions_table(&sessions, session_height)
                        .lines()
                        .map(ToString::to_string),
                );
            }
        }

        let remaining = height.saturating_sub(out.len());
        if remaining > 3 {
            out.push(divider_line(self.width));
            if let Some(tree) = self.process_tree(row.pid) {
                let tree = Tree::new(tree)
                    .branch_color(color)
                    .leaf_color(Color::BrightBlack)
                    .view(self.width, remaining.saturating_sub(1));
                out.extend(tree.lines().map(ToString::to_string));
            }
        }

        out.into_iter()
            .take(height)
            .map(|line| pad_line(&line, width))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn agent_sessions_table(&self, sessions: &[SessionRow], height: usize) -> String {
        let mut table = DataTable::new(vec![
            DataColumn::new("SESSION").width(14),
            DataColumn::new("TASK").width(12),
            DataColumn::new("EVT").width(5).align(CellAlign::Right),
            DataColumn::new("SEC").width(5).align(CellAlign::Right),
            DataColumn::new("LLM").width(5).align(CellAlign::Right),
            DataColumn::new("TOK").width(7).align(CellAlign::Right),
            DataColumn::new("RISK").width(5),
            DataColumn::new("LAST").min_width(16),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .empty("no sessions matched this agent");

        for row in sessions {
            table.add_row(
                DataRow::new(vec![
                    row.session.clone(),
                    row.task.clone(),
                    row.events.to_string(),
                    row.security.to_string(),
                    row.llm.to_string(),
                    format_count(row.total_tokens),
                    row.risk.label().to_string(),
                    format!("{} {}", row.last_kind, row.last_message),
                ])
                .fg(row.risk.color()),
            );
        }

        table.view(self.width, height)
    }

    fn agent_events_table(&self, events: &[EventRow], height: usize) -> String {
        let mut table = DataTable::new(vec![
            DataColumn::new("TIME").width(9),
            DataColumn::new("KIND").width(12),
            DataColumn::new("RISK").width(5),
            DataColumn::new("SESSION").width(12),
            DataColumn::new("TASK").width(12),
            DataColumn::new("PID").width(7).align(CellAlign::Right),
            DataColumn::new("MESSAGE").min_width(18),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .empty("no recent events matched this agent");

        for event in events {
            table.add_row(
                DataRow::new(vec![
                    event.ts.clone(),
                    event.kind.clone(),
                    event.risk.label().to_string(),
                    event.session.clone().unwrap_or_else(|| "-".to_string()),
                    event.task.clone().unwrap_or_else(|| "-".to_string()),
                    format_event_pid(event.pid),
                    event.message.clone(),
                ])
                .fg(event.risk.color()),
            );
        }

        table.view(self.width, height)
    }

    fn container_table(&self, rows: Vec<ContainerRow>) -> String {
        let mut table = DataTable::new(vec![
            self.configured_container_column(
                "containers.status",
                DataColumn::new("STATUS")
                    .width(9)
                    .min_width(7)
                    .priority(100),
            ),
            self.configured_container_column(
                "containers.name",
                DataColumn::new("NAME").width(16).min_width(8).priority(98),
            ),
            self.configured_container_column(
                "containers.id",
                DataColumn::new("CID").width(12).min_width(12).priority(99),
            ),
            self.configured_container_column(
                "containers.cpu",
                DataColumn::new("CPU")
                    .width(15)
                    .min_width(15)
                    .align(CellAlign::Right)
                    .priority(97),
            ),
            self.configured_container_column(
                "containers.cpus",
                DataColumn::new("CPUS")
                    .width(15)
                    .min_width(15)
                    .align(CellAlign::Right)
                    .priority(40),
            ),
            self.configured_container_column(
                "containers.mem",
                DataColumn::new("MEM")
                    .width(15)
                    .min_width(15)
                    .align(CellAlign::Right)
                    .priority(96),
            ),
            self.configured_container_column(
                "containers.net",
                DataColumn::new("NET I/O")
                    .width(17)
                    .min_width(11)
                    .priority(80),
            ),
            self.configured_container_column(
                "containers.block",
                DataColumn::new("IO").width(17).min_width(9).priority(79),
            ),
            self.configured_container_column(
                "containers.pids",
                DataColumn::new("PIDS")
                    .width(5)
                    .align(CellAlign::Right)
                    .priority(70),
            ),
            self.configured_container_column(
                "containers.uptime",
                DataColumn::new("UPTIME")
                    .width(14)
                    .min_width(8)
                    .priority(60),
            ),
            self.configured_container_column(
                "containers.image",
                DataColumn::new("IMAGE").width(18).priority(30),
            ),
            self.configured_container_column(
                "containers.mem_usage",
                DataColumn::new("MEM USAGE").width(17).priority(25),
            ),
            self.configured_container_column(
                "containers.ports",
                DataColumn::new("PORTS").width(24).priority(20),
            ),
            self.configured_container_column(
                "containers.health",
                DataColumn::new("HEALTH").width(10).priority(10),
            ),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .selected((!rows.is_empty()).then_some(self.selected))
        .scroll(self.scroll)
        .empty(if self.focused_container.is_some() {
            "focused container is no longer present".to_string()
        } else if self.show_all_containers {
            format!(
                "no containers found or {} is unavailable",
                self.connector.label()
            )
        } else {
            format!(
                "no running containers found or {} is unavailable",
                self.connector.label()
            )
        });

        for row in rows {
            let history = self.metric_history(&container_history_key(&row.id));
            let status = container_state_label(&row.status);
            let state_color = container_state_color(&status);
            let uptime = container_uptime_label(&row);
            let cid = short_id(&row.id).to_string();
            table.add_row(
                DataRow::new(vec![
                    status,
                    row.name,
                    cid,
                    self.metric_cell(row.cpu_pct, &history.cpu, CYAN),
                    self.scaled_cpu_metric_cell(row.cpu_pct, &history.cpu, row.cpu_count),
                    self.metric_cell(row.mem_pct, &history.mem, YELLOW),
                    row.net_io,
                    row.block_io,
                    row.pids,
                    uptime,
                    row.image,
                    row.mem_usage,
                    display_ports(&row.ports),
                    row.inspect.health,
                ])
                .cell_fg(0, state_color),
            );
        }
        table.view(self.width, self.visible_height() + 2)
    }

    fn container_focus_view(&self, rows: Vec<ContainerRow>) -> String {
        let width = self.width as usize;
        let Some(row) = rows.first() else {
            return Style::new()
                .fg(Color::BrightBlack)
                .italic()
                .render(&pad_plain(" focused container is no longer present", width));
        };

        let history = self.metric_history(&container_history_key(&row.id));
        let cpu = row.cpu_pct.unwrap_or_default();
        let mem = row.mem_pct.unwrap_or_default();
        let cpu_color = metric_color(cpu);
        let mem_color = metric_color(mem);
        let mut out = Vec::new();

        out.extend(
            SectionHeader::new(format!("single view {} ({})", row.name, short_id(&row.id)))
                .metadata(format!(
                    "status {} · health {} · image {}",
                    row.status, row.inspect.health, row.image
                ))
                .title_color(CYAN)
                .metadata_color(Color::BrightBlack)
                .separator_color(Color::BrightBlack)
                .view(self.width, 3)
                .lines()
                .map(ToString::to_string),
        );
        out.push(
            Meter::new(cpu as f64)
                .label("CPU")
                .width(width)
                .fg(cpu_color)
                .view(),
        );
        out.push(self.focus_trend_line("CPU trend", &history.cpu, cpu_color));
        out.push(
            Meter::new(mem as f64)
                .label("MEM")
                .width(width)
                .fg(mem_color)
                .view(),
        );
        out.push(self.focus_trend_line("MEM trend", &history.mem, mem_color));
        out.push(self.focus_bytes_trend_line(
            "NET trend",
            container_net_total(row),
            &history.net_io_bytes,
            GREEN,
        ));
        out.push(self.focus_bytes_trend_line(
            "IO trend",
            container_block_total(row),
            &history.block_io_bytes,
            ORANGE,
        ));
        out.push(divider_line(self.width));
        out.extend(
            self.container_resource_table(row, 9)
                .lines()
                .map(ToString::to_string),
        );

        let height = self.visible_height() + 2;
        let remaining = height.saturating_sub(out.len());
        if remaining > 6 {
            out.push(divider_line(self.width));
            let inspect_height = remaining.saturating_sub(1).min(8);
            out.extend(
                self.container_inspect_table(row, inspect_height)
                    .lines()
                    .map(ToString::to_string),
            );
        }

        let remaining = height.saturating_sub(out.len());
        if remaining > 4 {
            out.push(divider_line(self.width));
            out.extend(
                self.container_processes_table(row, remaining.saturating_sub(1))
                    .lines()
                    .map(ToString::to_string),
            );
        }

        out.into_iter()
            .take(height)
            .map(|line| pad_line(&line, width))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn container_inspect_table(&self, container: &ContainerRow, height: usize) -> String {
        let inspect = &container.inspect;
        let rows = [
            ("HEALTH", inspect.health.as_str()),
            ("RESTARTS", inspect.restarts.as_str()),
            ("RESTART POLICY", inspect.restart_policy.as_str()),
            ("CREATED", inspect.created.as_str()),
            ("STARTED", inspect.started.as_str()),
            ("EXIT", inspect.exit.as_str()),
            ("MOUNTS", inspect.mounts.as_str()),
            ("NETWORKS", inspect.networks.as_str()),
            ("ENV", inspect.env.as_str()),
            ("LABELS", inspect.labels.as_str()),
        ];
        let mut table = DataTable::new(vec![
            DataColumn::new("INSPECT").width(14),
            DataColumn::new("VALUE").min_width(18),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .empty("inspect metadata unavailable");

        for (key, value) in rows {
            if value != "-" {
                table.add_row(DataRow::new(vec![key.to_string(), value.to_string()]));
            }
        }

        table.view(self.width, height)
    }

    fn container_resource_table(&self, container: &ContainerRow, height: usize) -> String {
        let cpu_count = container
            .cpu_count
            .map(|cpus| cpus.to_string())
            .unwrap_or_else(|| "-".to_string());
        let rows = vec![
            ("MEMORY".to_string(), container.mem_usage.clone()),
            ("NET I/O".to_string(), container.net_io.clone()),
            ("IO".to_string(), container.block_io.clone()),
            ("PIDS".to_string(), container.pids.clone()),
            ("CPUS".to_string(), cpu_count),
            ("UPTIME".to_string(), container_uptime_label(container)),
            ("PORTS".to_string(), display_ports(&container.ports)),
        ];
        let mut table = DataTable::new(vec![
            DataColumn::new("RESOURCE").width(10),
            DataColumn::new("VALUE").min_width(18),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .empty("resource metadata unavailable");

        for (key, value) in rows {
            table.add_row(DataRow::new(vec![key, value]));
        }

        table.view(self.width, height)
    }

    fn container_processes_table(&self, container: &ContainerRow, height: usize) -> String {
        let panel = self
            .container_processes
            .as_ref()
            .filter(|panel| panel.container_id == container.id);
        let empty = match panel {
            Some(panel) if panel.loading && panel.rows.is_empty() => {
                format!("loading processes for {}", panel.container_name)
            }
            Some(panel) if panel.error.is_some() && panel.rows.is_empty() => format!(
                "container processes unavailable: {}",
                panel.error.as_deref().unwrap_or("unknown error")
            ),
            Some(_) => "no processes returned for this container".to_string(),
            None => "loading container processes...".to_string(),
        };
        let mut table = DataTable::new(vec![
            DataColumn::new("PID").width(7).align(CellAlign::Right),
            DataColumn::new("PPID").width(7).align(CellAlign::Right),
            DataColumn::new("CPU%").width(6).align(CellAlign::Right),
            DataColumn::new("MEM%").width(6).align(CellAlign::Right),
            DataColumn::new("ELAPSED").width(9),
            DataColumn::new("COMMAND").min_width(18),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .scroll(panel.map(|panel| panel.scroll).unwrap_or_default())
        .empty(empty);

        if let Some(panel) = panel {
            for row in &panel.rows {
                table.add_row(DataRow::new(vec![
                    row.pid.clone(),
                    row.ppid.clone(),
                    format_optional_pct(row.cpu_pct),
                    format_optional_pct(row.mem_pct),
                    row.elapsed.clone(),
                    row.command.clone(),
                ]));
            }
        }

        table.view(self.width, height)
    }

    fn focus_trend_line(&self, label: &str, values: &[f32], color: Color) -> String {
        let width = self.width as usize;
        let prefix = format!(" {label:<9} ");
        let chart_width = width
            .saturating_sub(a3s_tui::style::visible_len(&prefix))
            .max(1);
        let chart = Sparkline::new(values.iter().copied().map(f64::from))
            .width(chart_width)
            .range(0.0, 100.0)
            .fg(color)
            .view();
        format!("{prefix}{chart}")
    }

    fn focus_bytes_trend_line(
        &self,
        label: &str,
        value: u64,
        values: &[f64],
        color: Color,
    ) -> String {
        let width = self.width as usize;
        let value_label = format_bytes(value);
        let prefix = format!(" {label:<9} {value_label:>10} ");
        let chart_width = width
            .saturating_sub(a3s_tui::style::visible_len(&prefix))
            .max(1);
        let max = values.iter().copied().fold(value as f64, f64::max).max(1.0);
        let chart = Sparkline::new(values.iter().copied())
            .width(chart_width)
            .range(0.0, max)
            .fg(color)
            .view();
        format!("{prefix}{chart}")
    }

    fn column_panel_view(&self) -> String {
        let Some(panel) = &self.column_panel else {
            return String::new();
        };
        column_panel_lines(panel, self.width, self.visible_height()).join("\n")
    }

    fn logs_view(&self) -> String {
        let Some(log) = &self.log else {
            return String::new();
        };
        let state = if log.loading {
            LogViewState::Loading
        } else if log.refreshing {
            LogViewState::Refreshing
        } else {
            LogViewState::Ready
        };
        let timestamps = if log.timestamps {
            "timestamps:on"
        } else {
            "timestamps:off"
        };
        let follow = if log.follow {
            "follow:on"
        } else {
            "follow:off"
        };

        let mut view = LogView::new(format!(
            "logs {} ({})",
            log.container_name,
            short_id(&log.container_id)
        ))
        .text(&log.text)
        .scroll(log.scroll)
        .state(state)
        .loading_text("loading container logs...")
        .empty_text("no logs returned for this container")
        .title_color(CYAN)
        .metadata_color(Color::BrightBlack)
        .text_color(Color::BrightWhite)
        .muted_color(Color::BrightBlack)
        .separator_color(Color::BrightBlack);
        if state == LogViewState::Ready {
            view = view.metadata("tail 200");
        }
        view = view.metadata(timestamps).metadata(follow);

        view.view(self.width, self.log_visible_height().saturating_add(2))
    }

    fn container_menu_view(&self) -> String {
        let Some(menu) = &self.container_menu else {
            return String::new();
        };
        container_menu_lines(menu, self.width, self.visible_height()).join("\n")
    }

    fn help_view(&self) -> String {
        let rows = [
            ("Tab / Shift+Tab", "switch tabs: agents and containers"),
            ("↑/↓ or j/k", "select row"),
            ("Home / End", "jump to first or last row"),
            ("/ or f / Esc", "filter rows / clear filter"),
            ("s / r", "select sort field / reverse"),
            ("!", "cycle risk filter"),
            ("g", "cycle event kind filter"),
            ("--sessions", "start on agent session activity"),
            ("a / H / Space/p", "all containers / header / pause"),
            ("Enter", "container menu on Containers tab"),
            ("x", "toggle detail panel"),
            ("o", "single container, agent, or session focus"),
            ("← / →", "logs / container view"),
            ("l / r / t / f", "logs / refresh / timestamps / follow"),
            ("e", "exec shell in container"),
            ("w", "open first published web port"),
            ("r", "reverse sort order"),
            ("c", "configure columns"),
            ("c then d", "restore compact columns"),
            ("C", "switch container connector"),
            ("S", "save current top configuration"),
            ("K", "terminate process or stop container"),
            ("Esc", "clear filter or close panel/focus"),
            ("q", "quit"),
        ];
        let mut section = HelpSection::new("Keys");
        for (key, action) in rows {
            section.add_row(key, action);
        }
        HelpPanel::without_title()
            .section(section)
            .key_width(18)
            .indent(0)
            .gap(2)
            .section_color(Color::BrightBlack)
            .key_color(Color::BrightWhite)
            .description_color(Color::BrightBlack)
            .view(self.width, self.visible_height() + 2)
    }

    fn sort_panel_view(&self) -> String {
        let Some(panel) = &self.sort_panel else {
            return String::new();
        };
        sort_panel_lines(panel, self.width, self.visible_height()).join("\n")
    }

    fn connector_panel_view(&self) -> String {
        let Some(panel) = &self.connector_panel else {
            return String::new();
        };
        connector_panel_lines(panel, self.width, self.visible_height()).join("\n")
    }

    fn events_table(&self, rows: Vec<EventRow>) -> String {
        let mut table = DataTable::new(vec![
            self.configured_column("events.time", DataColumn::new("TIME").width(9)),
            self.configured_column("events.source", DataColumn::new("SOURCE").width(12)),
            self.configured_column("events.session", DataColumn::new("SESSION").width(12)),
            self.configured_column("events.task", DataColumn::new("TASK").width(10)),
            self.configured_column(
                "events.pid",
                DataColumn::new("PID").width(7).align(CellAlign::Right),
            ),
            self.configured_column(
                "events.ppid",
                DataColumn::new("PPID").width(7).align(CellAlign::Right),
            ),
            self.configured_column("events.kind", DataColumn::new("KIND").width(12)),
            self.configured_column("events.risk", DataColumn::new("RISK").width(5)),
            self.configured_column("events.message", DataColumn::new("MESSAGE").min_width(18)),
        ])
        .header_fg(Color::BrightBlack)
        .separator_fg(Color::BrightBlack)
        .selected((!rows.is_empty()).then_some(self.selected))
        .scroll(self.scroll)
        .empty("no events yet - container changes appear live; agent logs are auto-discovered when present");

        for row in rows {
            table.add_row(
                DataRow::new(vec![
                    row.ts,
                    row.source,
                    row.session.unwrap_or_else(|| "-".to_string()),
                    row.task.unwrap_or_else(|| "-".to_string()),
                    format_event_pid(row.pid),
                    format_event_pid(row.ppid),
                    row.kind,
                    row.risk.label().to_string(),
                    row.message,
                ])
                .fg(row.risk.color()),
            );
        }
        table.view(self.width, self.visible_height() + 2)
    }

    fn details(&self) -> String {
        if !self.detail {
            return String::new();
        }
        let mut lines = Vec::new();
        lines.push(divider_line(self.width));
        match self.tab {
            Tab::Agents if self.focused_agent_pid.is_none() => {
                if let Some(group) = self.current_agent_group() {
                    let activity = group.activity.clone();
                    let usage = group.usage;
                    lines.push(Style::new().fg(group.agent.color()).bold().render(&format!(
                        " agent {} · state {} · risk {}",
                        group.agent.label(),
                        agent_tree_state_label(&group),
                        group.risk().label()
                    )));
                    lines.push(format!(
                        " resources cpu {:.1}% · mem {:.1}% · system processes {} · agent processes {} · children {}",
                        usage.cpu_pct,
                        usage.mem_pct,
                        self.agent_system_processes(&group).len(),
                        group.processes.len(),
                        usage.descendants
                    ));
                    lines.push(format!(
                        " activity sessions {} · events {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · high {}",
                        group.sessions.len(),
                        activity.events,
                        activity.tools,
                        activity.security,
                        activity.files,
                        activity.egress,
                        activity.llm,
                        format_count(activity.total_tokens),
                        display_model(&activity.model),
                        display_provider(&activity.provider),
                        format_avg_ms(activity.latency_ms, activity.latency_samples),
                        activity.high_risk
                    ));
                    if let Some(session) = group.sessions.first() {
                        lines.push(format!(
                            " top session {} · task {} · cwd {} · risk {}",
                            session.session,
                            session.task,
                            session.workspace,
                            session.risk.label()
                        ));
                    }
                    if let Some(process) = group.processes.first() {
                        lines.push(format!(
                            " top process pid {} · elapsed {} · cwd {}",
                            process.pid,
                            process.elapsed,
                            display_workspace(process.cwd.as_deref())
                        ));
                    }
                    if let Some(event) = group.events.first() {
                        lines.push(format!(
                            " latest event {} · {} · {}",
                            event.kind,
                            event_scope_label(event),
                            event.message
                        ));
                    }
                    lines.push(
                        " actions o focus top process/session · / filter · ! risk · g kind"
                            .to_string(),
                    );
                }
            }
            Tab::Agents | Tab::Processes => {
                if let Some(row) = self.current_process() {
                    lines.push(
                        Style::new()
                            .fg(row.agent.map(|a| a.color()).unwrap_or(ACCENT))
                            .bold()
                            .render(&format!(
                                " process {} · ppid {} · risk {}",
                                row.pid,
                                row.ppid,
                                row.risk.label()
                            )),
                    );
                    lines.push(format!(
                        " cpu {:.1}% · mem {:.1}% · elapsed {} · children {}",
                        row.cpu_pct,
                        row.mem_pct,
                        row.elapsed,
                        self.descendant_count(row.pid)
                    ));
                    lines.push(format!(
                        " agent {}",
                        row.agent.map(|a| a.label()).unwrap_or("none")
                    ));
                    if row.agent.is_some() {
                        let activity = self.agent_activity_for_process(&row);
                        lines.push(format!(
                            " activity events {} · sessions {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · high {}",
                            activity.events,
                            activity.sessions,
                            activity.tools,
                            activity.security,
                            activity.files,
                            activity.egress,
                            activity.llm,
                            format_count(activity.total_tokens),
                            display_model(&activity.model),
                            display_provider(&activity.provider),
                            format_avg_ms(activity.latency_ms, activity.latency_samples),
                            activity.high_risk
                        ));
                        for event in self.recent_agent_events_for_process(&row) {
                            lines.push(format!(
                                " event {} · {} · {} · {}",
                                event.kind,
                                event.risk.label(),
                                event_scope_label(&event),
                                event.message
                            ));
                        }
                    }
                    lines.push(format!(" cwd {}", display_workspace(row.cwd.as_deref())));
                    lines.push(format!(" command {}", row.command));
                }
            }
            Tab::Sessions => {
                if self.focused_session.is_some() {
                    if let Some(row) = self.current_event() {
                        self.push_event_detail(&row, &mut lines);
                    }
                } else if let Some(row) = self.current_session() {
                    lines.push(
                        Style::new()
                            .fg(row.risk.color())
                            .bold()
                            .render(&format!(" session {} · {}", row.source, row.session)),
                    );
                    lines.push(format!(" task {}", row.task));
                    lines.push(format!(" cwd {}", row.workspace));
                    lines.push(format!(
                        " activity events {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · high {} · risk {}",
                        row.events,
                        row.tools,
                        row.security,
                        row.files,
                        row.egress,
                        row.llm,
                        format_count(row.total_tokens),
                        display_model(&row.model),
                        display_provider(&row.provider),
                        format_avg_ms(row.latency_ms, row.latency_samples),
                        row.high_risk,
                        row.risk.label()
                    ));
                    lines.push(format!(" latest {} · {}", row.last_kind, row.last_message));
                }
            }
            Tab::Containers => {
                if let Some(row) = self.current_container() {
                    let panel =
                        DetailPanel::new(format!("container {} ({})", row.name, short_id(&row.id)))
                            .show_separator(false)
                            .unlimited_rows()
                            .title_color(CYAN)
                            .value_color(Color::BrightWhite)
                            .action_color(ACCENT)
                            .text(format!("image {}", row.image))
                            .text(format!("status {}", row.status))
                            .text(format!(
                                "cpu {} · mem {} · net {} · block {} · pids {} · ports {}",
                                row.cpu_pct
                                    .map(|v| format!("{v:.1}%"))
                                    .unwrap_or_else(|| "-".to_string()),
                                row.mem_usage,
                                row.net_io,
                                row.block_io,
                                row.pids,
                                display_ports(&row.ports)
                            ))
                            .action("Enter menu · l logs · e shell · o focus · K stop");
                    lines.extend(panel.view(self.width, 5).lines().map(ToString::to_string));
                }
            }
            Tab::Events => {
                if let Some(row) = self.current_event() {
                    self.push_event_detail(&row, &mut lines);
                }
            }
        }
        lines
            .into_iter()
            .take(10)
            .map(|line| pad_line(&line, self.width as usize))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn push_event_detail(&self, row: &EventRow, lines: &mut Vec<String>) {
        let mut panel = DetailPanel::new(format!(
            "event {} · {} · risk {}",
            row.source,
            row.kind,
            row.risk.label()
        ))
        .show_separator(false)
        .unlimited_rows()
        .title_color(row.risk.color())
        .value_color(Color::BrightWhite)
        .action_color(ACCENT);

        panel = panel.text(format!("time {} · {}", row.ts, event_scope_label(row)));
        panel = panel.text(format!(
            "source {} · session {} · task {}",
            row.source,
            row.session.as_deref().unwrap_or("-"),
            row.task.as_deref().unwrap_or("-")
        ));
        if row.pid.is_some() || row.ppid.is_some() {
            panel = panel.text(format!(
                "process pid {} · ppid {}",
                row.pid
                    .map(|pid| pid.to_string())
                    .unwrap_or_else(|| "-".to_string()),
                row.ppid
                    .map(|pid| pid.to_string())
                    .unwrap_or_else(|| "-".to_string())
            ));
        }
        panel = panel.text(format!("message {}", row.message));
        for (key, value) in row.details.iter().take(4) {
            let budget = (self.width as usize)
                .saturating_sub(key.chars().count() + " detail  ".len())
                .max(12);
            panel = panel.text(format!("detail {key} {}", truncate(value, budget)));
        }
        if session_key_for_event(row).is_some() {
            panel = panel.action("o session focus · / filter · ! risk · g kind");
        } else {
            panel = panel.action("/ filter · ! risk · g kind");
        }
        lines.extend(panel.view(self.width, 9).lines().map(ToString::to_string));
    }

    fn confirm_view(&self) -> String {
        let Some(action) = &self.confirm else {
            return String::new();
        };
        let (title, target) = match action {
            Action::KillProcess(pid, label) => {
                ("Terminate process?", format!("PID {pid} · {label}"))
            }
            Action::StartContainer(_, _, name) => ("Start container?", name.clone()),
            Action::StopContainer(_, _, name) => ("Stop container?", name.clone()),
            Action::RestartContainer(_, _, name) => ("Restart container?", name.clone()),
            Action::PauseContainer(_, _, name) => ("Pause container?", name.clone()),
            Action::UnpauseContainer(_, _, name) => ("Unpause container?", name.clone()),
            Action::RemoveContainer(_, _, name) => ("Remove container?", name.clone()),
        };
        Confirm::new(target)
            .title(title)
            .with_labels("y", "n")
            .max_width(58)
            .colors(Color::BrightWhite, Some(RED))
            .box_view(self.width)
    }
}

impl Model for TopApp {
    type Msg = Msg;

    fn init(&mut self) -> Option<Cmd<Msg>> {
        Some(refresh_cmd(
            self.connector,
            self.show_all_containers,
            self.focused_container.clone(),
            self.observer.clone(),
        ))
    }

    fn update(&mut self, msg: Msg) -> Option<Cmd<Msg>> {
        match msg {
            Msg::Snapshot {
                connector,
                snapshot,
                observer,
            } => {
                self.apply_snapshot(snapshot, observer, connector);
                let mut cmds = vec![cmd::tick(self.interval, Msg::Tick)];
                if let Some(cmd) = self.focused_container_process_refresh_cmd() {
                    cmds.push(cmd);
                }
                if let Some(cmd) = self.open_log_refresh_cmd() {
                    cmds.push(cmd);
                }
                Some(single_or_batch(cmds))
            }
            Msg::Tick => {
                if self.paused {
                    Some(cmd::tick(self.interval, Msg::Tick))
                } else {
                    Some(refresh_cmd(
                        self.connector,
                        self.show_all_containers,
                        self.focused_container.clone(),
                        self.observer.clone(),
                    ))
                }
            }
            Msg::ActionDone(note) => {
                self.note = Some(note);
                Some(refresh_cmd(
                    self.connector,
                    self.show_all_containers,
                    self.focused_container.clone(),
                    self.observer.clone(),
                ))
            }
            Msg::ContainerLogs {
                connector,
                id,
                name,
                timestamps,
                result,
            } => {
                if self.log.as_ref().map(|log| log.container_id.as_str()) != Some(id.as_str()) {
                    return None;
                }
                let follow = self.log.as_ref().map(|log| log.follow).unwrap_or(true);
                let previous_scroll = self.log.as_ref().map(|log| log.scroll).unwrap_or_default();
                let text = match result {
                    Ok(text) => text,
                    Err(err) => format!("{} logs failed: {err}", connector.label()),
                };
                let max_scroll = text
                    .lines()
                    .count()
                    .saturating_sub(self.log_visible_height());
                self.log = Some(LogPanel {
                    connector,
                    container_id: id,
                    container_name: name,
                    text,
                    scroll: if follow {
                        max_scroll
                    } else {
                        previous_scroll.min(max_scroll)
                    },
                    timestamps,
                    loading: false,
                    refreshing: false,
                    follow,
                });
                None
            }
            Msg::ContainerProcesses { id, name, result } => {
                if self.focused_container.as_deref() != Some(id.as_str()) {
                    return None;
                }
                let (rows, error) = match result {
                    Ok(rows) => (rows, None),
                    Err(err) => (Vec::new(), Some(err)),
                };
                let scroll = self
                    .container_processes
                    .as_ref()
                    .filter(|panel| panel.container_id == id)
                    .map(|panel| panel.scroll)
                    .unwrap_or_default();
                self.container_processes = Some(ContainerProcessPanel {
                    container_id: id,
                    container_name: name,
                    rows,
                    scroll,
                    error,
                    loading: false,
                });
                None
            }
            Msg::ConfigSaved(result) => {
                self.note = Some(match result {
                    Ok(path) => format!("saved top config to {}", path.display()),
                    Err(err) => format!("failed to save top config: {err}"),
                });
                None
            }
            Msg::Term(Event::Resize { width, height }) => {
                self.width = width;
                self.height = height;
                self.clamp_selection();
                None
            }
            Msg::Term(Event::Key(key)) => self.handle_key(key),
            Msg::Term(_) => None,
        }
    }

    fn view(&self) -> String {
        let mut body = Vec::new();
        if self.show_header {
            body.push(self.header());
        }
        body.push(self.tabs());
        body.push(self.table());
        let details = if self.help
            || self.column_panel.is_some()
            || self.sort_panel.is_some()
            || self.connector_panel.is_some()
            || self.log.is_some()
            || self.container_menu.is_some()
            || (self.tab == Tab::Containers && self.focused_container.is_some())
            || (self.tab == Tab::Agents && self.focused_agent_pid.is_some())
        {
            String::new()
        } else {
            self.details()
        };
        if !details.is_empty() {
            body.push(details);
        }
        if let Some(note) = &self.note {
            body.push(Style::new().fg(YELLOW).render(&format!(" {note}")));
        } else if !self.snapshot.errors.is_empty() {
            body.push(
                Style::new()
                    .fg(YELLOW)
                    .render(&format!(" {}", self.snapshot.errors.join(" · "))),
            );
        }

        let main = body.join("\n");
        let help = self.footer_help_text();
        let sort_dir = if self.reverse_sort { "↑" } else { "↓" };
        let scope = if self.show_all_containers {
            format!("{}:all", self.connector.label())
        } else {
            format!("{}:running", self.connector.label())
        };
        let observer = observer_status_label(&self.observer);
        let status = StatusBar::new()
            .left(format!(
                " {} · sort:{}{} · risk:{} · kind:{} · {} · {} · {} rows",
                self.tab.label(),
                self.sort_by.label(),
                sort_dir,
                self.risk_filter.label(),
                self.kind_filter.label(),
                scope,
                observer,
                self.visible_len()
            ))
            .center(help)
            .right(if self.paused { "paused" } else { "live" })
            .fg(Color::BrightWhite)
            .bg(Color::Rgb(35, 40, 60))
            .view(self.width);

        let mut screen = Layout::vertical()
            .item(&main, Constraint::Fill)
            .item(&status, Constraint::Fixed(1))
            .render(self.height);

        let confirm = self.confirm_view();
        if !confirm.is_empty() {
            screen.push('\n');
            screen.push_str(&confirm);
        }
        if self.invert_colors {
            invert_screen(&screen)
        } else {
            screen
        }
    }
}

impl TopApp {
    fn footer_help_text(&self) -> &'static str {
        if self.column_panel.is_some() {
            "Space toggle · Enter apply · d compact · Esc cancel"
        } else if self.sort_panel.is_some() {
            "Enter apply sort · ↑/↓ select · Esc cancel"
        } else if self.connector_panel.is_some() {
            "Enter switch connector · ↑/↓ select · Esc cancel"
        } else if self.log.is_some() {
            "Esc close logs · ↑/↓ scroll · r refresh · t timestamps · f follow"
        } else if self.container_menu.is_some() {
            "Enter run action · ↑/↓ select · Esc close menu"
        } else if self.help {
            "h/Esc close help"
        } else if self.tab == Tab::Containers && self.focused_container.is_some() {
            "Esc list · ↑/↓ proc · Enter actions(start/stop/restart/pause) · l logs · e shell · w browser · K stop"
        } else if self.tab == Tab::Sessions && self.focused_session.is_some() {
            "Esc close session · ↑/↓ event · / filter · q quit"
        } else if self.editing_filter {
            "type filter · Enter apply · Esc cancel · Ctrl+U clear · Ctrl+W word"
        } else if self.tab == Tab::Agents {
            "↑/↓ agent · o focus process/session · x details · / filter · Tab containers · q quit"
        } else {
            "Tab switch · / filter · ! risk · g kind · C connector · h help · Enter menu · c columns · S save · o focus · l logs · q quit"
        }
    }

    fn handle_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if self.editing_filter {
            return self.handle_filter_key(key);
        }
        if self.confirm.is_some() {
            return self.handle_confirm_key(key);
        }
        if self.help {
            return self.handle_help_key(key);
        }
        if self.container_menu.is_some() {
            return self.handle_container_menu_key(key);
        }
        if self.sort_panel.is_some() {
            return self.handle_sort_key(key);
        }
        if self.connector_panel.is_some() {
            return self.handle_connector_key(key);
        }
        if self.column_panel.is_some() {
            return self.handle_column_key(key);
        }
        if self.log.is_some() {
            return self.handle_log_key(key);
        }
        if key.code == KeyCode::Esc {
            if self.focused_container.take().is_some() {
                self.container_processes = None;
                self.reset_position();
                return None;
            }
            if self.focused_agent_pid.take().is_some() {
                self.reset_position();
                return None;
            }
            if self.focused_session.take().is_some() {
                self.reset_position();
                return None;
            }
            if self.detail {
                self.detail = false;
                return None;
            }
        }
        if key.modifiers.is_empty() && self.tab == Tab::Containers {
            match key.code {
                KeyCode::Right => return self.toggle_container_focus(),
                KeyCode::Left => return self.open_container_logs(),
                _ => {}
            }
        }
        if key.modifiers.is_empty()
            && self.tab == Tab::Containers
            && self.focused_container.is_some()
            && self.handle_focused_container_scroll_key(&key)
        {
            return None;
        }

        let action = self.keymap.resolve(&key);
        match action {
            Some(TopKey::Quit) => Some(cmd::quit()),
            Some(TopKey::Up) => {
                self.selected = self.selected.saturating_sub(1);
                self.clamp_selection();
                None
            }
            Some(TopKey::Down) => {
                self.selected = (self.selected + 1).min(self.visible_len().saturating_sub(1));
                self.clamp_selection();
                None
            }
            Some(TopKey::Home) => {
                self.selected = 0;
                self.clamp_selection();
                None
            }
            Some(TopKey::End) => {
                self.selected = self.visible_len().saturating_sub(1);
                self.clamp_selection();
                None
            }
            Some(TopKey::PageUp) => {
                self.selected = self.selected.saturating_sub(10);
                self.clamp_selection();
                None
            }
            Some(TopKey::PageDown) => {
                self.selected = (self.selected + 10).min(self.visible_len().saturating_sub(1));
                self.clamp_selection();
                None
            }
            Some(TopKey::NextTab) => {
                self.tab = self.tab.next();
                self.reset_position();
                None
            }
            Some(TopKey::PrevTab) => {
                self.tab = self.tab.prev();
                self.reset_position();
                None
            }
            Some(TopKey::Filter) => {
                self.filter_before_edit = Some(self.filter.clone());
                self.editing_filter = true;
                None
            }
            Some(TopKey::Sort) => {
                self.open_sort_panel();
                None
            }
            Some(TopKey::TogglePause) => {
                self.paused = !self.paused;
                None
            }
            Some(TopKey::ToggleAll) => {
                self.show_all_containers = !self.show_all_containers;
                self.reset_position();
                Some(refresh_cmd(
                    self.connector,
                    self.show_all_containers,
                    self.focused_container.clone(),
                    self.observer.clone(),
                ))
            }
            Some(TopKey::ToggleHeader) => {
                self.show_header = !self.show_header;
                None
            }
            Some(TopKey::ToggleReverse) => {
                self.reverse_sort = !self.reverse_sort;
                self.clamp_selection();
                None
            }
            Some(TopKey::ToggleRiskFilter) => {
                self.risk_filter = self.risk_filter.next();
                self.reset_position();
                None
            }
            Some(TopKey::ToggleKindFilter) => {
                self.kind_filter = self.kind_filter.next();
                self.reset_position();
                None
            }
            Some(TopKey::Detail) => {
                if self.tab == Tab::Containers && key.code == KeyCode::Enter {
                    self.open_container_menu();
                } else {
                    self.detail = !self.detail;
                }
                None
            }
            Some(TopKey::Open) => {
                match self.tab {
                    Tab::Containers => return self.toggle_container_focus(),
                    Tab::Agents | Tab::Processes => self.toggle_agent_focus(),
                    Tab::Sessions => self.toggle_session_focus(),
                    Tab::Events => self.open_event_focus(),
                }
                None
            }
            Some(TopKey::Logs) => self.open_container_logs(),
            Some(TopKey::OpenBrowser) => self.open_container_browser(),
            Some(TopKey::Columns) => {
                self.open_column_panel();
                None
            }
            Some(TopKey::Connector) => {
                self.open_connector_panel();
                None
            }
            Some(TopKey::ExecShell) => self.open_container_shell(),
            Some(TopKey::SaveConfig) => Some(save_config_cmd(self.current_config())),
            Some(TopKey::Help) => {
                self.help = true;
                None
            }
            Some(TopKey::Kill) => {
                self.confirm = self.selected_action();
                None
            }
            None => None,
        }
    }

    fn handle_focused_container_scroll_key(&mut self, key: &KeyEvent) -> bool {
        let step = self.visible_height().saturating_div(2).max(1);
        let Some(panel) = &mut self.container_processes else {
            return false;
        };
        let max_scroll = panel.rows.len();
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                panel.scroll = panel.scroll.saturating_sub(1);
                true
            }
            KeyCode::Down | KeyCode::Char('j') => {
                panel.scroll = (panel.scroll + 1).min(max_scroll);
                true
            }
            KeyCode::PageUp => {
                panel.scroll = panel.scroll.saturating_sub(step);
                true
            }
            KeyCode::PageDown => {
                panel.scroll = (panel.scroll + step).min(max_scroll);
                true
            }
            KeyCode::Home => {
                panel.scroll = 0;
                true
            }
            KeyCode::End => {
                panel.scroll = max_scroll;
                true
            }
            _ => false,
        }
    }

    fn handle_help_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) {
            return Some(cmd::quit());
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('h') => {
                self.help = false;
                None
            }
            _ => None,
        }
    }

    fn handle_container_menu_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) {
            return Some(cmd::quit());
        }
        if key.code == KeyCode::Esc {
            self.container_menu = None;
            return None;
        }

        let Some(menu) = &mut self.container_menu else {
            return None;
        };
        if let KeyCode::Char(ch) = key.code {
            let ch = ch.to_ascii_lowercase();
            if let Some(item) = menu.items.iter().find(|item| item.key == ch).cloned() {
                let container = menu.container.clone();
                self.container_menu = None;
                return self.run_container_menu_action(container, item.action);
            }
        }
        match menu.select.handle_key(&key) {
            Some(SelectMsg::Selected(idx, _)) => {
                let item = menu.items.get(idx).cloned()?;
                let container = menu.container.clone();
                self.container_menu = None;
                self.run_container_menu_action(container, item.action)
            }
            None => None,
        }
    }

    fn handle_column_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) {
            return Some(cmd::quit());
        }
        if key.code == KeyCode::Esc {
            self.column_panel = None;
            return None;
        }
        if matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
            if let Some(tab) = self.column_panel.as_ref().map(|panel| panel.tab) {
                self.reset_tab_columns_to_default(tab);
                self.column_panel = None;
            }
            return None;
        }

        let Some(panel) = &mut self.column_panel else {
            return None;
        };
        match panel.select.handle_key(&key) {
            Some(MultiSelectMsg::Submit(indices)) => {
                if indices.is_empty() {
                    self.note = Some("at least one column must stay visible".to_string());
                    return None;
                }

                let visible: HashSet<usize> = indices.into_iter().collect();
                for (idx, choice) in panel.choices.iter().enumerate() {
                    if visible.contains(&idx) {
                        self.hidden_columns.remove(choice.id);
                    } else {
                        self.hidden_columns.insert(choice.id.to_string());
                    }
                }
                self.column_panel = None;
                None
            }
            Some(MultiSelectMsg::Toggle(_)) | None => None,
        }
    }

    fn handle_sort_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) {
            return Some(cmd::quit());
        }
        if key.code == KeyCode::Esc {
            self.sort_panel = None;
            return None;
        }

        let selected = {
            let Some(panel) = &mut self.sort_panel else {
                return None;
            };
            match panel.select.handle_key(&key) {
                Some(SelectMsg::Selected(idx, _)) => panel.choices.get(idx).copied(),
                None => return None,
            }
        };

        if let Some(sort_by) = selected {
            self.sort_by = sort_by;
            self.clamp_selection();
        }
        self.sort_panel = None;
        None
    }

    fn handle_connector_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) {
            return Some(cmd::quit());
        }
        if key.code == KeyCode::Esc {
            self.connector_panel = None;
            return None;
        }

        let selected = {
            let Some(panel) = &mut self.connector_panel else {
                return None;
            };
            match panel.select.handle_key(&key) {
                Some(SelectMsg::Selected(idx, _)) => panel.choices.get(idx).copied(),
                None => return None,
            }
        };

        self.connector_panel = None;
        if let Some(connector) = selected {
            self.connector = connector;
            self.focused_container = None;
            self.container_processes = None;
            self.container_menu = None;
            self.log = None;
            self.reset_position();
            return Some(refresh_cmd(
                self.connector,
                self.show_all_containers,
                self.focused_container.clone(),
                self.observer.clone(),
            ));
        }
        None
    }

    fn handle_filter_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        match key.code {
            KeyCode::Esc => {
                self.filter_before_edit = None;
                self.filter.clear();
                self.editing_filter = false;
                self.reset_position();
            }
            KeyCode::Enter => {
                self.filter_before_edit = None;
                self.editing_filter = false;
                self.reset_position();
            }
            KeyCode::Backspace => {
                self.filter.pop();
                self.reset_position();
            }
            KeyCode::Char('u') | KeyCode::Char('U')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                self.filter.clear();
                self.reset_position();
            }
            KeyCode::Char('w') | KeyCode::Char('W')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                delete_filter_word(&mut self.filter);
                self.reset_position();
            }
            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.filter.push(c);
                self.reset_position();
            }
            _ => {}
        }
        None
    }

    fn handle_log_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) {
            return Some(cmd::quit());
        }

        let max_scroll = self
            .log
            .as_ref()
            .map(|log| {
                log.text
                    .lines()
                    .count()
                    .saturating_sub(self.log_visible_height())
            })
            .unwrap_or_default();

        match key.code {
            KeyCode::Esc => {
                self.log = None;
                None
            }
            KeyCode::Up | KeyCode::Char('k') => {
                if let Some(log) = &mut self.log {
                    log.scroll = log.scroll.saturating_sub(1);
                    log.follow = false;
                }
                None
            }
            KeyCode::Down | KeyCode::Char('j') => {
                if let Some(log) = &mut self.log {
                    log.scroll = (log.scroll + 1).min(max_scroll);
                    log.follow = log.scroll >= max_scroll;
                }
                None
            }
            KeyCode::PageUp => {
                let step = self.log_visible_height();
                if let Some(log) = &mut self.log {
                    log.scroll = log.scroll.saturating_sub(step);
                    log.follow = false;
                }
                None
            }
            KeyCode::PageDown => {
                let step = self.log_visible_height();
                if let Some(log) = &mut self.log {
                    log.scroll = (log.scroll + step).min(max_scroll);
                    log.follow = log.scroll >= max_scroll;
                }
                None
            }
            KeyCode::Home => {
                if let Some(log) = &mut self.log {
                    log.scroll = 0;
                    log.follow = false;
                }
                None
            }
            KeyCode::End => {
                if let Some(log) = &mut self.log {
                    log.scroll = max_scroll;
                    log.follow = true;
                }
                None
            }
            KeyCode::Char('r') | KeyCode::Char('R') => self.open_log_refresh_cmd(),
            KeyCode::Char('f') | KeyCode::Char('F') => {
                if let Some(log) = &mut self.log {
                    if log.follow {
                        log.follow = false;
                    } else {
                        log.scroll = max_scroll;
                        log.follow = true;
                    }
                }
                None
            }
            KeyCode::Char('t') => {
                let Some(log) = &mut self.log else {
                    return None;
                };
                log.timestamps = !log.timestamps;
                log.loading = true;
                log.refreshing = true;
                log.follow = true;
                log.text.clear();
                Some(container_logs_cmd(
                    log.connector,
                    log.container_id.clone(),
                    log.container_name.clone(),
                    log.timestamps,
                ))
            }
            _ => None,
        }
    }

    fn handle_confirm_key(&mut self, key: KeyEvent) -> Option<Cmd<Msg>> {
        match key.code {
            KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
                self.confirm = None;
                None
            }
            KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
                let action = self.confirm.take()?;
                Some(cmd::cmd(move || async move {
                    Msg::ActionDone(run_action(action).await)
                }))
            }
            _ => None,
        }
    }

    fn toggle_container_focus(&mut self) -> Option<Cmd<Msg>> {
        let Some(container) = self.current_container() else {
            self.note = Some("select a container before opening focus view".to_string());
            return None;
        };
        if self.focused_container.as_deref() == Some(container.id.as_str()) {
            self.focused_container = None;
            self.container_processes = None;
            self.reset_position();
            None
        } else {
            self.focused_container = Some(container.id.clone());
            self.reset_position();
            self.detail = false;
            self.start_container_process_refresh(container)
        }
    }

    fn start_container_process_refresh(&mut self, container: ContainerRow) -> Option<Cmd<Msg>> {
        let (rows, scroll) = self
            .container_processes
            .as_ref()
            .filter(|panel| panel.container_id == container.id)
            .map(|panel| (panel.rows.clone(), panel.scroll))
            .unwrap_or_default();
        self.container_processes = Some(ContainerProcessPanel {
            container_id: container.id.clone(),
            container_name: container.name.clone(),
            rows,
            scroll,
            error: None,
            loading: true,
        });
        Some(container_processes_cmd(container))
    }

    fn focused_container_process_refresh_cmd(&mut self) -> Option<Cmd<Msg>> {
        let container = self.current_container()?;
        if self
            .container_processes
            .as_ref()
            .is_some_and(|panel| panel.container_id == container.id && panel.loading)
        {
            return None;
        }
        self.start_container_process_refresh(container)
    }

    fn toggle_agent_focus(&mut self) {
        if self.tab == Tab::Agents && self.focused_agent_pid.is_none() {
            let Some(group) = self.current_agent_group() else {
                self.note = Some("select an agent before opening focus view".to_string());
                return;
            };
            if let Some(process) = group.processes.first() {
                self.focused_agent_pid = Some(process.pid);
                self.reset_position();
                self.detail = false;
                return;
            }
            if let Some(session) = group.sessions.first() {
                self.focused_session = Some(SessionFocus::from_row(session));
                self.tab = Tab::Sessions;
                self.reset_position();
                self.detail = false;
                return;
            }
            self.note = Some(format!(
                "{} has no live process or session to focus",
                group.agent.label()
            ));
            return;
        }

        let Some(process) = self.current_process() else {
            self.note = Some("select an agent before opening focus view".to_string());
            return;
        };
        if process.agent.is_none() {
            self.note = Some("focus view is available for coding-agent processes".to_string());
            return;
        }
        if self.focused_agent_pid == Some(process.pid) {
            self.focused_agent_pid = None;
            self.reset_position();
        } else {
            self.focused_agent_pid = Some(process.pid);
            self.tab = Tab::Agents;
            self.reset_position();
            self.detail = false;
        }
    }

    fn toggle_session_focus(&mut self) {
        if self.focused_session.take().is_some() {
            self.reset_position();
            return;
        }

        let Some(row) = self.current_session() else {
            self.note = Some("select a session before opening focus view".to_string());
            return;
        };
        self.focused_session = Some(SessionFocus::from_row(&row));
        self.reset_position();
        self.detail = false;
    }

    fn open_event_focus(&mut self) {
        let Some(event) = self.current_event() else {
            self.note = Some("select an event before opening focus view".to_string());
            return;
        };
        let Some((source, session)) = session_key_for_event(&event) else {
            self.note = Some("event focus is available for coding-agent events".to_string());
            return;
        };
        self.focused_session = Some(SessionFocus { source, session });
        self.tab = Tab::Sessions;
        self.reset_position();
        self.detail = false;
    }

    fn open_container_menu(&mut self) {
        if self.tab != Tab::Containers {
            return;
        }
        let Some(container) = self.current_container() else {
            self.note = Some("select a container before opening the container menu".to_string());
            return;
        };
        let items = container_menu_items(&container);
        let labels = items
            .iter()
            .map(|item| format!("{}  {}", item.key, item.label))
            .collect::<Vec<_>>();
        self.container_menu = Some(ContainerMenu {
            container,
            items,
            select: Select::new(labels),
        });
    }

    fn run_container_menu_action(
        &mut self,
        container: ContainerRow,
        action: ContainerMenuAction,
    ) -> Option<Cmd<Msg>> {
        match action {
            ContainerMenuAction::Focus => {
                self.focused_container = Some(container.id.clone());
                self.reset_position();
                self.detail = false;
                self.start_container_process_refresh(container)
            }
            ContainerMenuAction::Logs => self.open_container_logs_for(container),
            ContainerMenuAction::ExecShell => self.open_container_shell_for(container),
            ContainerMenuAction::OpenBrowser => self.open_container_browser_for(container),
            ContainerMenuAction::Start
            | ContainerMenuAction::Stop
            | ContainerMenuAction::Restart
            | ContainerMenuAction::Pause
            | ContainerMenuAction::Unpause
            | ContainerMenuAction::Remove => {
                self.confirm = Some(container_action(container, action));
                None
            }
        }
    }

    fn open_container_browser(&mut self) -> Option<Cmd<Msg>> {
        if self.tab != Tab::Containers {
            self.note = Some("browser open is available on the Containers tab".to_string());
            return None;
        }
        let Some(container) = self.current_container() else {
            self.note = Some("select a container before opening a browser".to_string());
            return None;
        };
        self.open_container_browser_for(container)
    }

    fn open_container_browser_for(&mut self, container: ContainerRow) -> Option<Cmd<Msg>> {
        let Some(url) = container_web_url(&container) else {
            self.note = Some(format!(
                "{} has no published web port to open",
                container.name
            ));
            return None;
        };
        if let Ok(mut slot) = self.external_action.lock() {
            *slot = Some(ExternalAction::OpenBrowser {
                url,
                name: container.name,
            });
            Some(cmd::quit())
        } else {
            self.note = Some("failed to prepare browser open".to_string());
            None
        }
    }

    fn open_container_logs(&mut self) -> Option<Cmd<Msg>> {
        if self.tab != Tab::Containers {
            self.note = Some("logs are available on the Containers tab".to_string());
            return None;
        }
        let Some(container) = self.current_container() else {
            self.note = Some("select a container before opening logs".to_string());
            return None;
        };
        self.open_container_logs_for(container)
    }

    fn open_container_logs_for(&mut self, container: ContainerRow) -> Option<Cmd<Msg>> {
        if !matches!(
            container.connector,
            ContainerConnector::A3sBox | ContainerConnector::Docker
        ) {
            self.note =
                Some("logs are currently available for a3s-box and Docker containers".to_string());
            return None;
        }
        let timestamps = self.log.as_ref().map(|log| log.timestamps).unwrap_or(false);
        self.log = Some(LogPanel {
            connector: container.connector,
            container_id: container.id.clone(),
            container_name: container.name.clone(),
            text: String::new(),
            scroll: 0,
            timestamps,
            loading: true,
            refreshing: true,
            follow: true,
        });
        Some(container_logs_cmd(
            container.connector,
            container.id,
            container.name,
            timestamps,
        ))
    }

    fn open_log_refresh_cmd(&mut self) -> Option<Cmd<Msg>> {
        let log = self.log.as_mut()?;
        if log.loading || log.refreshing {
            return None;
        }
        log.refreshing = true;
        Some(container_logs_cmd(
            log.connector,
            log.container_id.clone(),
            log.container_name.clone(),
            log.timestamps,
        ))
    }

    fn open_container_shell(&mut self) -> Option<Cmd<Msg>> {
        if self.tab != Tab::Containers {
            self.note = Some("exec shell is available on the Containers tab".to_string());
            return None;
        }
        let Some(container) = self.current_container() else {
            self.note = Some("select a container before opening a shell".to_string());
            return None;
        };
        self.open_container_shell_for(container)
    }

    fn open_container_shell_for(&mut self, container: ContainerRow) -> Option<Cmd<Msg>> {
        if !matches!(
            container.connector,
            ContainerConnector::A3sBox | ContainerConnector::Docker
        ) {
            self.note = Some(
                "exec shell is currently available for a3s-box and Docker containers".to_string(),
            );
            return None;
        }
        if let Ok(mut slot) = self.external_action.lock() {
            *slot = Some(ExternalAction::ContainerShell {
                connector: container.connector,
                id: container.id,
                name: container.name,
            });
            Some(cmd::quit())
        } else {
            self.note = Some("failed to prepare container shell".to_string());
            None
        }
    }

    fn open_sort_panel(&mut self) {
        let choices = sort_choices_for_tab(self.tab);
        if choices.is_empty() {
            self.note = Some("events stay newest-first; use /, !, or g to narrow them".to_string());
            return;
        }
        let labels = choices
            .iter()
            .map(|sort_by| sort_choice_label(*sort_by))
            .collect::<Vec<_>>();
        let selected = choices
            .iter()
            .position(|sort_by| *sort_by == self.sort_by)
            .unwrap_or(0);
        self.sort_panel = Some(SortPanel {
            choices,
            select: Select::new(labels)
                .with_selected(selected)
                .with_number_shortcuts(),
        });
    }

    fn open_connector_panel(&mut self) {
        let choices = vec![
            ContainerConnector::A3sBox,
            ContainerConnector::Docker,
            ContainerConnector::RunC,
        ];
        let labels = choices
            .iter()
            .map(|connector| connector_choice_label(*connector))
            .collect::<Vec<_>>();
        let selected = choices
            .iter()
            .position(|connector| *connector == self.connector)
            .unwrap_or(0);
        self.connector_panel = Some(ConnectorPanel {
            choices,
            select: Select::new(labels)
                .with_selected(selected)
                .with_number_shortcuts(),
        });
    }

    fn open_column_panel(&mut self) {
        let choices = self.column_choices(self.tab);
        let labels = choices.iter().map(|c| c.label).collect::<Vec<_>>();
        let checked = choices
            .iter()
            .map(|c| self.column_visible(c.id))
            .collect::<Vec<_>>();
        self.column_panel = Some(ColumnPanel {
            tab: self.tab,
            choices,
            select: MultiSelect::new(labels)
                .with_checked(checked)
                .with_number_shortcuts(),
        });
    }

    fn reset_tab_columns_to_default(&mut self, tab: Tab) {
        let default_hidden = default_hidden_columns();
        for choice in self.column_choices(tab) {
            if default_hidden.contains(choice.id) {
                self.hidden_columns.insert(choice.id.to_string());
            } else {
                self.hidden_columns.remove(choice.id);
            }
        }
        self.ensure_visible_columns();
    }

    fn current_config(&self) -> TopConfig {
        TopConfig {
            show_all_containers: self.show_all_containers,
            show_header: self.show_header,
            reverse_sort: self.reverse_sort,
            sort_by: self.sort_by,
            risk_filter: self.risk_filter,
            kind_filter: self.kind_filter,
            connector: self.connector,
            filter: self.filter.clone(),
            hidden_columns: self.hidden_columns.clone(),
        }
    }
}

#[derive(Debug, Clone)]
struct TopOptions {
    tab: Tab,
    interval: Duration,
    force_all_containers: bool,
    force_active_containers: bool,
    container_query: Option<String>,
    filter: Option<String>,
    sort_by: Option<SortBy>,
    risk_filter: Option<RiskFilter>,
    kind_filter: Option<KindFilter>,
    connector: Option<ContainerConnector>,
    reverse_sort: Option<bool>,
    show_header: Option<bool>,
    compact_columns: bool,
    json: bool,
    watch: bool,
    json_count: Option<usize>,
    invert_colors: bool,
    start_help: bool,
    config: TopConfig,
    external_action: Arc<Mutex<Option<ExternalAction>>>,
}

impl Default for TopOptions {
    fn default() -> Self {
        Self {
            tab: Tab::Agents,
            interval: Duration::from_millis(1500),
            force_all_containers: false,
            force_active_containers: false,
            container_query: None,
            filter: None,
            sort_by: None,
            risk_filter: None,
            kind_filter: None,
            connector: None,
            reverse_sort: None,
            show_header: None,
            compact_columns: false,
            json: false,
            watch: false,
            json_count: None,
            invert_colors: false,
            start_help: false,
            config: TopConfig::default(),
            external_action: Arc::new(Mutex::new(None)),
        }
    }
}

pub async fn run(args: Vec<String>) -> anyhow::Result<()> {
    let mut base_options = parse_options(args)?;
    let force_all_containers = base_options.force_all_containers;
    let force_active_containers = base_options.force_active_containers;
    base_options.config = load_top_config().unwrap_or_default();
    apply_cli_overrides(
        &mut base_options,
        force_all_containers,
        force_active_containers,
    );
    if base_options.json {
        run_json_snapshot(base_options).await?;
        return Ok(());
    }
    loop {
        let external_action = Arc::new(Mutex::new(None));
        let mut options = base_options.clone();
        options.external_action = external_action.clone();
        ProgramBuilder::new(TopApp::new(options))
            .with_alt_screen()
            .with_fps(30)
            .run()
            .await?;

        let action = external_action.lock().ok().and_then(|mut slot| slot.take());
        let Some(action) = action else {
            break;
        };
        run_external_action(action).await?;
        base_options.config = load_top_config().unwrap_or_default();
        apply_cli_overrides(
            &mut base_options,
            force_all_containers,
            force_active_containers,
        );
    }
    Ok(())
}

fn apply_cli_overrides(
    options: &mut TopOptions,
    force_all_containers: bool,
    force_active_containers: bool,
) {
    if options.container_query.is_some() {
        options.tab = Tab::Containers;
        if !force_all_containers && !force_active_containers {
            options.config.show_all_containers = true;
        }
    }
    if force_all_containers {
        options.config.show_all_containers = true;
    }
    if force_active_containers {
        options.config.show_all_containers = false;
    }
    if let Some(filter) = &options.filter {
        options.config.filter = filter.clone();
    }
    if let Some(sort_by) = options.sort_by {
        options.config.sort_by = sort_by;
    }
    if let Some(risk_filter) = options.risk_filter {
        options.config.risk_filter = risk_filter;
    }
    if let Some(kind_filter) = options.kind_filter {
        options.config.kind_filter = kind_filter;
    }
    if let Some(connector) = options.connector.or_else(env_connector) {
        options.config.connector = connector;
    }
    if let Some(reverse_sort) = options.reverse_sort {
        options.config.reverse_sort = reverse_sort;
    }
    if let Some(show_header) = options.show_header {
        options.config.show_header = show_header;
    }
    if options.compact_columns {
        options.config.hidden_columns = default_hidden_columns();
    }
}

async fn run_json_snapshot(options: TopOptions) -> anyhow::Result<()> {
    let connector = options.config.connector;
    let show_all_containers = options.config.show_all_containers;
    let interval = options.interval;
    let container_target = options.container_query.clone();
    let max_snapshots = options
        .json_count
        .unwrap_or(if options.watch { usize::MAX } else { 1 });
    let streaming = options.watch || max_snapshots > 1;
    let mut app = TopApp::new(options);
    let mut observer = ObserverState::default();
    let mut emitted = 0usize;

    while emitted < max_snapshots {
        let (snapshot, next_observer) = collect_snapshot(
            connector,
            show_all_containers,
            container_target.clone(),
            observer,
        )
        .await;
        observer = next_observer.clone();
        app.apply_snapshot(snapshot, next_observer, connector);

        let json = top_snapshot_json(&app, unix_millis());
        if streaming {
            println!("{}", serde_json::to_string(&json)?);
        } else {
            println!("{}", serde_json::to_string_pretty(&json)?);
        }
        emitted += 1;

        if emitted >= max_snapshots {
            break;
        }
        tokio::time::sleep(interval).await;
    }
    Ok(())
}

fn unix_millis() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}

fn top_snapshot_json(app: &TopApp, collected_at_unix_ms: u128) -> serde_json::Value {
    let agents = app.filtered_agents();
    let sessions = app.filtered_sessions();
    let containers = app.filtered_containers();
    let container_states = container_state_summary(&containers);
    let raw_container_states = container_state_summary(&app.snapshot.containers);
    let processes = app.filtered_processes();
    let events = app.filtered_events();
    let high_events = app
        .snapshot
        .events
        .iter()
        .filter(|event| event.risk == Risk::High)
        .count();
    let total_tokens = app
        .snapshot
        .events
        .iter()
        .filter_map(event_token_usage)
        .map(|tokens| tokens.total)
        .sum::<u64>();

    serde_json::json!({
        "schema": "a3s.top.snapshot.v1",
        "collected_at_unix_ms": collected_at_unix_ms,
        "config": {
            "tab": app.tab.label(),
            "sort_by": app.sort_by.label(),
            "reverse_sort": app.reverse_sort,
            "risk_filter": app.risk_filter.label(),
            "kind_filter": app.kind_filter.label(),
            "connector": app.connector.label(),
            "show_all_containers": app.show_all_containers,
            "filter": &app.filter,
            "container": app.focused_container.as_deref(),
        },
        "observer": {
            "status": observer_status_label(&app.observer),
            "paths": app.observer.paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>(),
        },
        "summary": {
            "agents": agents.len(),
            "sessions": sessions.len(),
            "containers": containers.len(),
            "container_states": container_state_summary_json(container_states),
            "processes": processes.len(),
            "events": events.len(),
            "raw_processes": app.snapshot.processes.len(),
            "raw_containers": app.snapshot.containers.len(),
            "raw_container_states": container_state_summary_json(raw_container_states),
            "raw_events": app.snapshot.events.len(),
            "high_events": high_events,
            "total_tokens": total_tokens,
            "errors": app.snapshot.errors.len(),
        },
        "agents": agents.iter().map(|row| agent_json(app, row)).collect::<Vec<_>>(),
        "sessions": sessions.iter().map(session_json).collect::<Vec<_>>(),
        "containers": containers
            .iter()
            .map(|row| container_json(app, row))
            .collect::<Vec<_>>(),
        "processes": processes
            .iter()
            .map(|row| process_json(app, row))
            .collect::<Vec<_>>(),
        "events": events.iter().map(event_json).collect::<Vec<_>>(),
        "errors": &app.snapshot.errors,
    })
}

fn agent_json(app: &TopApp, row: &ProcessRow) -> serde_json::Value {
    let usage = app.process_tree_usage(row.pid);
    let activity = app.agent_activity_for_process(row);
    let history = app.metric_history(&agent_tree_history_key(row.pid));
    let sessions = agent_session_rows(app, row);
    let top_session = sessions.first();
    serde_json::json!({
        "pid": row.pid,
        "ppid": row.ppid,
        "agent": row.agent.map(|agent| agent.label()),
        "cpu_pct": row.cpu_pct,
        "mem_pct": row.mem_pct,
        "subtree": {
            "cpu_pct": usage.cpu_pct,
            "mem_pct": usage.mem_pct,
            "descendants": usage.descendants,
        },
        "process_tree": process_tree_json(app, row.pid),
        "history": metric_history_json(&history),
        "elapsed": &row.elapsed,
        "cwd": row.cwd.as_deref(),
        "risk": row.risk.label(),
        "command": &row.command,
        "activity": activity_json(&activity),
        "top_session": top_session.map(|row| row.session.as_str()),
        "top_task": top_session.and_then(|row| empty_dash_to_null(&row.task)),
        "sessions": sessions.iter().map(session_json).collect::<Vec<_>>(),
        "recent_events": app
            .recent_agent_events_for_process(row)
            .iter()
            .map(event_json)
            .collect::<Vec<_>>(),
    })
}

fn agent_session_rows(app: &TopApp, row: &ProcessRow) -> Vec<SessionRow> {
    if row.agent.is_none() {
        return Vec::new();
    }
    let events = app
        .snapshot
        .events
        .iter()
        .filter(|event| app.event_matches_agent_process(row, event))
        .cloned()
        .collect::<Vec<_>>();
    session_rows(&events)
}

fn process_tree_json(app: &TopApp, root_pid: u32) -> serde_json::Value {
    let mut visited = HashSet::new();
    process_tree_node_json(app, root_pid, &mut visited).unwrap_or(serde_json::Value::Null)
}

fn process_tree_node_json(
    app: &TopApp,
    pid: u32,
    visited: &mut HashSet<u32>,
) -> Option<serde_json::Value> {
    if !visited.insert(pid) {
        return None;
    }
    let process = app
        .snapshot
        .processes
        .iter()
        .find(|process| process.pid == pid)?;
    let mut children = app
        .snapshot
        .processes
        .iter()
        .filter(|candidate| candidate.ppid == pid)
        .map(|candidate| candidate.pid)
        .collect::<Vec<_>>();
    children.sort_unstable();
    let children = children
        .into_iter()
        .filter_map(|child_pid| process_tree_node_json(app, child_pid, visited))
        .collect::<Vec<_>>();

    Some(serde_json::json!({
        "pid": process.pid,
        "ppid": process.ppid,
        "cpu_pct": process.cpu_pct,
        "mem_pct": process.mem_pct,
        "elapsed": &process.elapsed,
        "cwd": process.cwd.as_deref(),
        "agent": process.agent.map(|agent| agent.label()),
        "risk": process.risk.label(),
        "command": &process.command,
        "children": children,
    }))
}

fn activity_json(activity: &AgentActivity) -> serde_json::Value {
    serde_json::json!({
        "events": activity.events,
        "sessions": activity.sessions,
        "tools": activity.tools,
        "security": activity.security,
        "files": activity.files,
        "egress": activity.egress,
        "llm": activity.llm,
        "tokens": {
            "prompt": activity.prompt_tokens,
            "completion": activity.completion_tokens,
            "total": activity.total_tokens,
        },
        "model": empty_dash_to_null(&activity.model),
        "provider": empty_dash_to_null(&activity.provider),
        "latency_ms_avg": average_u64(activity.latency_ms, activity.latency_samples),
        "ttft_ms_avg": average_u64(activity.ttft_ms, activity.ttft_samples),
        "wire": {
            "request_bytes": activity.req_bytes,
            "response_bytes": activity.resp_bytes,
        },
        "high_risk": activity.high_risk,
    })
}

fn session_json(row: &SessionRow) -> serde_json::Value {
    serde_json::json!({
        "source": &row.source,
        "session": &row.session,
        "task": empty_dash_to_null(&row.task),
        "workspace": empty_dash_to_null(&row.workspace),
        "events": row.events,
        "tools": row.tools,
        "security": row.security,
        "files": row.files,
        "egress": row.egress,
        "llm": row.llm,
        "tokens": {
            "prompt": row.prompt_tokens,
            "completion": row.completion_tokens,
            "total": row.total_tokens,
        },
        "model": empty_dash_to_null(&row.model),
        "provider": empty_dash_to_null(&row.provider),
        "latency_ms_avg": average_u64(row.latency_ms, row.latency_samples),
        "ttft_ms_avg": average_u64(row.ttft_ms, row.ttft_samples),
        "wire": {
            "request_bytes": row.req_bytes,
            "response_bytes": row.resp_bytes,
        },
        "high_risk": row.high_risk,
        "risk": row.risk.label(),
        "last_kind": &row.last_kind,
        "last_message": &row.last_message,
    })
}

fn container_json(app: &TopApp, row: &ContainerRow) -> serde_json::Value {
    let history = app.metric_history(&container_history_key(&row.id));
    serde_json::json!({
        "connector": row.connector.label(),
        "id": &row.id,
        "cid": short_id(&row.id),
        "short_id": short_id(&row.id),
        "name": &row.name,
        "image": &row.image,
        "status": &row.status,
        "cpu_pct": row.cpu_pct,
        "cpu_count": row.cpu_count,
        "cpu_usage_total_ns": row.cpu_usage_total_ns,
        "mem_pct": row.mem_pct,
        "mem_usage": &row.mem_usage,
        "net_io": &row.net_io,
        "block_io": &row.block_io,
        "pids": &row.pids,
        "ports": &row.ports,
        "history": metric_history_json(&history),
        "inspect": {
            "health": empty_dash_to_null(&row.inspect.health),
            "restarts": empty_dash_to_null(&row.inspect.restarts),
            "restart_policy": empty_dash_to_null(&row.inspect.restart_policy),
            "created": empty_dash_to_null(&row.inspect.created),
            "started": empty_dash_to_null(&row.inspect.started),
            "exit": empty_dash_to_null(&row.inspect.exit),
            "mounts": empty_dash_to_null(&row.inspect.mounts),
            "env": empty_dash_to_null(&row.inspect.env),
            "labels": empty_dash_to_null(&row.inspect.labels),
            "networks": empty_dash_to_null(&row.inspect.networks),
        },
    })
}

fn container_matches_query(container: &ContainerRow, query: &str) -> bool {
    let query = query.trim();
    if query.is_empty() {
        return true;
    }
    container.name == query
        || container.id == query
        || short_id(&container.id) == query
        || container.id.starts_with(query)
        || short_id(&container.id).starts_with(query)
}

fn container_target_filters(target: Option<&str>) -> Vec<Option<String>> {
    let Some(target) = target.map(str::trim).filter(|target| !target.is_empty()) else {
        return vec![None];
    };
    vec![Some(format!("name={target}")), Some(format!("id={target}"))]
}

fn delete_filter_word(filter: &mut String) {
    while filter.chars().last().is_some_and(|ch| ch.is_whitespace()) {
        filter.pop();
    }
    while filter.chars().last().is_some_and(|ch| !ch.is_whitespace()) {
        filter.pop();
    }
}

fn dedupe_container_rows(rows: &mut Vec<ContainerRow>) {
    let mut seen = HashSet::new();
    rows.retain(|row| seen.insert(row.id.clone()));
}

fn process_json(app: &TopApp, row: &ProcessRow) -> serde_json::Value {
    let history = app.metric_history(&process_history_key(row.pid));
    serde_json::json!({
        "pid": row.pid,
        "ppid": row.ppid,
        "cpu_pct": row.cpu_pct,
        "mem_pct": row.mem_pct,
        "history": metric_history_json(&history),
        "elapsed": &row.elapsed,
        "cwd": row.cwd.as_deref(),
        "agent": row.agent.map(|agent| agent.label()),
        "risk": row.risk.label(),
        "command": &row.command,
    })
}

fn metric_history_json(history: &MetricHistory) -> serde_json::Value {
    serde_json::json!({
        "cpu_pct": &history.cpu,
        "mem_pct": &history.mem,
        "net_io_bytes": &history.net_io_bytes,
        "block_io_bytes": &history.block_io_bytes,
    })
}

fn event_json(row: &EventRow) -> serde_json::Value {
    serde_json::json!({
        "ts": &row.ts,
        "source": &row.source,
        "session": row.session.as_deref(),
        "task": row.task.as_deref(),
        "pid": row.pid,
        "ppid": row.ppid,
        "kind": &row.kind,
        "message": &row.message,
        "risk": row.risk.label(),
        "details": row
            .details
            .iter()
            .map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
            .collect::<Vec<_>>(),
    })
}

fn average_u64(total: u64, samples: u64) -> Option<u64> {
    total.checked_div(samples)
}

fn empty_dash_to_null(value: &str) -> Option<&str> {
    let value = value.trim();
    (!value.is_empty() && value != "-").then_some(value)
}

fn parse_options(args: Vec<String>) -> anyhow::Result<TopOptions> {
    let mut options = TopOptions::default();
    let mut it = args.into_iter();
    while let Some(arg) = it.next() {
        match arg.as_str() {
            "--agents" => options.tab = Tab::Agents,
            "--sessions" => options.tab = Tab::Sessions,
            "--containers" => options.tab = Tab::Containers,
            "--processes" => options.tab = Tab::Processes,
            "--events" => options.tab = Tab::Events,
            "-a" | "--active" | "--active-only" => {
                options.force_active_containers = true;
                options.force_all_containers = false;
            }
            "--all" => {
                options.force_all_containers = true;
                options.force_active_containers = false;
            }
            "-f" | "--filter" => {
                options.filter = Some(
                    it.next()
                        .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?,
                );
            }
            "-s" | "--sort" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                options.sort_by = Some(SortBy::from_label(&value).ok_or_else(|| {
                    anyhow::anyhow!(
                        "unknown top sort field '{value}' (expected cpu, mem, net, block, pids, state, id, uptime, name, or tokens)"
                    )
                })?);
            }
            "--risk" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                options.risk_filter = Some(RiskFilter::from_label(&value).ok_or_else(|| {
                    anyhow::anyhow!(
                        "unknown top risk filter '{value}' (expected all, medium, or high)"
                    )
                })?);
            }
            "--kind" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                options.kind_filter = Some(KindFilter::from_label(&value).ok_or_else(|| {
                    anyhow::anyhow!(
                        "unknown top event kind filter '{value}' (expected all, tool, security, file, egress, llm, or other)"
                    )
                })?);
            }
            "--connector" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                options.connector =
                    Some(ContainerConnector::from_label(&value).ok_or_else(|| {
                        anyhow::anyhow!(
                            "unknown top connector '{value}' (expected a3s-box, docker, or runc)"
                        )
                    })?);
            }
            "--container" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                options.container_query = Some(value);
                options.tab = Tab::Containers;
            }
            "-r" | "--reverse" => options.reverse_sort = Some(true),
            "-i" | "--invert" => options.invert_colors = true,
            "--compact" | "--compact-columns" => options.compact_columns = true,
            "--json" => options.json = true,
            "--count" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                let count = value.parse::<usize>()?;
                if count == 0 {
                    return Err(anyhow::anyhow!("--count must be greater than zero"));
                }
                options.json_count = Some(count);
            }
            "--no-header" => options.show_header = Some(false),
            "--watch" | "--interval" => {
                let value = it
                    .next()
                    .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?;
                options.interval = parse_duration(&value)?;
                options.watch = true;
            }
            "-h" => options.start_help = true,
            "--help" => {
                print_help();
                std::process::exit(0);
            }
            "-v" | "-V" | "--version" => {
                print_version();
                std::process::exit(0);
            }
            other if other.starts_with('-') => {
                return Err(anyhow::anyhow!("unknown a3s top option '{other}'"));
            }
            other => {
                if options.container_query.is_some() {
                    return Err(anyhow::anyhow!("a3s top accepts only one container target"));
                }
                options.container_query = Some(other.to_string());
                options.tab = Tab::Containers;
            }
        }
    }
    Ok(options)
}

fn env_connector() -> Option<ContainerConnector> {
    std::env::var("A3S_TOP_CONNECTOR")
        .ok()
        .and_then(|value| ContainerConnector::from_label(&value))
}

fn print_help() {
    println!(
        "a3s top — live monitor for a3s-box containers, coding agents, and diagnostics\n\n\
         usage:\n  \
           a3s top [container] [--agents|--sessions|--containers|--processes|--events] [--connector a3s-box|docker|runc] [-a|--active] [--all] [-f|--filter a3s-box] [-s|--sort cpu|mem|net|block|pids|state|id|uptime|name|tokens] [--risk all|medium|high] [--kind all|tool|security|file|egress|llm|other] [-r|--reverse] [--compact] [--watch 1500ms] [--json] [--count 10] [-h]\n\n\
         options:\n  \
           --container ID open a ctop-style single-container view by name, CID, short CID, or ID prefix\n  \
           --processes open the advanced raw process diagnostics view\n  \
           -a, --active show active/running containers only, matching ctop\n  \
           --all include stopped, exited, and dead containers\n  \
           -f, --filter TEXT filter visible rows, matching ctop\n  \
           -s, --sort FIELD sort by cpu, mem, net, block, pids, state, id, uptime, name, or tokens\n  \
           -r, --reverse reverse sort order\n  \
           -h open the interactive help dialog at startup; --help prints this help\n  \
           --risk all|medium|high filter agent/process/session/event risk\n  \
           --kind all|tool|security|file|egress|llm|other filter observer event kind\n  \
           --compact restore the default compact column set, overriding saved columns\n  \
           --json print one machine-readable snapshot and exit; combine with --watch for NDJSON\n  \
           --count N limit JSON snapshots, useful with --json --watch\n  \
           -i, --invert reverse terminal colors · -v, --version show version\n\n\
         keys:\n  \
           Tab/Shift+Tab switch Agents/Containers · ↑/↓ select · Home/End jump · / or f filter · h help · s select sort · r reverse · Space/p pause\n  \
           ! risk filter · Enter container menu · ← logs · → container view · x detail · a all containers · H header · o focus agent/container/session · l logs · e shell · w browser\n  \
           g event kind filter · C connector · c columns · S save config · K terminate/stop · Esc clear filter/panel · q quit\n\n\
         observer:\n  \
           auto-discovers Claude, Codex, and A3S Code logs; use A3S_TOP_OBSERVER_LOG(S) to add explicit NDJSON/JSON files\n  \
           set A3S_TOP_OBSERVER_AUTO=0 to disable auto-discovery\n  \
           set A3S_TOP_CONNECTOR=a3s-box|docker|runc to change the default container connector\n  \
           runC connector honors RUNC_ROOT and RUNC_SYSTEMD_CGROUP"
    );
}

fn print_version() {
    println!("a3s top {}", env!("CARGO_PKG_VERSION"));
}

fn top_keymap() -> Keymap<TopKey> {
    let mut km = Keymap::new();
    km.register(KeyBinding::new(KeyCode::Char('q')), TopKey::Quit, "quit");
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('c'), KeyModifiers::CONTROL),
        TopKey::Quit,
        "quit",
    );
    km.register(KeyBinding::new(KeyCode::Up), TopKey::Up, "select up");
    km.register(KeyBinding::new(KeyCode::Char('k')), TopKey::Up, "select up");
    km.register(KeyBinding::new(KeyCode::Down), TopKey::Down, "select down");
    km.register(
        KeyBinding::new(KeyCode::Char('j')),
        TopKey::Down,
        "select down",
    );
    km.register(KeyBinding::new(KeyCode::Home), TopKey::Home, "select first");
    km.register(KeyBinding::new(KeyCode::End), TopKey::End, "select last");
    km.register(KeyBinding::new(KeyCode::PageUp), TopKey::PageUp, "page up");
    km.register(
        KeyBinding::new(KeyCode::PageDown),
        TopKey::PageDown,
        "page down",
    );
    km.register(KeyBinding::new(KeyCode::Tab), TopKey::NextTab, "next tab");
    km.register(KeyBinding::new(KeyCode::Right), TopKey::NextTab, "next tab");
    km.register(
        KeyBinding::new(KeyCode::BackTab),
        TopKey::PrevTab,
        "previous tab",
    );
    km.register(
        KeyBinding::new(KeyCode::Left),
        TopKey::PrevTab,
        "previous tab",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('/')),
        TopKey::Filter,
        "filter",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('f')),
        TopKey::Filter,
        "filter",
    );
    km.register(KeyBinding::new(KeyCode::Char('h')), TopKey::Help, "help");
    km.register(
        KeyBinding::new(KeyCode::Char('c')),
        TopKey::Columns,
        "columns",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('C')),
        TopKey::Connector,
        "connector",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('C'), KeyModifiers::SHIFT),
        TopKey::Connector,
        "connector",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('S')),
        TopKey::SaveConfig,
        "save config",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('S'), KeyModifiers::SHIFT),
        TopKey::SaveConfig,
        "save config",
    );
    km.register(KeyBinding::new(KeyCode::Char('s')), TopKey::Sort, "sort");
    km.register(
        KeyBinding::new(KeyCode::Char(' ')),
        TopKey::TogglePause,
        "pause",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('p')),
        TopKey::TogglePause,
        "pause",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('a')),
        TopKey::ToggleAll,
        "toggle all containers",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('H')),
        TopKey::ToggleHeader,
        "toggle header",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('H'), KeyModifiers::SHIFT),
        TopKey::ToggleHeader,
        "toggle header",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('r')),
        TopKey::ToggleReverse,
        "reverse sort",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('R')),
        TopKey::ToggleReverse,
        "reverse sort",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('R'), KeyModifiers::SHIFT),
        TopKey::ToggleReverse,
        "reverse sort",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('!')),
        TopKey::ToggleRiskFilter,
        "risk filter",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('!'), KeyModifiers::SHIFT),
        TopKey::ToggleRiskFilter,
        "risk filter",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('g')),
        TopKey::ToggleKindFilter,
        "event kind filter",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('G'), KeyModifiers::SHIFT),
        TopKey::ToggleKindFilter,
        "event kind filter",
    );
    km.register(KeyBinding::new(KeyCode::Enter), TopKey::Detail, "detail");
    km.register(
        KeyBinding::new(KeyCode::Char('x')),
        TopKey::Detail,
        "detail",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('o')),
        TopKey::Open,
        "focus container",
    );
    km.register(KeyBinding::new(KeyCode::Char('l')), TopKey::Logs, "logs");
    km.register(
        KeyBinding::new(KeyCode::Char('w')),
        TopKey::OpenBrowser,
        "open browser",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('e')),
        TopKey::ExecShell,
        "exec shell",
    );
    km.register(
        KeyBinding::new(KeyCode::Char('K')),
        TopKey::Kill,
        "terminate",
    );
    km.register(
        KeyBinding::with_modifiers(KeyCode::Char('K'), KeyModifiers::SHIFT),
        TopKey::Kill,
        "terminate",
    );
    km
}

fn refresh_cmd(
    connector: ContainerConnector,
    show_all_containers: bool,
    container_target: Option<String>,
    observer: ObserverState,
) -> Cmd<Msg> {
    cmd::cmd(move || async move {
        let (snapshot, observer) =
            collect_snapshot(connector, show_all_containers, container_target, observer).await;
        Msg::Snapshot {
            connector,
            snapshot,
            observer,
        }
    })
}

fn single_or_batch(mut cmds: Vec<Cmd<Msg>>) -> Cmd<Msg> {
    if cmds.len() == 1 {
        cmds.remove(0)
    } else {
        cmd::batch(cmds)
    }
}

fn container_logs_cmd(
    connector: ContainerConnector,
    id: String,
    name: String,
    timestamps: bool,
) -> Cmd<Msg> {
    cmd::cmd(move || async move {
        let result = collect_container_logs(connector, &id, timestamps).await;
        Msg::ContainerLogs {
            connector,
            id,
            name,
            timestamps,
            result,
        }
    })
}

fn container_processes_cmd(container: ContainerRow) -> Cmd<Msg> {
    cmd::cmd(move || async move {
        let id = container.id.clone();
        let name = container.name.clone();
        let result = collect_container_processes(container.connector, &container.id).await;
        Msg::ContainerProcesses { id, name, result }
    })
}

fn save_config_cmd(config: TopConfig) -> Cmd<Msg> {
    cmd::cmd(move || async move { Msg::ConfigSaved(save_top_config(config).await) })
}

fn top_config_path() -> PathBuf {
    if let Some(path) = std::env::var_os("A3S_TOP_CONFIG") {
        return PathBuf::from(path);
    }
    if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
        return PathBuf::from(config_home).join("a3s").join("top.json");
    }
    if let Some(home) = std::env::var_os("HOME") {
        return PathBuf::from(home)
            .join(".config")
            .join("a3s")
            .join("top.json");
    }
    PathBuf::from(".a3s-top.json")
}

fn load_top_config() -> Result<TopConfig, String> {
    let path = top_config_path();
    if !path.exists() {
        return Ok(TopConfig::default());
    }
    let text =
        std::fs::read_to_string(&path).map_err(|err| format!("read {}: {err}", path.display()))?;
    parse_top_config(&text)
}

fn parse_top_config(text: &str) -> Result<TopConfig, String> {
    let value: serde_json::Value = serde_json::from_str(text).map_err(|err| err.to_string())?;
    let mut config = TopConfig::default();

    if let Some(v) = value.get("show_all_containers").and_then(|v| v.as_bool()) {
        config.show_all_containers = v;
    }
    if let Some(v) = value.get("show_header").and_then(|v| v.as_bool()) {
        config.show_header = v;
    }
    if let Some(v) = value.get("reverse_sort").and_then(|v| v.as_bool()) {
        config.reverse_sort = v;
    }
    if let Some(sort) = value
        .get("sort_by")
        .and_then(|v| v.as_str())
        .and_then(SortBy::from_label)
    {
        config.sort_by = sort;
    }
    if let Some(risk_filter) = value
        .get("risk_filter")
        .and_then(|v| v.as_str())
        .and_then(RiskFilter::from_label)
    {
        config.risk_filter = risk_filter;
    }
    if let Some(kind_filter) = value
        .get("kind_filter")
        .and_then(|v| v.as_str())
        .and_then(KindFilter::from_label)
    {
        config.kind_filter = kind_filter;
    }
    if let Some(connector) = value
        .get("connector")
        .and_then(|v| v.as_str())
        .and_then(ContainerConnector::from_label)
    {
        config.connector = connector;
    }
    if let Some(filter) = value.get("filter").and_then(|v| v.as_str()) {
        config.filter = filter.to_string();
    }
    if let Some(columns) = value.get("hidden_columns").and_then(|v| v.as_array()) {
        config.hidden_columns = columns
            .iter()
            .filter_map(|v| v.as_str().map(ToString::to_string))
            .collect();
    }

    Ok(config)
}

fn top_config_json(config: &TopConfig) -> String {
    let mut hidden_columns = config.hidden_columns.iter().cloned().collect::<Vec<_>>();
    hidden_columns.sort();
    serde_json::to_string_pretty(&serde_json::json!({
        "show_all_containers": config.show_all_containers,
        "show_header": config.show_header,
        "reverse_sort": config.reverse_sort,
        "sort_by": config.sort_by.label(),
        "risk_filter": config.risk_filter.label(),
        "kind_filter": config.kind_filter.label(),
        "connector": config.connector.label(),
        "filter": &config.filter,
        "hidden_columns": hidden_columns,
    }))
    .unwrap_or_else(|_| "{}".to_string())
}

async fn save_top_config(config: TopConfig) -> Result<PathBuf, String> {
    let path = top_config_path();
    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent)
            .await
            .map_err(|err| format!("create {}: {err}", parent.display()))?;
    }
    tokio::fs::write(&path, top_config_json(&config))
        .await
        .map_err(|err| format!("write {}: {err}", path.display()))?;
    Ok(path)
}

async fn run_external_action(action: ExternalAction) -> anyhow::Result<()> {
    match action {
        ExternalAction::ContainerShell {
            connector,
            id,
            name,
        } => {
            println!(
                "opening shell in {} container {name} ({})",
                connector.label(),
                short_id(&id)
            );
            match connector {
                ContainerConnector::A3sBox => {
                    let a3s_box = crate::box_cmd::ensure_a3s_box()?;
                    let status = std::process::Command::new(a3s_box)
                        .args(["shell", &id])
                        .status()?;
                    if !status.success() {
                        eprintln!("a3s-box shell exited with status {status}");
                    }
                }
                ContainerConnector::Docker => {
                    let shell = detect_container_shell(&id);
                    let status = std::process::Command::new("docker")
                        .args(["exec", "-it", &id, &shell])
                        .status()?;
                    if !status.success() {
                        eprintln!("docker exec exited with status {status}");
                    }
                }
                ContainerConnector::RunC => {
                    eprintln!("runc shell is not supported from a3s top yet");
                }
            }
        }
        ExternalAction::OpenBrowser { url, name } => {
            println!("opening browser for container {name}: {url}");
            if let Err(err) = open_url(&url) {
                eprintln!("failed to open browser: {err}");
            }
        }
    }
    Ok(())
}

fn open_url(url: &str) -> anyhow::Result<()> {
    let status = {
        #[cfg(target_os = "macos")]
        {
            std::process::Command::new("open").arg(url).status()
        }
        #[cfg(target_os = "windows")]
        {
            std::process::Command::new("cmd")
                .args(["/C", "start", "", url])
                .status()
        }
        #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
        {
            std::process::Command::new("xdg-open").arg(url).status()
        }
    }?;

    if status.success() {
        Ok(())
    } else {
        Err(anyhow::anyhow!(
            "browser opener exited with status {status}"
        ))
    }
}

fn detect_container_shell(id: &str) -> String {
    let output = std::process::Command::new("docker")
        .args([
            "exec",
            id,
            "sh",
            "-c",
            "command -v bash 2>/dev/null || command -v sh 2>/dev/null",
        ])
        .output();
    let Ok(output) = output else {
        return "sh".to_string();
    };
    if !output.status.success() {
        return "sh".to_string();
    }
    let shell = String::from_utf8_lossy(&output.stdout)
        .lines()
        .next()
        .unwrap_or("sh")
        .trim()
        .to_string();
    if shell.is_empty() {
        "sh".to_string()
    } else {
        shell
    }
}

fn runc_command() -> Command {
    let mut command = Command::new("runc");
    for arg in runc_global_args() {
        command.arg(arg);
    }
    command
}

fn runc_global_args() -> Vec<String> {
    let root = std::env::var("RUNC_ROOT").unwrap_or_else(|_| "/run/runc".to_string());
    let mut args = vec!["--root".to_string(), root];
    if env_flag("RUNC_SYSTEMD_CGROUP") {
        args.push("--systemd-cgroup".to_string());
    }
    args
}

fn env_flag(key: &str) -> bool {
    std::env::var(key)
        .ok()
        .map(|value| {
            matches!(
                value.trim().to_ascii_lowercase().as_str(),
                "1" | "true" | "yes" | "on"
            )
        })
        .unwrap_or(false)
}

async fn collect_snapshot(
    connector: ContainerConnector,
    show_all_containers: bool,
    container_target: Option<String>,
    observer: ObserverState,
) -> (TopSnapshot, ObserverState) {
    let (processes, containers, observer) = tokio::join!(
        collect_processes(),
        collect_containers(connector, show_all_containers, container_target.as_deref()),
        collect_observer_events(observer),
    );
    let (processes, process_error) = match processes {
        Ok(rows) => (rows, None),
        Err(err) => (Vec::new(), Some(format!("process collector: {err}"))),
    };
    let (containers, container_error) = match containers {
        Ok(rows) => (rows, None),
        Err(err) => (Vec::new(), Some(format!("container collector: {err}"))),
    };

    let mut errors = Vec::new();
    errors.extend(process_error);
    errors.extend(container_error);
    let mut events = observer.events.clone();
    if events.is_empty() {
        events.extend(errors.iter().map(|err| EventRow {
            ts: "now".into(),
            source: "collector".into(),
            session: None,
            task: None,
            pid: None,
            ppid: None,
            kind: "warning".into(),
            message: err.clone(),
            details: vec![("error".into(), err.clone())],
            risk: Risk::Medium,
        }));
    }

    (
        TopSnapshot {
            processes,
            containers,
            events,
            errors,
        },
        observer,
    )
}

async fn collect_containers(
    connector: ContainerConnector,
    show_all: bool,
    target: Option<&str>,
) -> anyhow::Result<Vec<ContainerRow>> {
    match connector {
        ContainerConnector::A3sBox => collect_a3s_box_containers(show_all, target).await,
        ContainerConnector::Docker => collect_docker_containers(show_all, target).await,
        ContainerConnector::RunC => collect_runc_containers(show_all, target).await,
    }
}

async fn collect_a3s_box_containers(
    show_all: bool,
    target: Option<&str>,
) -> anyhow::Result<Vec<ContainerRow>> {
    let a3s_box = ensure_a3s_box_binary().await?;
    let mut containers = Vec::new();
    for filter in container_target_filters(target) {
        containers.extend(a3s_box_ps_rows(&a3s_box, show_all, filter.as_deref()).await?);
    }
    dedupe_container_rows(&mut containers);
    if containers.is_empty() {
        return Ok(containers);
    }

    if let Some(by_id) = a3s_box_stats_rows(&a3s_box).await {
        for container in &mut containers {
            if let Some(stats) = by_id
                .get(&container.id)
                .or_else(|| by_id.get(short_id(&container.id)))
                .or_else(|| by_id.get(&container.name))
            {
                container.cpu_pct = stats.cpu_pct;
                container.cpu_count = stats.cpu_count;
                container.mem_pct = stats.mem_pct;
                container.mem_usage = stats.mem_usage.clone();
                container.net_io = stats.net_io.clone();
                container.block_io = stats.block_io.clone();
                if let Some(pids) = stats.pids_current {
                    container.pids = pids.to_string();
                }
            }
        }
    }
    enrich_a3s_box_process_counts(&mut containers, &a3s_box).await;
    enrich_a3s_box_inspect(&mut containers, &a3s_box).await;
    Ok(containers)
}

async fn enrich_a3s_box_process_counts(containers: &mut [ContainerRow], a3s_box: &Path) {
    let targets = containers
        .iter()
        .filter(|container| container_is_running(&container.status))
        .filter(|container| container.pids == "-")
        .map(|container| container.id.clone())
        .collect::<Vec<_>>();
    if targets.is_empty() {
        return;
    }

    let counts = stream::iter(targets.into_iter().map(|id| {
        let a3s_box = a3s_box.to_path_buf();
        async move {
            let count = a3s_box_container_process_count(&a3s_box, &id).await;
            (id, count)
        }
    }))
    .buffer_unordered(A3S_BOX_PIDS_CONCURRENCY)
    .collect::<Vec<_>>()
    .await
    .into_iter()
    .filter_map(|(id, count)| count.map(|count| (id, count)))
    .collect::<HashMap<_, _>>();

    for container in containers {
        if let Some(count) = counts.get(&container.id) {
            container.pids = count.to_string();
        }
    }
}

async fn a3s_box_container_process_count(a3s_box: &Path, id: &str) -> Option<usize> {
    let rows = tokio::time::timeout(
        A3S_BOX_PIDS_TIMEOUT,
        collect_a3s_box_container_processes_with_binary(a3s_box, id),
    )
    .await
    .ok()?
    .ok()?;
    Some(a3s_box_process_count_from_rows(&rows))
}

async fn a3s_box_ps_rows(
    a3s_box: &Path,
    show_all: bool,
    filter: Option<&str>,
) -> anyhow::Result<Vec<ContainerRow>> {
    let mut ps_args = vec!["ps".to_string(), "--format".to_string(), "json".to_string()];
    if show_all {
        ps_args.insert(1, "-a".to_string());
    }
    if let Some(filter) = filter {
        ps_args.push("--filter".to_string());
        ps_args.push(filter.to_string());
    }
    let json_command_label = format!("a3s-box {}", ps_args.join(" "));

    let json_error = match Command::new(a3s_box).args(ps_args).output().await {
        Ok(ps) if ps.status.success() => {
            let text = String::from_utf8_lossy(&ps.stdout);
            let rows = parse_a3s_box_ps_json(&text);
            if !rows.is_empty() || text.trim() == "[]" {
                return Ok(rows);
            }
            Some(format!("{json_command_label} returned unparseable JSON"))
        }
        Ok(ps) => Some(command_output_error(&json_command_label, &ps)),
        Err(err) => Some(format!("failed to run {json_command_label}: {err}")),
    };

    match a3s_box_ps_output(a3s_box, show_all, filter).await {
        Ok(output) => Ok(parse_a3s_box_ps(&output)),
        Err(err) => Err(anyhow::anyhow!(
            "{}; fallback table failed: {err}",
            json_error.unwrap_or_else(|| "a3s-box ps JSON output was unavailable".to_string())
        )),
    }
}

async fn a3s_box_ps_output(
    a3s_box: &Path,
    show_all: bool,
    filter: Option<&str>,
) -> anyhow::Result<String> {
    let mut ps_args = vec![
        "ps".to_string(),
        "--format".to_string(),
        "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.Created}}\t{{.Command}}"
            .to_string(),
    ];
    if show_all {
        ps_args.insert(1, "-a".to_string());
    }
    if let Some(filter) = filter {
        ps_args.push("--filter".to_string());
        ps_args.push(filter.to_string());
    }
    let command_label = format!("a3s-box {}", ps_args.join(" "));

    let ps = Command::new(a3s_box).args(ps_args).output().await;
    let ps = ps.map_err(|err| anyhow::anyhow!("failed to run {command_label}: {err}"))?;
    if !ps.status.success() {
        return Err(anyhow::anyhow!(command_output_error(&command_label, &ps)));
    }
    Ok(String::from_utf8_lossy(&ps.stdout).into_owned())
}

fn command_output_error(command: &str, output: &std::process::Output) -> String {
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let detail = if !stderr.is_empty() {
        stderr
    } else if !stdout.is_empty() {
        stdout
    } else {
        "no output".to_string()
    };
    format!("{command} exited with status {}: {detail}", output.status)
}

async fn a3s_box_stats_rows(a3s_box: &Path) -> Option<HashMap<String, A3sBoxStatsRow>> {
    let json = Command::new(a3s_box)
        .args(["stats", "--no-stream", "--format", "json"])
        .output()
        .await
        .ok();
    if let Some(json) = json.filter(|output| output.status.success()) {
        let text = String::from_utf8_lossy(&json.stdout);
        let rows = parse_a3s_box_stats_json(&text);
        if !rows.is_empty() || text.trim() == "[]" {
            return Some(rows);
        }
    }

    let table = Command::new(a3s_box)
        .args(["stats", "--no-stream"])
        .output()
        .await
        .ok()?;
    if !table.status.success() {
        return None;
    }
    Some(parse_a3s_box_stats(&String::from_utf8_lossy(&table.stdout)))
}

async fn ensure_a3s_box_binary() -> anyhow::Result<PathBuf> {
    // Resolving the binary scans $PATH + stats several candidate paths; the
    // location is stable for the session, so memoize the first success (errors
    // are not cached, so a missing binary is retried next refresh).
    static A3S_BOX_BIN: tokio::sync::OnceCell<PathBuf> = tokio::sync::OnceCell::const_new();
    A3S_BOX_BIN
        .get_or_try_init(|| async {
            tokio::task::spawn_blocking(crate::box_cmd::ensure_a3s_box)
                .await
                .map_err(|err| anyhow::anyhow!("a3s-box installer task failed: {err}"))?
        })
        .await
        .cloned()
}

#[derive(Debug, Clone, PartialEq)]
struct A3sBoxStatsRow {
    cpu_pct: Option<f32>,
    cpu_count: Option<u32>,
    mem_pct: Option<f32>,
    mem_usage: String,
    net_io: String,
    block_io: String,
    pid: Option<String>,
    pids_current: Option<u64>,
}

fn parse_a3s_box_ps(text: &str) -> Vec<ContainerRow> {
    text.lines()
        .filter_map(|line| {
            let mut parts = line.splitn(7, '\t');
            let id = parts.next()?.trim();
            if id.is_empty() {
                return None;
            }
            let name = parts.next().unwrap_or(id).trim();
            let image = parts.next().unwrap_or("-").trim();
            let status = parts.next().unwrap_or("-").trim();
            let ports = parts.next().unwrap_or("").trim();
            let created = parts.next().unwrap_or("-").trim();
            let command = parts.next().unwrap_or("").trim();
            let mut inspect = ContainerInspect {
                created: created.to_string(),
                started: created.to_string(),
                ..ContainerInspect::default()
            };
            if !command.is_empty() {
                inspect.labels = format!("command={}", truncate(command, 80));
            }
            Some(ContainerRow {
                connector: ContainerConnector::A3sBox,
                id: id.to_string(),
                name: if name.is_empty() {
                    id.to_string()
                } else {
                    name.to_string()
                },
                image: if image.is_empty() {
                    "-".into()
                } else {
                    image.to_string()
                },
                status: if status.is_empty() {
                    "-".into()
                } else {
                    status.to_string()
                },
                inspect,
                ports: normalize_ports(ports),
                cpu_pct: None,
                cpu_count: None,
                cpu_usage_total_ns: None,
                mem_pct: None,
                mem_usage: "-".into(),
                net_io: "-".into(),
                block_io: "-".into(),
                pids: "-".into(),
            })
        })
        .collect()
}

fn parse_a3s_box_ps_json(text: &str) -> Vec<ContainerRow> {
    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
        return Vec::new();
    };
    let items = match value {
        serde_json::Value::Array(items) => items,
        serde_json::Value::Object(mut object) => ["containers", "boxes", "items"]
            .iter()
            .find_map(|key| object.remove(*key))
            .and_then(|value| value.as_array().cloned())
            .unwrap_or_default(),
        _ => return Vec::new(),
    };

    items.iter().filter_map(parse_a3s_box_ps_json_row).collect()
}

fn parse_a3s_box_ps_json_row(item: &serde_json::Value) -> Option<ContainerRow> {
    let id = json_string(item, &["id", "Id", "ID"])
        .or_else(|| json_string(item, &["short_id", "shortId", "cid", "CID"]))?;
    let name = json_string(item, &["name", "names", "Names"]).unwrap_or_else(|| id.clone());
    let image = json_string(item, &["image", "Image"]).unwrap_or_else(|| "-".into());
    let status = json_string(item, &["raw_status", "rawStatus", "state", "State"])
        .or_else(|| json_string(item, &["status", "Status"]))
        .unwrap_or_else(|| "-".into());
    let ports = json_ports_text(item);
    let created_at = json_string(item, &["created_at", "createdAt"]);
    let started_at = json_string(item, &["started_at", "startedAt"]);
    let created = json_string(item, &["created", "Created"])
        .or_else(|| {
            created_at
                .as_deref()
                .map(|value| compact_timestamp(Some(value)))
        })
        .unwrap_or_else(|| "-".into());
    let started = started_at
        .as_deref()
        .map(|value| compact_timestamp(Some(value)))
        .unwrap_or_else(|| created.clone());
    let command = json_string(item, &["command", "Command"]).unwrap_or_default();
    let health = json_string(item, &["health", "Health"])
        .filter(|value| !value.eq_ignore_ascii_case("none"))
        .unwrap_or_else(|| "-".into());
    let labels = a3s_box_ps_labels_summary(item.get("labels"), &command);

    Some(ContainerRow {
        connector: ContainerConnector::A3sBox,
        id,
        name,
        image,
        status,
        inspect: ContainerInspect {
            health,
            created,
            started,
            labels,
            ..ContainerInspect::default()
        },
        ports: normalize_ports(&ports),
        cpu_pct: None,
        cpu_count: None,
        cpu_usage_total_ns: None,
        mem_pct: None,
        mem_usage: "-".into(),
        net_io: "-".into(),
        block_io: "-".into(),
        pids: "-".into(),
    })
}

fn json_ports_text(value: &serde_json::Value) -> String {
    if let Some(ports) = json_string(value, &["ports_text", "portsText"]) {
        return ports;
    }
    if let Some(ports) = value
        .get("ports")
        .or_else(|| value.get("Ports"))
        .and_then(serde_json::Value::as_array)
    {
        return ports
            .iter()
            .filter_map(|port| match port {
                serde_json::Value::String(value) => Some(value.trim().to_string()),
                serde_json::Value::Number(value) => Some(value.to_string()),
                _ => None,
            })
            .filter(|value| !value.is_empty())
            .collect::<Vec<_>>()
            .join(", ");
    }
    json_string(value, &["ports", "Ports"]).unwrap_or_default()
}

fn a3s_box_ps_labels_summary(labels: Option<&serde_json::Value>, command: &str) -> String {
    let mut parts = Vec::new();
    if !command.trim().is_empty() {
        parts.push(format!("command={}", truncate(command, 80)));
    }
    let labels = docker_label_summary(labels);
    if labels != "-" {
        parts.push(labels);
    }
    if parts.is_empty() {
        "-".into()
    } else {
        parts.join(", ")
    }
}

fn parse_a3s_box_stats(text: &str) -> HashMap<String, A3sBoxStatsRow> {
    let mut rows = HashMap::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty()
            || line.starts_with("BOX ID")
            || line.starts_with("No active boxes")
            || line.starts_with("NAME ")
        {
            continue;
        }
        let Some((id, name, stats)) = parse_a3s_box_stats_line(line) else {
            continue;
        };
        rows.insert(id.clone(), stats.clone());
        rows.insert(name, stats);
    }
    rows
}

fn parse_a3s_box_stats_json(text: &str) -> HashMap<String, A3sBoxStatsRow> {
    let mut rows = HashMap::new();
    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
        return rows;
    };
    let items = match value {
        serde_json::Value::Array(items) => items,
        serde_json::Value::Object(mut object) => match object.remove("containers") {
            Some(serde_json::Value::Array(items)) => items,
            _ => return rows,
        },
        _ => return rows,
    };

    for item in items {
        let Some((id, name, stats)) = parse_a3s_box_stats_json_row(&item) else {
            continue;
        };
        rows.insert(id.clone(), stats.clone());
        if let Some(short) = json_string(&item, &["short_id", "shortId"]).filter(|s| s != &id) {
            rows.insert(short, stats.clone());
        }
        rows.insert(name, stats);
    }

    rows
}

fn parse_a3s_box_stats_json_row(
    item: &serde_json::Value,
) -> Option<(String, String, A3sBoxStatsRow)> {
    let id = json_string(item, &["id", "short_id", "shortId"])?;
    let name = json_string(item, &["name", "names"]).unwrap_or_else(|| id.clone());
    let memory_bytes = json_u64_field(item, &["memory_bytes", "memoryBytes"])?;
    let memory_limit_bytes = json_u64_field(item, &["memory_limit_bytes", "memoryLimitBytes"])?;
    let network_rx = json_u64_field(item, &["network_rx_bytes", "networkRxBytes"]).unwrap_or(0);
    let network_tx = json_u64_field(item, &["network_tx_bytes", "networkTxBytes"]).unwrap_or(0);
    let block_read = json_u64_field(item, &["block_read_bytes", "blockReadBytes"]).unwrap_or(0);
    let block_write = json_u64_field(item, &["block_write_bytes", "blockWriteBytes"]).unwrap_or(0);
    let mem_pct = json_f64_field(item, &["memory_percent", "memoryPercent"]).map(|v| v as f32);
    let cpu_count = json_u64_field(item, &["cpus", "cpu_count", "cpuCount"])
        .and_then(|value| u32::try_from(value).ok())
        .filter(|value| *value > 0);
    let pids_current = json_u64_field(item, &["pids_current", "pidsCurrent"])
        .or_else(|| item.pointer("/pids/current").and_then(json_u64));

    Some((
        id,
        name,
        A3sBoxStatsRow {
            cpu_pct: json_f64_field(item, &["cpu_percent", "cpuPercent"]).map(|v| v as f32),
            cpu_count,
            mem_pct,
            mem_usage: format_byte_pair_decimal(memory_bytes, memory_limit_bytes),
            net_io: format_byte_pair_decimal(network_rx, network_tx),
            block_io: format_byte_pair_decimal(block_read, block_write),
            pid: json_u64_field(item, &["pid"]).map(|pid| pid.to_string()),
            pids_current,
        },
    ))
}

fn json_string(value: &serde_json::Value, fields: &[&str]) -> Option<String> {
    fields.iter().find_map(|field| {
        value
            .get(*field)
            .and_then(serde_json::Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    })
}

fn json_u64_field(value: &serde_json::Value, fields: &[&str]) -> Option<u64> {
    fields.iter().find_map(|field| {
        let value = value.get(*field)?;
        value
            .as_u64()
            .or_else(|| value.as_str()?.trim().parse::<u64>().ok())
    })
}

fn json_f64_field(value: &serde_json::Value, fields: &[&str]) -> Option<f64> {
    fields.iter().find_map(|field| {
        let value = value.get(*field)?;
        value.as_f64().or_else(|| {
            value
                .as_str()?
                .trim()
                .trim_end_matches('%')
                .parse::<f64>()
                .ok()
        })
    })
}

fn format_byte_pair_decimal(first: u64, second: u64) -> String {
    format!(
        "{} / {}",
        format_bytes_decimal(first),
        format_bytes_decimal(second)
    )
}

fn format_bytes_decimal(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = 1024 * KB;
    const GB: u64 = 1024 * MB;

    if bytes >= GB {
        format!("{:.1} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.1} KB", bytes as f64 / KB as f64)
    } else {
        format!("{bytes} B")
    }
}

fn parse_a3s_box_stats_line(line: &str) -> Option<(String, String, A3sBoxStatsRow)> {
    let mut parts = line.split_whitespace();
    let id = parts.next()?.to_string();
    let name = parts.next()?.to_string();
    let _status = parts.next()?;
    let cpu_pct = parts.next().and_then(parse_percent);
    let rest = parts.collect::<Vec<_>>();
    let mem_pct_idx = rest.iter().rposition(|part| part.ends_with('%'))?;
    let mem_pct = parse_percent(rest[mem_pct_idx]);
    let pid = rest.get(mem_pct_idx + 1).map(|value| (*value).to_string());
    let (net_io, block_io) = split_a3s_box_stats_io(rest.get(mem_pct_idx + 2..).unwrap_or(&[]));
    let mem_usage = rest[..mem_pct_idx].join(" ");
    Some((
        id,
        name,
        A3sBoxStatsRow {
            cpu_pct,
            cpu_count: None,
            mem_pct,
            mem_usage: if mem_usage.is_empty() {
                "-".into()
            } else {
                mem_usage
            },
            net_io,
            block_io,
            pid,
            pids_current: None,
        },
    ))
}

fn split_a3s_box_stats_io(parts: &[&str]) -> (String, String) {
    if parts.is_empty() {
        return ("-".into(), "-".into());
    }

    let slash_positions = parts
        .iter()
        .enumerate()
        .filter_map(|(idx, part)| (*part == "/").then_some(idx))
        .collect::<Vec<_>>();

    if slash_positions.len() >= 2 && slash_positions[1] >= 2 {
        let second_pair_start = slash_positions[1] - 2;
        let net_io = parts[..second_pair_start].join(" ");
        let block_io = parts[second_pair_start..].join(" ");
        return (non_empty_or_dash(net_io), non_empty_or_dash(block_io));
    }

    ("-".into(), parts.join(" "))
}

fn non_empty_or_dash(value: String) -> String {
    if value.is_empty() {
        "-".into()
    } else {
        value
    }
}

// ponytail: process-global inspect cache. `a3s-box inspect` output is
// near-static (created/started/labels/health), so a short TTL eliminates the
// per-tick fan-out (was up to A3S_BOX_INSPECT_LIMIT subprocess spawns every
// refresh). Keyed by the requested container id; pruned to live ids each pass.
static INSPECT_CACHE: std::sync::OnceLock<Mutex<HashMap<String, (Instant, ContainerInspect)>>> =
    std::sync::OnceLock::new();
const INSPECT_TTL: Duration = Duration::from_secs(10);

async fn enrich_a3s_box_inspect(containers: &mut [ContainerRow], a3s_box: &Path) {
    if containers.is_empty() {
        return;
    }
    let cache = INSPECT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
    let now = Instant::now();

    // Only inspect ids whose cached entry is missing or past its TTL.
    let stale_ids = {
        let map = cache.lock().unwrap();
        containers
            .iter()
            .take(A3S_BOX_INSPECT_LIMIT)
            .filter(|container| match map.get(&container.id) {
                Some((at, _)) => now.duration_since(*at) >= INSPECT_TTL,
                None => true,
            })
            .map(|container| container.id.clone())
            .collect::<Vec<_>>()
    };

    if !stale_ids.is_empty() {
        let fresh: Vec<(String, Option<ContainerInspect>)> = stream::iter(stale_ids)
            .map(|id| {
                let a3s_box = a3s_box.to_path_buf();
                async move {
                    let inspect = collect_a3s_box_inspect(&a3s_box, &id).await;
                    (id, inspect)
                }
            })
            .buffer_unordered(8)
            .collect()
            .await;
        let mut map = cache.lock().unwrap();
        for (id, inspect) in fresh {
            if let Some(inspect) = inspect {
                map.insert(id, (now, inspect));
            }
        }
    }

    let live_ids: HashSet<String> = containers.iter().map(|c| c.id.clone()).collect();
    let mut map = cache.lock().unwrap();
    map.retain(|id, _| live_ids.contains(id));
    for container in containers.iter_mut() {
        if let Some((_, inspect)) = map.get(&container.id) {
            container.inspect = inspect.clone();
        }
    }
}

async fn collect_a3s_box_inspect(a3s_box: &Path, id: &str) -> Option<ContainerInspect> {
    let output = tokio::time::timeout(
        Duration::from_millis(1200),
        Command::new(a3s_box).args(["inspect", id]).output(),
    )
    .await
    .ok()?
    .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_a3s_box_inspect(&String::from_utf8_lossy(&output.stdout))
        .into_values()
        .next()
}

fn parse_a3s_box_inspect(text: &str) -> HashMap<String, ContainerInspect> {
    let mut by_id = HashMap::new();
    if let Ok(value) = serde_json::from_str::<serde_json::Value>(text) {
        match value {
            serde_json::Value::Array(items) => {
                for item in items {
                    if let Some((id, inspect)) = parse_a3s_box_inspect_record(&item) {
                        by_id.insert(short_id(&id).to_string(), inspect.clone());
                        by_id.insert(id, inspect);
                    }
                }
                return by_id;
            }
            value => {
                if let Some((id, inspect)) = parse_a3s_box_inspect_record(&value) {
                    by_id.insert(short_id(&id).to_string(), inspect.clone());
                    by_id.insert(id, inspect);
                    return by_id;
                }
            }
        }
    }

    for line in text.lines() {
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };
        if let Some((id, inspect)) = parse_a3s_box_inspect_record(&value) {
            by_id.insert(short_id(&id).to_string(), inspect.clone());
            by_id.insert(id, inspect);
        }
    }
    by_id
}

fn parse_a3s_box_inspect_record(value: &serde_json::Value) -> Option<(String, ContainerInspect)> {
    let id = value
        .get("id")
        .or_else(|| value.get("Id"))
        .and_then(|v| v.as_str())?
        .to_string();
    let status = value
        .get("status")
        .and_then(|v| v.as_str())
        .unwrap_or_default();
    let status_detail = value
        .get("status_detail")
        .unwrap_or(&serde_json::Value::Null);
    let state = value.get("State").unwrap_or(&serde_json::Value::Null);

    Some((
        id,
        ContainerInspect {
            health: a3s_box_health_label(value, status_detail),
            restarts: value
                .get("restart_count")
                .and_then(json_u64)
                .map(|count| count.to_string())
                .unwrap_or_else(|| "-".into()),
            restart_policy: a3s_box_restart_policy(value),
            created: compact_timestamp(value.get("created_at").and_then(|v| v.as_str())),
            started: compact_timestamp(value.get("started_at").and_then(|v| v.as_str())),
            exit: a3s_box_exit_label(value, state, status),
            mounts: a3s_box_mounts_summary(value),
            env: a3s_box_env_summary(value.get("env")),
            labels: docker_label_summary(value.get("labels")),
            networks: a3s_box_network_summary(value),
        },
    ))
}

fn a3s_box_health_label(value: &serde_json::Value, status_detail: &serde_json::Value) -> String {
    status_detail
        .get("health")
        .or_else(|| value.get("health_status"))
        .and_then(|v| v.as_str())
        .filter(|health| !health.is_empty() && *health != "none")
        .map(ToString::to_string)
        .unwrap_or_else(|| "-".into())
}

fn a3s_box_restart_policy(value: &serde_json::Value) -> String {
    let policy = value
        .get("restart_policy")
        .and_then(|v| v.as_str())
        .unwrap_or("no");
    let max = value
        .get("max_restart_count")
        .and_then(json_u64)
        .unwrap_or(0);
    if policy == "on-failure" && max > 0 {
        format!("{policy}:{max}")
    } else if policy.is_empty() {
        "-".into()
    } else {
        policy.to_string()
    }
}

fn a3s_box_exit_label(
    value: &serde_json::Value,
    state: &serde_json::Value,
    status: &str,
) -> String {
    let running = state
        .get("Running")
        .and_then(|v| v.as_bool())
        .unwrap_or(matches!(status, "running" | "paused"));
    if running {
        return "-".into();
    }
    value
        .get("exit_code")
        .or_else(|| state.get("ExitCode"))
        .and_then(|v| v.as_i64())
        .map(|code| code.to_string())
        .unwrap_or_else(|| "-".into())
}

fn a3s_box_mounts_summary(value: &serde_json::Value) -> String {
    let mut labels = Vec::new();
    collect_json_string_array(value.get("volumes"), &mut labels);
    collect_json_string_array(value.get("volume_names"), &mut labels);
    collect_json_string_array(value.get("tmpfs"), &mut labels);
    collect_json_string_array(value.get("anonymous_volumes"), &mut labels);
    if labels.is_empty() {
        return "-".into();
    }
    let total = labels.len();
    summarize_named_count(
        total,
        "mount",
        labels
            .into_iter()
            .take(3)
            .map(|label| truncate(&label, 32))
            .collect(),
    )
}

fn a3s_box_env_summary(value: Option<&serde_json::Value>) -> String {
    let Some(env) = value.and_then(|v| v.as_object()) else {
        return "-".into();
    };
    if env.is_empty() {
        "-".into()
    } else if env.len() == 1 {
        "1 var".into()
    } else {
        format!("{} vars", env.len())
    }
}

fn a3s_box_network_summary(value: &serde_json::Value) -> String {
    let mut labels = Vec::new();
    if let Some(name) = value.get("network_name").and_then(|v| v.as_str()) {
        if !name.is_empty() {
            labels.push(name.to_string());
        }
    }
    if labels.is_empty() {
        if let Some(mode) = value.get("network_mode") {
            if !mode.is_null() {
                labels.push(compact_json_value(mode));
            }
        }
    }
    collect_json_string_array(value.get("add_host"), &mut labels);
    if labels.is_empty() {
        return "-".into();
    }
    let total = labels.len();
    summarize_named_count(
        total,
        "net",
        labels
            .into_iter()
            .take(3)
            .map(|label| truncate(&label, 36))
            .collect(),
    )
}

fn collect_json_string_array(value: Option<&serde_json::Value>, out: &mut Vec<String>) {
    let Some(items) = value.and_then(|v| v.as_array()) else {
        return;
    };
    out.extend(
        items
            .iter()
            .filter_map(|item| item.as_str())
            .filter(|item| !item.is_empty())
            .map(ToString::to_string),
    );
}

async fn collect_docker_containers(
    show_all: bool,
    target: Option<&str>,
) -> anyhow::Result<Vec<ContainerRow>> {
    let mut containers = Vec::new();
    for filter in container_target_filters(target) {
        containers.extend(parse_docker_container_list(
            &docker_ps_output(show_all, filter.as_deref()).await,
        ));
    }
    dedupe_container_rows(&mut containers);
    if containers.is_empty() {
        return Ok(containers);
    }

    let stats = Command::new("docker")
        .args([
            "stats",
            "--no-stream",
            "--format",
            "{{.ID}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}",
        ])
        .output()
        .await;
    if let Ok(stats) = stats {
        if stats.status.success() {
            let stats_text = String::from_utf8_lossy(&stats.stdout);
            type DockerStatsRow = (Option<f32>, Option<f32>, String, String, String, String);
            let mut by_id: HashMap<String, DockerStatsRow> = HashMap::new();
            for line in stats_text.lines() {
                let mut parts = line.split('\t');
                let Some(id) = parts.next() else { continue };
                let cpu = parts.next().and_then(parse_percent);
                let mem_pct = parts.next().and_then(parse_percent);
                let mem = parts.next().unwrap_or("-").to_string();
                let net = parts.next().unwrap_or("-").to_string();
                let block = parts.next().unwrap_or("-").to_string();
                let pids = parts.next().unwrap_or("-").to_string();
                by_id.insert(id.to_string(), (cpu, mem_pct, mem, net, block, pids));
            }
            for c in &mut containers {
                let short = short_id(&c.id);
                let stats = by_id.get(&c.id).or_else(|| by_id.get(short)).cloned();
                if let Some((cpu, mem_pct, mem, net, block, pids)) = stats {
                    c.cpu_pct = cpu;
                    c.mem_pct = mem_pct;
                    c.mem_usage = mem;
                    c.net_io = net;
                    c.block_io = block;
                    c.pids = pids;
                }
            }
        }
    }
    enrich_docker_inspect(&mut containers).await;
    Ok(containers)
}

async fn docker_ps_output(show_all: bool, filter: Option<&str>) -> String {
    let mut ps_args = vec![
        "ps".to_string(),
        "--no-trunc".to_string(),
        "--format".to_string(),
        "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}".to_string(),
    ];
    if show_all {
        ps_args.insert(1, "-a".to_string());
    }
    if let Some(filter) = filter {
        ps_args.push("--filter".to_string());
        ps_args.push(filter.to_string());
    }

    let ps = Command::new("docker").args(ps_args).output().await;
    let Ok(ps) = ps else {
        return String::new();
    };
    if !ps.status.success() {
        return String::new();
    }
    String::from_utf8_lossy(&ps.stdout).into_owned()
}

fn parse_docker_container_list(text: &str) -> Vec<ContainerRow> {
    text.lines()
        .filter_map(|line| {
            let mut parts = line.splitn(5, '\t');
            Some(ContainerRow {
                connector: ContainerConnector::Docker,
                id: parts.next()?.to_string(),
                name: parts.next()?.to_string(),
                image: parts.next()?.to_string(),
                status: parts.next().unwrap_or_default().to_string(),
                inspect: ContainerInspect::default(),
                ports: normalize_ports(parts.next().unwrap_or_default()),
                cpu_pct: None,
                cpu_count: None,
                cpu_usage_total_ns: None,
                mem_pct: None,
                mem_usage: "-".into(),
                net_io: "-".into(),
                block_io: "-".into(),
                pids: "-".into(),
            })
        })
        .collect()
}

async fn enrich_docker_inspect(containers: &mut [ContainerRow]) {
    if containers.is_empty() {
        return;
    }

    let mut command = Command::new("docker");
    command.args(["inspect", "--type", "container"]);
    for container in containers.iter() {
        command.arg(&container.id);
    }

    let output = tokio::time::timeout(Duration::from_millis(1200), command.output())
        .await
        .ok()
        .and_then(Result::ok);
    let Some(output) = output else {
        return;
    };
    if !output.status.success() {
        return;
    }

    let inspect_by_id = parse_docker_inspect(&String::from_utf8_lossy(&output.stdout));
    for container in containers {
        let short = short_id(&container.id);
        if let Some(inspect) = inspect_by_id
            .get(&container.id)
            .or_else(|| inspect_by_id.get(short))
        {
            container.inspect = inspect.clone();
        }
    }
}

fn parse_docker_inspect(text: &str) -> HashMap<String, ContainerInspect> {
    let mut by_id = HashMap::new();
    if let Ok(value) = serde_json::from_str::<serde_json::Value>(text) {
        match value {
            serde_json::Value::Array(items) => {
                for item in items {
                    if let Some((id, inspect)) = parse_docker_inspect_container(&item) {
                        by_id.insert(short_id(&id).to_string(), inspect.clone());
                        by_id.insert(id, inspect);
                    }
                }
                return by_id;
            }
            value => {
                if let Some((id, inspect)) = parse_docker_inspect_container(&value) {
                    by_id.insert(short_id(&id).to_string(), inspect.clone());
                    by_id.insert(id, inspect);
                    return by_id;
                }
            }
        }
    }

    for line in text.lines() {
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };
        if let Some((id, inspect)) = parse_docker_inspect_container(&value) {
            by_id.insert(short_id(&id).to_string(), inspect.clone());
            by_id.insert(id, inspect);
        }
    }
    by_id
}

fn parse_docker_inspect_container(value: &serde_json::Value) -> Option<(String, ContainerInspect)> {
    let id = value.get("Id").and_then(|v| v.as_str())?.to_string();
    let state = value.get("State").unwrap_or(&serde_json::Value::Null);
    let config = value.get("Config").unwrap_or(&serde_json::Value::Null);
    let host_config = value.get("HostConfig").unwrap_or(&serde_json::Value::Null);
    let network_settings = value
        .get("NetworkSettings")
        .unwrap_or(&serde_json::Value::Null);

    Some((
        id,
        ContainerInspect {
            health: docker_health_label(state),
            restarts: value
                .get("RestartCount")
                .and_then(json_u64)
                .map(|count| count.to_string())
                .unwrap_or_else(|| "-".into()),
            restart_policy: docker_restart_policy(host_config.get("RestartPolicy")),
            created: compact_timestamp(value.get("Created").and_then(|v| v.as_str())),
            started: compact_timestamp(state.get("StartedAt").and_then(|v| v.as_str())),
            exit: docker_exit_label(state),
            mounts: docker_mounts_summary(value.get("Mounts")),
            env: docker_env_summary(config.get("Env")),
            labels: docker_label_summary(config.get("Labels")),
            networks: docker_network_summary(network_settings.get("Networks")),
        },
    ))
}

fn docker_health_label(state: &serde_json::Value) -> String {
    if let Some(status) = state.pointer("/Health/Status").and_then(|v| v.as_str()) {
        return status.to_string();
    }
    if state
        .get("OOMKilled")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        return "oom-killed".into();
    }
    if state.get("Dead").and_then(|v| v.as_bool()).unwrap_or(false) {
        return "dead".into();
    }
    "-".into()
}

fn docker_restart_policy(value: Option<&serde_json::Value>) -> String {
    let Some(value) = value else {
        return "-".into();
    };
    let name = value.get("Name").and_then(|v| v.as_str()).unwrap_or("-");
    if name.is_empty() || name == "no" {
        return name.to_string();
    }
    let max_retry = value
        .get("MaximumRetryCount")
        .and_then(json_u64)
        .unwrap_or(0);
    if max_retry > 0 {
        format!("{name}:{max_retry}")
    } else {
        name.to_string()
    }
}

fn docker_exit_label(state: &serde_json::Value) -> String {
    let status = state.get("Status").and_then(|v| v.as_str()).unwrap_or("");
    if matches!(status, "running" | "restarting" | "paused") {
        return "-".into();
    }

    let code = state
        .get("ExitCode")
        .and_then(|v| v.as_i64())
        .map(|code| code.to_string())
        .unwrap_or_else(|| "-".into());
    let error = state
        .get("Error")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .trim();
    if !error.is_empty() {
        return truncate(&format!("{code} {error}"), 80);
    }
    if state
        .get("OOMKilled")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        return format!("{code} oom-killed");
    }
    code
}

fn docker_mounts_summary(value: Option<&serde_json::Value>) -> String {
    let Some(items) = value.and_then(|v| v.as_array()) else {
        return "-".into();
    };
    if items.is_empty() {
        return "-".into();
    }
    let labels = items
        .iter()
        .take(3)
        .filter_map(|item| {
            let target = item
                .get("Destination")
                .or_else(|| item.get("Target"))
                .and_then(|v| v.as_str())
                .or_else(|| item.get("Name").and_then(|v| v.as_str()))
                .or_else(|| item.get("Source").and_then(|v| v.as_str()))?;
            let mode = if item.get("RW").and_then(|v| v.as_bool()).unwrap_or(true) {
                "rw"
            } else {
                "ro"
            };
            Some(format!("{}({mode})", truncate(target, 28)))
        })
        .collect::<Vec<_>>();
    summarize_named_count(items.len(), "mount", labels)
}

fn docker_env_summary(value: Option<&serde_json::Value>) -> String {
    let Some(items) = value.and_then(|v| v.as_array()) else {
        return "-".into();
    };
    if items.is_empty() {
        "-".into()
    } else if items.len() == 1 {
        "1 var".into()
    } else {
        format!("{} vars", items.len())
    }
}

fn docker_label_summary(value: Option<&serde_json::Value>) -> String {
    let Some(labels) = value.and_then(|v| v.as_object()) else {
        return "-".into();
    };
    if labels.is_empty() {
        return "-".into();
    }
    let mut keys = labels.keys().cloned().collect::<Vec<_>>();
    keys.sort();
    summarize_named_count(
        keys.len(),
        "label",
        keys.into_iter()
            .take(3)
            .map(|key| truncate(&key, 32))
            .collect(),
    )
}

fn docker_network_summary(value: Option<&serde_json::Value>) -> String {
    let Some(networks) = value.and_then(|v| v.as_object()) else {
        return "-".into();
    };
    if networks.is_empty() {
        return "-".into();
    }
    let mut labels = networks
        .iter()
        .map(|(name, detail)| {
            let ip = detail
                .get("IPAddress")
                .and_then(|v| v.as_str())
                .filter(|ip| !ip.is_empty());
            match ip {
                Some(ip) => format!("{name} {ip}"),
                None => name.clone(),
            }
        })
        .collect::<Vec<_>>();
    labels.sort();
    summarize_named_count(
        labels.len(),
        "net",
        labels
            .into_iter()
            .take(3)
            .map(|label| truncate(&label, 36))
            .collect(),
    )
}

fn summarize_named_count(total: usize, singular: &str, labels: Vec<String>) -> String {
    let plural = if total == 1 {
        singular.to_string()
    } else {
        format!("{singular}s")
    };
    if labels.is_empty() {
        return format!("{total} {plural}");
    }
    let suffix = if total > labels.len() {
        format!(", +{}", total - labels.len())
    } else {
        String::new()
    };
    format!("{total} {plural}: {}{suffix}", labels.join(", "))
}

fn compact_timestamp(value: Option<&str>) -> String {
    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
        return "-".into();
    };
    if value.starts_with("0001-") {
        return "-".into();
    }
    let value = value.trim_end_matches('Z');
    let value = value.split('.').next().unwrap_or(value).replace('T', " ");
    truncate(&value, 32)
}

async fn collect_runc_containers(
    show_all: bool,
    target: Option<&str>,
) -> anyhow::Result<Vec<ContainerRow>> {
    let output = runc_command()
        .args(["list", "--format", "json"])
        .output()
        .await;
    let Ok(output) = output else {
        return Ok(Vec::new());
    };
    if !output.status.success() {
        return Ok(Vec::new());
    }
    let text = String::from_utf8_lossy(&output.stdout);
    let mut containers = parse_runc_list(&text)?;
    filter_runc_container_rows(&mut containers, show_all, target);
    let ids = containers
        .iter()
        .filter(|container| container_is_running(&container.status))
        .map(|container| container.id.clone())
        .collect::<Vec<_>>();
    let stats = futures::future::join_all(ids.iter().map(|id| collect_runc_stats(id))).await;
    let stats_by_id = ids
        .into_iter()
        .zip(stats)
        .filter_map(|(id, stats)| stats.map(|stats| (id, stats)))
        .collect::<HashMap<_, _>>();
    for container in &mut containers {
        if let Some(stats) = stats_by_id.get(&container.id) {
            apply_runc_stats(container, stats);
        }
    }
    Ok(containers)
}

fn filter_runc_container_rows(rows: &mut Vec<ContainerRow>, show_all: bool, target: Option<&str>) {
    let target = target.map(str::trim).filter(|target| !target.is_empty());
    rows.retain(|row| {
        (show_all || container_is_running(&row.status))
            && target.is_none_or(|query| container_matches_query(row, query))
    });
}

fn parse_runc_list(text: &str) -> anyhow::Result<Vec<ContainerRow>> {
    let value: serde_json::Value = serde_json::from_str(text)?;
    let Some(items) = value.as_array() else {
        return Ok(Vec::new());
    };
    Ok(items.iter().filter_map(parse_runc_container).collect())
}

fn parse_runc_container(value: &serde_json::Value) -> Option<ContainerRow> {
    let id = value.get("id").and_then(|v| v.as_str())?.to_string();
    let status = value
        .get("status")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();
    let bundle = value.get("bundle").and_then(|v| v.as_str()).unwrap_or("-");
    let rootfs = value.get("rootfs").and_then(|v| v.as_str()).unwrap_or("-");
    let pid = value
        .get("pid")
        .and_then(|v| v.as_i64())
        .filter(|pid| *pid > 0)
        .map(|pid| pid.to_string())
        .unwrap_or_else(|| "-".to_string());
    Some(ContainerRow {
        connector: ContainerConnector::RunC,
        id: id.clone(),
        name: id,
        image: if rootfs == "-" {
            format!("bundle:{bundle}")
        } else {
            format!("rootfs:{rootfs}")
        },
        status,
        inspect: ContainerInspect::default(),
        cpu_pct: None,
        cpu_count: None,
        cpu_usage_total_ns: None,
        mem_pct: None,
        mem_usage: "-".into(),
        net_io: "-".into(),
        block_io: "-".into(),
        pids: pid,
        ports: "-".into(),
    })
}

async fn collect_runc_stats(id: &str) -> Option<RuncStats> {
    let mut command = runc_command();
    command.args(["events", "--stats", id]);
    let output = tokio::time::timeout(Duration::from_millis(800), command.output())
        .await
        .ok()?
        .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_runc_stats_event(&String::from_utf8_lossy(&output.stdout))
}

fn apply_runc_stats(container: &mut ContainerRow, stats: &RuncStats) {
    container.cpu_usage_total_ns = stats.cpu_usage_total_ns;
    if let Some(usage) = stats.memory_usage {
        if let Some(limit) = stats.memory_limit.filter(|limit| *limit > 0) {
            container.mem_pct = Some((usage as f32 / limit as f32) * 100.0);
            container.mem_usage = format!("{} / {}", format_bytes(usage), format_bytes(limit));
        } else {
            container.mem_usage = format_bytes(usage);
        }
    }
    if stats.net_rx > 0 || stats.net_tx > 0 {
        container.net_io = format_byte_pair(stats.net_rx, stats.net_tx);
    }
    if stats.block_read > 0 || stats.block_write > 0 {
        container.block_io = format_byte_pair(stats.block_read, stats.block_write);
    }
    if let Some(pids) = stats.pids_current {
        container.pids = pids.to_string();
    }
}

fn parse_runc_stats_event(text: &str) -> Option<RuncStats> {
    let value = serde_json::from_str::<serde_json::Value>(text)
        .ok()
        .or_else(|| {
            text.lines()
                .find_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
        })?;
    let data = value.get("data").unwrap_or(&value);
    let mut stats = RuncStats {
        cpu_usage_total_ns: data.pointer("/cpu/usage/total").and_then(json_u64),
        memory_usage: data
            .pointer("/memory/usage/usage")
            .and_then(json_u64)
            .or_else(|| data.pointer("/memory/raw/usage").and_then(json_u64)),
        memory_limit: data
            .pointer("/memory/usage/limit")
            .and_then(json_u64)
            .or_else(|| data.pointer("/memory/raw/limit").and_then(json_u64)),
        pids_current: data.pointer("/pids/current").and_then(json_u64),
        ..RuncStats::default()
    };

    if let Some(interfaces) = data
        .get("network_interfaces")
        .or_else(|| data.get("networkInterfaces"))
        .and_then(|v| v.as_array())
    {
        for interface in interfaces {
            stats.net_rx += json_field_u64(interface, &["RxBytes", "rxBytes", "rx_bytes"]);
            stats.net_tx += json_field_u64(interface, &["TxBytes", "txBytes", "tx_bytes"]);
        }
    }

    if let Some(entries) = data
        .pointer("/blkio/ioServiceBytesRecursive")
        .or_else(|| data.pointer("/blkio/io_service_bytes_recursive"))
        .and_then(|v| v.as_array())
    {
        for entry in entries {
            let op = entry
                .get("op")
                .or_else(|| entry.get("Op"))
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_ascii_lowercase();
            let value = json_field_u64(entry, &["value", "Value"]);
            if op == "read" {
                stats.block_read += value;
            } else if op == "write" {
                stats.block_write += value;
            }
        }
    }

    Some(stats)
}

fn container_lifecycle_events(
    connector: ContainerConnector,
    previous: &[ContainerRow],
    current: &[ContainerRow],
) -> Vec<EventRow> {
    let previous_by_id = previous
        .iter()
        .map(|row| (row.id.as_str(), row))
        .collect::<HashMap<_, _>>();
    let current_by_id = current
        .iter()
        .map(|row| (row.id.as_str(), row))
        .collect::<HashMap<_, _>>();
    let mut events = Vec::new();

    for row in current {
        let previous = previous_by_id.get(row.id.as_str()).copied();
        let current_state = container_state_label(&row.status);
        let previous_state = previous.map(|row| container_state_label(&row.status));
        if previous_state.as_deref() != Some(current_state.as_str()) {
            let action = container_transition_action(previous_state.as_deref(), &current_state);
            events.push(container_event_row(
                connector,
                action,
                previous_state.as_deref(),
                row,
            ));
            continue;
        }

        if let Some(previous) = previous {
            let previous_health = previous.inspect.health.as_str();
            let current_health = row.inspect.health.as_str();
            if current_health != "-" && previous_health != current_health && previous_health != "-"
            {
                events.push(container_event_row(
                    connector,
                    "health_status",
                    Some(previous_health),
                    row,
                ));
            }
        }
    }

    for row in previous {
        if !current_by_id.contains_key(row.id.as_str()) {
            let previous_state = container_state_label(&row.status);
            events.push(container_event_row(
                connector,
                "destroy",
                Some(&previous_state),
                row,
            ));
        }
    }

    events.sort_by(|a, b| a.message.cmp(&b.message));
    events
}

fn container_transition_action(previous: Option<&str>, current: &str) -> &'static str {
    match (previous, current) {
        (None, "created") => "create",
        (None, "running") => "start",
        (None, "paused") => "pause",
        (None, "exited" | "dead") => "die",
        (Some("created"), "running") => "start",
        (Some("running"), "paused") => "pause",
        (Some("paused"), "running") => "unpause",
        (Some("exited" | "dead"), "running") => "restart",
        (Some(old), "exited" | "dead") if old != current => "die",
        (Some(old), _) if old != current => "update",
        _ => "update",
    }
}

fn container_event_row(
    connector: ContainerConnector,
    action: &'static str,
    previous_status: Option<&str>,
    row: &ContainerRow,
) -> EventRow {
    let status = container_state_label(&row.status);
    let mut details = vec![
        ("connector".into(), connector.label().into()),
        ("action".into(), action.into()),
        ("id".into(), row.id.clone()),
        ("cid".into(), short_id(&row.id).to_string()),
        ("name".into(), row.name.clone()),
        ("image".into(), row.image.clone()),
        ("status".into(), status.clone()),
    ];
    if let Some(previous_status) = previous_status {
        details.push(("previous_status".into(), previous_status.to_string()));
    }
    if row.inspect.health != "-" {
        details.push(("health".into(), row.inspect.health.clone()));
    }
    if row.pids != "-" {
        details.push(("pids".into(), row.pids.clone()));
    }

    EventRow {
        ts: "now".into(),
        source: connector.label().into(),
        session: None,
        task: None,
        pid: None,
        ppid: None,
        kind: "Container".into(),
        message: format!(
            "{action} {} ({}) · status {status}",
            row.name,
            short_id(&row.id)
        ),
        details,
        risk: match action {
            "die" | "destroy" | "pause" | "restart" | "update" => Risk::Medium,
            _ => Risk::Low,
        },
    }
}

fn push_runtime_events(current: &mut Vec<EventRow>, mut rows: Vec<EventRow>) {
    if rows.is_empty() {
        return;
    }
    rows.append(current);
    rows.truncate(200);
    *current = rows;
}

fn prepend_runtime_events(events: &mut Vec<EventRow>, runtime_events: &[EventRow]) {
    if runtime_events.is_empty() {
        return;
    }
    let mut merged = runtime_events.to_vec();
    merged.append(events);
    merged.truncate(200);
    *events = merged;
}

async fn collect_observer_events(mut state: ObserverState) -> ObserverState {
    let sources = observer_paths();
    if sources.paths.is_empty() {
        return ObserverState::default();
    }

    if state.paths != sources.paths || state.auto_paths != sources.auto_paths {
        state.paths = sources.paths.clone();
        state.auto_paths = sources.auto_paths.clone();
        state.files.retain(|path, _| sources.paths.contains(path));
    }

    for path in sources.paths {
        collect_observer_path(&mut state, path).await;
    }
    trim_observer_events(&mut state.events);
    state
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ObserverPaths {
    paths: Vec<PathBuf>,
    auto_paths: HashSet<PathBuf>,
}

fn observer_paths() -> ObserverPaths {
    let explicit = observer_paths_from_env();
    let mut paths = Vec::new();
    let mut auto_paths = HashSet::new();

    for path in explicit {
        push_unique_path(&mut paths, path);
    }

    if observer_auto_enabled() {
        for path in observer_paths_from_auto() {
            let inserted = push_unique_path(&mut paths, path.clone());
            if inserted {
                auto_paths.insert(path);
            }
        }
    }

    ObserverPaths { paths, auto_paths }
}

fn observer_paths_from_env() -> Vec<PathBuf> {
    let Some(value) = std::env::var_os("A3S_TOP_OBSERVER_LOGS")
        .or_else(|| std::env::var_os("A3S_TOP_OBSERVER_LOG"))
    else {
        return Vec::new();
    };
    let text = value.to_string_lossy();
    let raw_paths = if text.contains(',') {
        text.split(',')
            .map(|part| PathBuf::from(part.trim()))
            .collect::<Vec<_>>()
    } else {
        std::env::split_paths(&value).collect::<Vec<_>>()
    };
    raw_paths
        .into_iter()
        .filter(|path| !path.as_os_str().is_empty())
        .collect()
}

fn observer_auto_enabled() -> bool {
    std::env::var("A3S_TOP_OBSERVER_AUTO")
        .map(|value| {
            let value = value.trim().to_ascii_lowercase();
            !matches!(value.as_str(), "0" | "false" | "off" | "no")
        })
        .unwrap_or(true)
}

fn observer_paths_from_auto() -> Vec<PathBuf> {
    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
        return Vec::new();
    };

    let mut paths = Vec::new();
    for path in [
        home.join(".a3s").join("observer").join("events.ndjson"),
        home.join(".a3s").join("observer").join("events.jsonl"),
        home.join(".a3s").join("top").join("events.ndjson"),
        home.join(".a3s").join("top").join("events.jsonl"),
        home.join(".a3s").join("events.ndjson"),
        home.join(".a3s").join("events.jsonl"),
        home.join(".codex").join("history.jsonl"),
    ] {
        if path.is_file() {
            push_unique_path(&mut paths, path);
        }
    }

    for path in recent_files_with_extension(
        &home.join(".claude").join("projects"),
        "jsonl",
        3,
        OBSERVER_AUTO_MAX_FILES_PER_AGENT,
    ) {
        push_unique_path(&mut paths, path);
    }

    for path in recent_files_with_extension(
        &home.join(".codex").join("sessions"),
        "jsonl",
        5,
        OBSERVER_AUTO_MAX_FILES_PER_AGENT,
    ) {
        push_unique_path(&mut paths, path);
    }

    for path in recent_files_with_extension(
        &home.join(".a3s").join("tui-sessions").join("runs"),
        "json",
        1,
        OBSERVER_AUTO_MAX_FILES_PER_AGENT,
    ) {
        push_unique_path(&mut paths, path);
    }

    for path in recent_files_with_extension(
        &home.join(".a3s").join("runtime-workspaces"),
        "json",
        4,
        OBSERVER_AUTO_MAX_FILES_PER_AGENT,
    )
    .into_iter()
    .filter(|path| path_contains_component(path, "runs"))
    {
        push_unique_path(&mut paths, path);
    }

    for path in recent_files_with_extension(
        &home.join(".a3s").join("workspace"),
        "json",
        6,
        OBSERVER_AUTO_MAX_FILES_PER_AGENT,
    )
    .into_iter()
    .filter(|path| path_contains_component(path, "runs"))
    {
        push_unique_path(&mut paths, path);
    }

    paths
}

fn recent_files_with_extension(
    root: &Path,
    extension: &str,
    max_depth: usize,
    limit: usize,
) -> Vec<PathBuf> {
    if !root.is_dir() {
        return Vec::new();
    }

    let mut found = Vec::new();
    let mut visited = 0usize;
    collect_recent_files(root, extension, max_depth, &mut visited, &mut found);
    found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
    found
        .into_iter()
        .take(limit)
        .map(|(_, path)| path)
        .collect()
}

fn collect_recent_files(
    dir: &Path,
    extension: &str,
    depth: usize,
    visited: &mut usize,
    found: &mut Vec<(u128, PathBuf)>,
) {
    if *visited >= OBSERVER_AUTO_MAX_SCAN_FILES {
        return;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };

    for entry in entries.flatten() {
        if *visited >= OBSERVER_AUTO_MAX_SCAN_FILES {
            return;
        }
        *visited += 1;
        let path = entry.path();
        let Ok(file_type) = entry.file_type() else {
            continue;
        };
        if file_type.is_dir() {
            if depth > 0 && !observer_scan_dir_is_ignored(&path) {
                collect_recent_files(&path, extension, depth - 1, visited, found);
            }
            continue;
        }
        if !file_type.is_file()
            || path
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| !ext.eq_ignore_ascii_case(extension))
                .unwrap_or(true)
        {
            continue;
        }
        let modified = entry
            .metadata()
            .ok()
            .and_then(|metadata| metadata.modified().ok())
            .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
            .map(|duration| duration.as_millis())
            .unwrap_or(0);
        found.push((modified, path));
    }
}

fn observer_scan_dir_is_ignored(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| {
            matches!(
                name,
                ".git" | "cache" | "memory" | "buildcache" | "artifacts"
            )
        })
        .unwrap_or(false)
}

fn path_contains_component(path: &Path, component: &str) -> bool {
    path.components().any(|part| {
        part.as_os_str()
            .to_str()
            .map(|value| value == component)
            .unwrap_or(false)
    })
}

fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) -> bool {
    if paths.iter().any(|existing| existing == &path) {
        false
    } else {
        paths.push(path);
        true
    }
}

fn observer_status_label(state: &ObserverState) -> String {
    match state.paths.as_slice() {
        [] => "obs:off".to_string(),
        [path] if state.auto_paths.contains(path) => format!(
            "obs:auto:{}",
            path.file_name().and_then(|s| s.to_str()).unwrap_or("log")
        ),
        [path] => format!(
            "obs:{}",
            path.file_name().and_then(|s| s.to_str()).unwrap_or("log")
        ),
        paths if paths.iter().all(|path| state.auto_paths.contains(path)) => {
            format!("obs:auto:{}", paths.len())
        }
        paths => format!("obs:{} files", paths.len()),
    }
}

async fn collect_observer_path(state: &mut ObserverState, path: PathBuf) {
    let mut file_state = state.files.remove(&path).unwrap_or_default();
    let is_auto = state.auto_paths.contains(&path);

    let metadata = match tokio::fs::metadata(&path).await {
        Ok(metadata) => metadata,
        Err(err) => {
            push_observer_event(state, observer_error_event(&path, "stat", err.to_string()));
            state.files.insert(path, file_state);
            return;
        }
    };
    if is_observer_json_document(&path) {
        collect_observer_json_document_path(state, path, file_state, &metadata).await;
        return;
    }
    if metadata.len() < file_state.offset {
        file_state.offset = 0;
        file_state.pending.clear();
    }
    if is_auto && file_state.offset == 0 && metadata.len() > OBSERVER_AUTO_INITIAL_TAIL_BYTES {
        file_state.offset = metadata
            .len()
            .saturating_sub(OBSERVER_AUTO_INITIAL_TAIL_BYTES);
        file_state.pending.clear();
    }

    let mut file = match tokio::fs::File::open(&path).await {
        Ok(file) => file,
        Err(err) => {
            push_observer_event(state, observer_error_event(&path, "read", err.to_string()));
            state.files.insert(path, file_state);
            return;
        }
    };
    if let Err(err) = file.seek(SeekFrom::Start(file_state.offset)).await {
        push_observer_event(state, observer_error_event(&path, "seek", err.to_string()));
        state.files.insert(path, file_state);
        return;
    }

    let mut chunk = String::new();
    match file.read_to_string(&mut chunk).await {
        Ok(_) => {
            file_state.offset = file_state.offset.saturating_add(chunk.len() as u64);
            let rows = append_observer_chunk(&mut file_state, &chunk);
            push_observer_rows(state, rows);
        }
        Err(err) => {
            push_observer_event(state, observer_error_event(&path, "read", err.to_string()))
        }
    }
    state.files.insert(path, file_state);
}

async fn collect_observer_json_document_path(
    state: &mut ObserverState,
    path: PathBuf,
    mut file_state: ObserverFileState,
    metadata: &std::fs::Metadata,
) {
    let text = match tokio::fs::read_to_string(&path).await {
        Ok(text) => text,
        Err(err) => {
            push_observer_event(state, observer_error_event(&path, "read", err.to_string()));
            state.files.insert(path, file_state);
            return;
        }
    };
    let mut rows = parse_observer_json_document(&text);
    if metadata.len() < file_state.offset || rows.len() < file_state.json_events_seen {
        file_state.json_events_seen = 0;
    }
    let seen = file_state.json_events_seen.min(rows.len());
    file_state.json_events_seen = rows.len();
    file_state.offset = metadata.len();
    if seen < rows.len() {
        rows.drain(..seen);
        rows.reverse();
        push_observer_rows(state, rows);
    }
    state.files.insert(path, file_state);
}

fn is_observer_json_document(path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.eq_ignore_ascii_case("json"))
        .unwrap_or(false)
}

fn observer_error_event(path: &std::path::Path, action: &str, error: String) -> EventRow {
    EventRow {
        ts: "now".into(),
        source: "observer".into(),
        session: None,
        task: None,
        pid: None,
        ppid: None,
        kind: "error".into(),
        message: format!(
            "failed to {action} observer log {}: {error}",
            path.display()
        ),
        details: vec![
            ("path".into(), path.display().to_string()),
            ("error".into(), error),
        ],
        risk: Risk::Medium,
    }
}

fn append_observer_chunk(file: &mut ObserverFileState, chunk: &str) -> Vec<EventRow> {
    if chunk.is_empty() && file.pending.is_empty() {
        return Vec::new();
    }
    let mut text = String::new();
    text.push_str(&file.pending);
    text.push_str(chunk);

    let complete = if text.ends_with('\n') {
        file.pending.clear();
        text.as_str()
    } else if let Some((head, tail)) = text.rsplit_once('\n') {
        file.pending = tail.to_string();
        head
    } else {
        file.pending = text;
        ""
    };

    let mut rows = complete
        .lines()
        .filter_map(parse_observer_line)
        .collect::<Vec<_>>();
    rows.reverse();
    rows
}

fn push_observer_rows(state: &mut ObserverState, mut rows: Vec<EventRow>) {
    if rows.is_empty() {
        return;
    }
    rows.append(&mut state.events);
    state.events = rows;
}

fn push_observer_event(state: &mut ObserverState, event: EventRow) {
    state.events.insert(0, event);
}

fn trim_observer_events(events: &mut Vec<EventRow>) {
    if events.len() <= OBSERVER_EVENT_LIMIT {
        events.sort_by_key(|event| std::cmp::Reverse(observer_event_sort_key(event)));
        return;
    }
    events.sort_by_key(|event| std::cmp::Reverse(observer_event_sort_key(event)));
    events.truncate(OBSERVER_EVENT_LIMIT);
}

fn observer_event_sort_key(event: &EventRow) -> u128 {
    observer_timestamp_sort_key(&event.ts).unwrap_or(0)
}

fn observer_timestamp_sort_key(ts: &str) -> Option<u128> {
    let ts = ts.trim();
    if ts.is_empty() || matches!(ts, "recent" | "now") {
        return Some(u128::MAX);
    }
    if ts.chars().all(|c| c.is_ascii_digit()) {
        let value = ts.parse::<u128>().ok()?;
        return Some(match ts.len() {
            0..=10 => value.saturating_mul(1000),
            11..=13 => value,
            14..=16 => value / 1000,
            _ => value / 1_000_000,
        });
    }
    parse_iso_timestamp_millis(ts)
}

fn parse_iso_timestamp_millis(ts: &str) -> Option<u128> {
    let mut digits = ts
        .chars()
        .filter(|c| c.is_ascii_digit())
        .map(|c| (c as u8 - b'0') as i32);
    let year = take_digits(&mut digits, 4)?;
    let month = take_digits(&mut digits, 2)?;
    let day = take_digits(&mut digits, 2)?;
    let hour = take_digits(&mut digits, 2).unwrap_or(0);
    let minute = take_digits(&mut digits, 2).unwrap_or(0);
    let second = take_digits(&mut digits, 2).unwrap_or(0);
    let millis = take_digits(&mut digits, 3).unwrap_or(0);
    if !(1..=12).contains(&month)
        || !(1..=31).contains(&day)
        || !(0..=23).contains(&hour)
        || !(0..=59).contains(&minute)
        || !(0..=60).contains(&second)
    {
        return None;
    }
    let days = days_from_civil(year, month, day)?;
    Some(
        (days as u128)
            .saturating_mul(86_400_000)
            .saturating_add((hour as u128).saturating_mul(3_600_000))
            .saturating_add((minute as u128).saturating_mul(60_000))
            .saturating_add((second as u128).saturating_mul(1000))
            .saturating_add(millis as u128),
    )
}

fn take_digits(digits: &mut impl Iterator<Item = i32>, count: usize) -> Option<i32> {
    let mut value = 0;
    for _ in 0..count {
        value = value * 10 + digits.next()?;
    }
    Some(value)
}

fn days_from_civil(year: i32, month: i32, day: i32) -> Option<i64> {
    let year = year - (month <= 2) as i32;
    let era = if year >= 0 { year } else { year - 399 } / 400;
    let yoe = year - era * 400;
    let month = month + if month > 2 { -3 } else { 9 };
    let doy = (153 * month + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era as i64 * 146_097 + doe as i64 - 719_468;
    (days >= 0).then_some(days)
}

async fn collect_container_logs(
    connector: ContainerConnector,
    id: &str,
    timestamps: bool,
) -> Result<String, String> {
    let mut command = match connector {
        ContainerConnector::A3sBox => {
            let a3s_box = ensure_a3s_box_binary()
                .await
                .map_err(|err| err.to_string())?;
            let mut command = Command::new(a3s_box);
            command.args(["logs", "--tail", "200"]);
            command
        }
        ContainerConnector::Docker => {
            let mut command = Command::new("docker");
            command.args(["logs", "--tail", "200"]);
            command
        }
        ContainerConnector::RunC => {
            return Err("runc logs are not supported".to_string());
        }
    };
    if timestamps {
        command.arg("--timestamps");
    }
    command.arg(id);

    let output = command.output().await.map_err(|err| err.to_string())?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    if output.status.success() {
        let mut text = String::new();
        text.push_str(stdout.trim_end());
        if !stderr.trim().is_empty() {
            if !text.is_empty() {
                text.push('\n');
            }
            text.push_str(stderr.trim_end());
        }
        Ok(text)
    } else {
        let reason = if stderr.trim().is_empty() {
            output.status.to_string()
        } else {
            stderr.trim().to_string()
        };
        Err(reason)
    }
}

async fn collect_container_processes(
    connector: ContainerConnector,
    id: &str,
) -> Result<Vec<ContainerProcessRow>, String> {
    match connector {
        ContainerConnector::A3sBox => collect_a3s_box_container_processes(id).await,
        ContainerConnector::Docker => collect_docker_container_processes(id).await,
        ContainerConnector::RunC => collect_runc_container_processes(id).await,
    }
}

async fn collect_a3s_box_container_processes(id: &str) -> Result<Vec<ContainerProcessRow>, String> {
    let a3s_box = ensure_a3s_box_binary()
        .await
        .map_err(|err| err.to_string())?;
    collect_a3s_box_container_processes_with_binary(&a3s_box, id).await
}

async fn collect_a3s_box_container_processes_with_binary(
    a3s_box: &Path,
    id: &str,
) -> Result<Vec<ContainerProcessRow>, String> {
    let json = Command::new(a3s_box)
        .args(["top", id, "--format", "json"])
        .output()
        .await
        .map_err(|err| err.to_string())?;
    if json.status.success() {
        let stdout = String::from_utf8_lossy(&json.stdout);
        if let Some(rows) = parse_a3s_box_top_json(&stdout) {
            return Ok(filter_a3s_box_probe_processes(rows));
        }
    }

    let output = Command::new(a3s_box)
        .args(["top", id, "--", "-eo", "pid,ppid,pcpu,pmem,etime,args"])
        .output()
        .await
        .map_err(|err| err.to_string())?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    if output.status.success() {
        Ok(filter_a3s_box_probe_processes(
            parse_container_process_table(&stdout),
        ))
    } else if stderr.trim().is_empty() {
        Err(output.status.to_string())
    } else {
        Err(stderr.trim().to_string())
    }
}

fn parse_a3s_box_top_json(text: &str) -> Option<Vec<ContainerProcessRow>> {
    let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
    let items = value.as_array()?;
    Some(
        items
            .iter()
            .filter_map(parse_a3s_box_top_json_row)
            .collect(),
    )
}

fn parse_a3s_box_top_json_row(item: &serde_json::Value) -> Option<ContainerProcessRow> {
    Some(ContainerProcessRow {
        pid: json_value_string(item, &["pid", "PID"])?,
        ppid: json_value_string(item, &["ppid", "parent_pid", "PPID"])
            .unwrap_or_else(|| "-".to_string()),
        cpu_pct: json_f64_field(item, &["cpu_percent", "cpu_pct", "pcpu", "%cpu", "CPU"])
            .map(|value| value as f32),
        mem_pct: json_f64_field(
            item,
            &[
                "memory_percent",
                "mem_percent",
                "mem_pct",
                "pmem",
                "%mem",
                "MEM",
            ],
        )
        .map(|value| value as f32),
        elapsed: json_value_string(item, &["elapsed", "etime", "time", "ELAPSED"])
            .unwrap_or_else(|| "-".to_string()),
        command: json_value_string(item, &["command", "cmd", "args", "COMMAND"])
            .unwrap_or_else(|| "-".to_string()),
    })
}

fn json_value_string(value: &serde_json::Value, fields: &[&str]) -> Option<String> {
    fields.iter().find_map(|field| {
        let value = value.get(*field)?;
        if let Some(text) = value.as_str() {
            return Some(text.trim().to_string()).filter(|text| !text.is_empty());
        }
        if let Some(number) = value.as_u64() {
            return Some(number.to_string());
        }
        if let Some(number) = value.as_i64() {
            return Some(number.to_string());
        }
        if let Some(number) = value.as_f64() {
            return Some(number.to_string());
        }
        None
    })
}

fn filter_a3s_box_probe_processes(mut rows: Vec<ContainerProcessRow>) -> Vec<ContainerProcessRow> {
    if let Some(idx) = rows
        .iter()
        .position(|row| is_a3s_box_process_probe(&row.command))
    {
        rows.remove(idx);
    }
    rows
}

fn a3s_box_process_count_from_rows(rows: &[ContainerProcessRow]) -> usize {
    rows.iter()
        .filter(|row| !is_a3s_box_process_probe(&row.command))
        .count()
}

fn is_a3s_box_process_probe(command: &str) -> bool {
    let command = command.trim();
    command == "ps"
        || command.starts_with("ps -eo pid,ppid,pcpu,pmem,etime,args")
        || command.starts_with("/bin/ps -eo pid,ppid,pcpu,pmem,etime,args")
        || command.starts_with("/usr/bin/ps -eo pid,ppid,pcpu,pmem,etime,args")
}

async fn collect_docker_container_processes(id: &str) -> Result<Vec<ContainerProcessRow>, String> {
    let output = Command::new("docker")
        .args(["top", id, "-eo", "pid,ppid,pcpu,pmem,etime,args"])
        .output()
        .await
        .map_err(|err| err.to_string())?;
    if output.status.success() {
        let rows = parse_container_process_table(&String::from_utf8_lossy(&output.stdout));
        if !rows.is_empty() {
            return Ok(rows);
        }
    }

    let fallback = Command::new("docker")
        .args(["top", id])
        .output()
        .await
        .map_err(|err| err.to_string())?;
    let stdout = String::from_utf8_lossy(&fallback.stdout);
    let stderr = String::from_utf8_lossy(&fallback.stderr);
    if fallback.status.success() {
        Ok(parse_container_process_table(&stdout))
    } else if stderr.trim().is_empty() {
        Err(fallback.status.to_string())
    } else {
        Err(stderr.trim().to_string())
    }
}

async fn collect_runc_container_processes(id: &str) -> Result<Vec<ContainerProcessRow>, String> {
    let mut command = runc_command();
    let output = command
        .args(["ps", id])
        .output()
        .await
        .map_err(|err| err.to_string())?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    if output.status.success() {
        Ok(parse_runc_processes(&stdout))
    } else if stderr.trim().is_empty() {
        Err(output.status.to_string())
    } else {
        Err(stderr.trim().to_string())
    }
}

fn parse_container_process_table(text: &str) -> Vec<ContainerProcessRow> {
    let mut lines = text.lines().filter(|line| !line.trim().is_empty());
    let Some(header) = lines.next() else {
        return Vec::new();
    };
    let headers = header
        .split_whitespace()
        .map(|part| part.trim().to_ascii_uppercase())
        .collect::<Vec<_>>();
    let pid_idx = headers.iter().position(|part| part == "PID");
    let ppid_idx = headers.iter().position(|part| part == "PPID");
    let cpu_idx = headers
        .iter()
        .position(|part| matches!(part.as_str(), "%CPU" | "PCPU" | "CPU%"));
    let mem_idx = headers
        .iter()
        .position(|part| matches!(part.as_str(), "%MEM" | "PMEM" | "MEM%"));
    let elapsed_idx = headers
        .iter()
        .position(|part| matches!(part.as_str(), "ELAPSED" | "ETIME" | "TIME"));
    let command_idx = headers
        .iter()
        .position(|part| matches!(part.as_str(), "COMMAND" | "CMD" | "ARGS"));

    lines
        .filter_map(|line| {
            parse_container_process_line(
                line,
                pid_idx?,
                ppid_idx,
                cpu_idx,
                mem_idx,
                elapsed_idx,
                command_idx,
            )
        })
        .collect()
}

fn parse_container_process_line(
    line: &str,
    pid_idx: usize,
    ppid_idx: Option<usize>,
    cpu_idx: Option<usize>,
    mem_idx: Option<usize>,
    elapsed_idx: Option<usize>,
    command_idx: Option<usize>,
) -> Option<ContainerProcessRow> {
    let parts = line.split_whitespace().collect::<Vec<_>>();
    let pid = parts.get(pid_idx)?.to_string();
    let ppid = ppid_idx
        .and_then(|idx| parts.get(idx))
        .map(|part| (*part).to_string())
        .unwrap_or_else(|| "-".to_string());
    let command = command_idx
        .and_then(|idx| (idx < parts.len()).then(|| parts[idx..].join(" ")))
        .unwrap_or_else(|| "-".to_string());
    Some(ContainerProcessRow {
        pid,
        ppid,
        cpu_pct: cpu_idx
            .and_then(|idx| parts.get(idx))
            .and_then(|value| parse_percent(value)),
        mem_pct: mem_idx
            .and_then(|idx| parts.get(idx))
            .and_then(|value| parse_percent(value)),
        elapsed: elapsed_idx
            .and_then(|idx| parts.get(idx))
            .map(|part| (*part).to_string())
            .unwrap_or_else(|| "-".to_string()),
        command,
    })
}

fn parse_runc_processes(text: &str) -> Vec<ContainerProcessRow> {
    text.lines()
        .filter_map(|line| {
            let pid = line.split_whitespace().next()?.trim();
            if pid.parse::<u32>().is_err() {
                return None;
            }
            Some(ContainerProcessRow {
                pid: pid.to_string(),
                ppid: "-".to_string(),
                cpu_pct: None,
                mem_pct: None,
                elapsed: "-".to_string(),
                command: "runc process".to_string(),
            })
        })
        .collect()
}

fn container_menu_items(container: &ContainerRow) -> Vec<ContainerMenuItem> {
    let running = container_is_running(&container.status);
    let paused = container_is_paused(&container.status);
    let mut items = vec![ContainerMenuItem {
        action: ContainerMenuAction::Focus,
        key: 'o',
        label: "Open single view".to_string(),
    }];

    if matches!(
        container.connector,
        ContainerConnector::A3sBox | ContainerConnector::Docker
    ) {
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Logs,
            key: 'l',
            label: "View logs".to_string(),
        });
    }
    if container_web_url(container).is_some() {
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::OpenBrowser,
            key: 'w',
            label: "Open browser".to_string(),
        });
    }

    if running && !paused {
        if matches!(
            container.connector,
            ContainerConnector::A3sBox | ContainerConnector::Docker
        ) {
            items.push(ContainerMenuItem {
                action: ContainerMenuAction::ExecShell,
                key: 'e',
                label: "Exec shell".to_string(),
            });
        }
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Pause,
            key: 'p',
            label: "Pause container".to_string(),
        });
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Stop,
            key: 's',
            label: "Stop container".to_string(),
        });
        if matches!(
            container.connector,
            ContainerConnector::A3sBox | ContainerConnector::Docker
        ) {
            items.push(ContainerMenuItem {
                action: ContainerMenuAction::Restart,
                key: 'r',
                label: "Restart container".to_string(),
            });
        }
    } else if paused {
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Unpause,
            key: 'u',
            label: "Unpause container".to_string(),
        });
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Stop,
            key: 's',
            label: "Stop container".to_string(),
        });
        if matches!(
            container.connector,
            ContainerConnector::A3sBox | ContainerConnector::Docker
        ) {
            items.push(ContainerMenuItem {
                action: ContainerMenuAction::Restart,
                key: 'r',
                label: "Restart container".to_string(),
            });
        }
    } else {
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Start,
            key: 's',
            label: "Start container".to_string(),
        });
        items.push(ContainerMenuItem {
            action: ContainerMenuAction::Remove,
            key: 'd',
            label: "Remove container".to_string(),
        });
    }

    items
}

fn container_action(container: ContainerRow, action: ContainerMenuAction) -> Action {
    let name = format!("{} ({})", container.name, short_id(&container.id));
    match action {
        ContainerMenuAction::Start => {
            Action::StartContainer(container.connector, container.id, name)
        }
        ContainerMenuAction::Stop => Action::StopContainer(container.connector, container.id, name),
        ContainerMenuAction::Restart => {
            Action::RestartContainer(container.connector, container.id, name)
        }
        ContainerMenuAction::Pause => {
            Action::PauseContainer(container.connector, container.id, name)
        }
        ContainerMenuAction::Unpause => {
            Action::UnpauseContainer(container.connector, container.id, name)
        }
        ContainerMenuAction::Remove => {
            Action::RemoveContainer(container.connector, container.id, name)
        }
        ContainerMenuAction::Focus
        | ContainerMenuAction::Logs
        | ContainerMenuAction::ExecShell
        | ContainerMenuAction::OpenBrowser => {
            unreachable!("non-confirmable menu actions are handled before confirmation")
        }
    }
}

fn container_menu_lines(menu: &ContainerMenu, width: u16, height: usize) -> Vec<String> {
    if width == 0 || height == 0 {
        return Vec::new();
    }

    let container = &menu.container;
    let items = menu
        .items
        .iter()
        .map(|item| MenuItem::new(item.key.to_string()).description(item.label.clone()))
        .collect::<Vec<_>>();
    MenuPanel::new(format!(
        "container menu {} ({})",
        container.name,
        short_id(&container.id)
    ))
    .subtitle(format!(
        "image {} · status {}",
        container.image, container.status
    ))
    .items(items)
    .selected(menu.select.selected_index())
    .max_items(height)
    .show_scroll(true)
    .indent(1)
    .marker(">")
    .title_color(CYAN)
    .subtitle_color(Color::BrightBlack)
    .text_color(Color::BrightWhite)
    .muted_color(Color::BrightBlack)
    .selected_colors(Color::BrightWhite, ACCENT)
    .view(width, height.saturating_add(2))
    .lines()
    .map(str::to_string)
    .collect()
}

fn column_panel_lines(panel: &ColumnPanel, width: u16, height: usize) -> Vec<String> {
    if width == 0 || height == 0 {
        return Vec::new();
    }

    let items = panel
        .choices
        .iter()
        .enumerate()
        .map(|(idx, choice)| MenuItem::new(choice.label).checked(panel.select.is_checked(idx)))
        .collect::<Vec<_>>();

    MenuPanel::new(format!("columns · {}", panel.tab.label()))
        .items(items)
        .selected(panel.select.cursor())
        .max_items(height)
        .show_scroll(true)
        .number_shortcuts(true)
        .indent(1)
        .marker(">")
        .title_color(ACCENT)
        .text_color(Color::BrightWhite)
        .muted_color(Color::BrightBlack)
        .checked_color(Color::Green)
        .selected_colors(Color::BrightWhite, ACCENT)
        .view(width, height.saturating_add(2))
        .lines()
        .map(str::to_string)
        .collect()
}

fn sort_panel_lines(panel: &SortPanel, width: u16, height: usize) -> Vec<String> {
    let items = panel
        .choices
        .iter()
        .enumerate()
        .map(|(idx, sort_by)| MenuItem::new(numbered_label(idx, sort_choice_label(*sort_by))))
        .collect::<Vec<_>>();
    option_menu_lines(
        "sort by",
        "choose the primary ordering for the current top view",
        items,
        panel.select.selected_index(),
        width,
        height,
    )
}

fn connector_panel_lines(panel: &ConnectorPanel, width: u16, height: usize) -> Vec<String> {
    let items = panel
        .choices
        .iter()
        .enumerate()
        .map(|(idx, connector)| {
            MenuItem::new(numbered_label(idx, connector_choice_label(*connector)))
        })
        .collect::<Vec<_>>();
    option_menu_lines(
        "container connector",
        "choose which runtime feeds the Containers tab",
        items,
        panel.select.selected_index(),
        width,
        height,
    )
}

fn option_menu_lines(
    title: &str,
    subtitle: &str,
    items: Vec<MenuItem>,
    selected: usize,
    width: u16,
    height: usize,
) -> Vec<String> {
    if width == 0 || height == 0 {
        return Vec::new();
    }

    MenuPanel::new(title)
        .subtitle(subtitle)
        .items(items)
        .selected(selected)
        .max_items(height)
        .show_scroll(true)
        .indent(1)
        .marker(">")
        .title_color(CYAN)
        .subtitle_color(Color::BrightBlack)
        .text_color(Color::BrightWhite)
        .muted_color(Color::BrightBlack)
        .selected_colors(Color::BrightWhite, ACCENT)
        .view(width, height.saturating_add(2))
        .lines()
        .map(str::to_string)
        .collect()
}

fn numbered_label(index: usize, label: &str) -> String {
    number_shortcut_label(index)
        .map(|shortcut| format!("{shortcut} {label}"))
        .unwrap_or_else(|| format!("  {label}"))
}

fn number_shortcut_label(index: usize) -> Option<char> {
    match index {
        0..=8 => Some((b'1' + index as u8) as char),
        9 => Some('0'),
        _ => None,
    }
}

fn container_is_running(status: &str) -> bool {
    let lower = status.to_lowercase();
    lower.starts_with("up") || lower.contains("running") || lower.contains("paused")
}

fn container_is_paused(status: &str) -> bool {
    status.to_lowercase().contains("paused")
}

fn parse_observer_line(line: &str) -> Option<EventRow> {
    let value: serde_json::Value = serde_json::from_str(line).ok()?;
    parse_a3s_observer_value(&value)
        .or_else(|| parse_claude_jsonl_value(&value))
        .or_else(|| parse_codex_jsonl_value(&value))
}

fn parse_observer_json_document(text: &str) -> Vec<EventRow> {
    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
        return Vec::new();
    };

    match value {
        serde_json::Value::Array(items) => items
            .iter()
            .flat_map(parse_observer_json_document_item)
            .collect(),
        item => parse_observer_json_document_item(&item),
    }
}

fn parse_observer_json_document_item(value: &serde_json::Value) -> Vec<EventRow> {
    if let Some(rows) = parse_a3s_run_record_value(value) {
        return rows;
    }
    if let Some(row) = parse_a3s_trace_value(value) {
        return vec![row];
    }
    parse_a3s_observer_value(value)
        .or_else(|| parse_claude_jsonl_value(value))
        .or_else(|| parse_codex_jsonl_value(value))
        .into_iter()
        .collect()
}

fn parse_a3s_observer_value(value: &serde_json::Value) -> Option<EventRow> {
    let identity = value.get("identity")?;
    let ts = observer_event_timestamp(value).unwrap_or_else(|| "recent".into());
    let source = identity
        .get("agent")
        .and_then(|v| v.as_str())
        .unwrap_or("agent")
        .to_string();
    let session = identity.get("session").and_then(compact_identity_value);
    let task = identity.get("task").and_then(compact_identity_value);
    let event = value.get("event")?.as_object()?;
    let (kind, payload) = event.iter().next()?;
    let mut details = event_payload_details(payload);
    if let Some(provider) = value.get("provider").and_then(compact_provider_value) {
        details.insert(0, ("provider".into(), provider));
    }
    let pid = event_detail_u32(&details, &["pid"]);
    let ppid = event_detail_u32(&details, &["ppid", "parent_pid", "parentPid"]);
    let message = event_payload_message(payload, &details);
    let risk = match kind.as_str() {
        "SecurityAction" | "FileDelete" => Risk::High,
        "Egress" | "FileAccess" | "ToolExec" => Risk::Medium,
        _ => Risk::Low,
    };
    Some(EventRow {
        ts,
        source,
        session,
        task,
        pid,
        ppid,
        kind: kind.clone(),
        message,
        details,
        risk,
    })
}

fn parse_claude_jsonl_value(value: &serde_json::Value) -> Option<EventRow> {
    let type_name = value.get("type").and_then(|v| v.as_str())?;
    if value.get("message").is_none()
        && !matches!(
            type_name,
            "mode" | "permission-mode" | "pr-link" | "last-prompt" | "ai-title" | "system"
        )
    {
        return None;
    }

    let mut details = Vec::new();
    push_json_detail(&mut details, "type", value.get("type"));
    push_json_detail(&mut details, "cwd", value.get("cwd"));
    push_json_detail(&mut details, "git_branch", value.get("gitBranch"));
    push_json_detail(&mut details, "version", value.get("version"));
    let session = value
        .get("sessionId")
        .and_then(compact_identity_value)
        .or_else(|| value.get("session_id").and_then(compact_identity_value));
    let task = value.get("uuid").and_then(compact_identity_value);
    let ts = observer_event_timestamp(value).unwrap_or_else(|| "recent".into());

    let (kind, message, risk) = match type_name {
        "assistant" => {
            let message_value = value.get("message")?;
            push_json_detail(&mut details, "model", message_value.get("model"));
            if let Some(usage) = message_value.get("usage") {
                push_usage_details(&mut details, usage);
            }
            if let Some(tool) = claude_tool_use(message_value) {
                push_json_detail(&mut details, "tool", tool.get("name"));
                if let Some(input) = tool.get("input") {
                    push_json_detail(&mut details, "command", input.get("command"));
                    push_json_detail(&mut details, "description", input.get("description"));
                }
                (
                    "ToolExec".to_string(),
                    claude_tool_message(tool),
                    Risk::Medium,
                )
            } else {
                (
                    "LlmCall".to_string(),
                    claude_message_text(message_value)
                        .unwrap_or_else(|| "assistant message".into()),
                    Risk::Low,
                )
            }
        }
        "user" => (
            "UserPrompt".to_string(),
            value
                .get("message")
                .and_then(claude_message_text)
                .unwrap_or_else(|| "user prompt".into()),
            Risk::Low,
        ),
        "permission-mode" => (
            "SecurityAction".to_string(),
            format!(
                "permission mode {}",
                value
                    .get("permissionMode")
                    .and_then(|v| v.as_str())
                    .unwrap_or("changed")
            ),
            Risk::Medium,
        ),
        "pr-link" => (
            "ToolExec".to_string(),
            format!(
                "opened PR {}",
                value
                    .get("prUrl")
                    .and_then(|v| v.as_str())
                    .unwrap_or("link")
            ),
            Risk::Low,
        ),
        "mode" | "last-prompt" | "ai-title" | "system" => (
            "AgentEvent".to_string(),
            claude_generic_message(value, type_name),
            Risk::Low,
        ),
        other => (
            "AgentEvent".to_string(),
            format!("claude {other}"),
            Risk::Low,
        ),
    };

    Some(EventRow {
        ts,
        source: "claude".into(),
        session,
        task,
        pid: None,
        ppid: None,
        kind,
        message,
        details,
        risk,
    })
}

fn parse_codex_jsonl_value(value: &serde_json::Value) -> Option<EventRow> {
    let type_name = value.get("type").and_then(|v| v.as_str())?;
    let looks_like_codex = value.get("payload").is_some()
        || value.get("session_id").is_some()
        || matches!(type_name, "event_msg" | "response_item");
    if !looks_like_codex {
        return None;
    }

    let payload = value.get("payload").unwrap_or(value);
    let payload_type = payload
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or(type_name);
    let mut details = vec![("type".into(), payload_type.to_string())];
    let session = value
        .get("session_id")
        .and_then(compact_identity_value)
        .or_else(|| payload.get("session_id").and_then(compact_identity_value));
    let task = payload
        .get("turn_id")
        .and_then(compact_identity_value)
        .or_else(|| {
            payload
                .get("internal_chat_message_metadata_passthrough")
                .and_then(|v| v.get("turn_id"))
                .and_then(compact_identity_value)
        });
    let ts = observer_event_timestamp(value)
        .or_else(|| observer_event_timestamp(payload))
        .unwrap_or_else(|| "recent".into());

    let (kind, message, risk) = match payload_type {
        "token_count" => {
            if let Some(info) = payload.get("info") {
                push_codex_token_usage(&mut details, info);
            }
            ("LlmCall".into(), "token usage update".into(), Risk::Low)
        }
        "message" => {
            push_json_detail(&mut details, "role", payload.get("role"));
            let phase = payload
                .get("phase")
                .and_then(|v| v.as_str())
                .unwrap_or("message");
            (
                "LlmCall".into(),
                codex_message_text(payload).unwrap_or_else(|| format!("codex {phase}")),
                Risk::Low,
            )
        }
        "task_complete" => {
            push_json_detail(&mut details, "duration_ms", payload.get("duration_ms"));
            push_json_detail(
                &mut details,
                "time_to_first_token_ms",
                payload.get("time_to_first_token_ms"),
            );
            (
                "AgentEvent".into(),
                payload
                    .get("last_agent_message")
                    .and_then(|v| v.as_str())
                    .map(|value| truncate(value, 120))
                    .unwrap_or_else(|| "task complete".into()),
                Risk::Low,
            )
        }
        "exec_command_begin" | "exec_command_end" | "tool_call" => {
            push_json_detail(&mut details, "cmd", payload.get("cmd"));
            push_json_detail(&mut details, "command", payload.get("command"));
            (
                "ToolExec".into(),
                format!("codex {payload_type}"),
                Risk::Medium,
            )
        }
        other => ("AgentEvent".into(), format!("codex {other}"), Risk::Low),
    };

    Some(EventRow {
        ts,
        source: "codex".into(),
        session,
        task,
        pid: None,
        ppid: None,
        kind,
        message,
        details,
        risk,
    })
}

fn parse_a3s_run_record_value(value: &serde_json::Value) -> Option<Vec<EventRow>> {
    let snapshot = value.get("snapshot")?;
    let events = value.get("events")?.as_array()?;
    let session = snapshot
        .get("session_id")
        .and_then(compact_identity_value)
        .or_else(|| snapshot.get("id").and_then(compact_identity_value));
    let task = snapshot.get("id").and_then(compact_identity_value);
    let workspace = snapshot
        .get("workspace")
        .or_else(|| snapshot.get("cwd"))
        .and_then(|v| v.as_str())
        .map(str::to_string);

    Some(
        events
            .iter()
            .filter_map(|entry| {
                parse_a3s_run_event(entry, session.clone(), task.clone(), &workspace)
            })
            .collect(),
    )
}

fn parse_a3s_run_event(
    value: &serde_json::Value,
    session: Option<String>,
    task: Option<String>,
    workspace: &Option<String>,
) -> Option<EventRow> {
    let event = value.get("event")?;
    let event_type = event.get("type").and_then(|v| v.as_str())?;
    let mut details = event_payload_details(event);
    if let Some(workspace) = workspace {
        details.push(("workspace".into(), workspace.clone()));
    }
    push_json_detail(&mut details, "sequence", value.get("sequence"));
    let ts = value
        .get("timestamp_ms")
        .and_then(compact_timestamp_field)
        .unwrap_or_else(|| "recent".into());
    let kind = a3s_event_kind(event_type);
    let risk = match kind {
        "ToolExec" => Risk::Medium,
        "SecurityAction" => Risk::High,
        _ => Risk::Low,
    };

    Some(EventRow {
        ts,
        source: "a3s-code".into(),
        session,
        task,
        pid: None,
        ppid: None,
        kind: kind.into(),
        message: a3s_event_message(event_type, event, &details),
        details,
        risk,
    })
}

fn parse_a3s_trace_value(value: &serde_json::Value) -> Option<EventRow> {
    if value.get("schema").and_then(|v| v.as_str()) != Some("a3s.trace_event.v1") {
        return None;
    }
    let kind_value = value
        .get("kind")
        .and_then(|v| v.as_str())
        .unwrap_or("trace");
    let mut details = event_payload_details(value);
    Some(EventRow {
        ts: "recent".into(),
        source: "a3s-code".into(),
        session: None,
        task: None,
        pid: None,
        ppid: None,
        kind: a3s_event_kind(kind_value).into(),
        message: a3s_event_message(kind_value, value, &details),
        details: {
            details.truncate(8);
            details
        },
        risk: if a3s_event_kind(kind_value) == "ToolExec" {
            Risk::Medium
        } else {
            Risk::Low
        },
    })
}

fn observer_event_timestamp(value: &serde_json::Value) -> Option<String> {
    [
        "ts",
        "time",
        "timestamp",
        "created_at",
        "createdAt",
        "observed_at",
        "observedAt",
    ]
    .iter()
    .find_map(|key| value.get(*key).and_then(compact_timestamp_field))
}

fn compact_timestamp_field(value: &serde_json::Value) -> Option<String> {
    let text = match value {
        serde_json::Value::Null => return None,
        serde_json::Value::String(s) => s.trim().to_string(),
        serde_json::Value::Number(n) => n.to_string(),
        _ => return None,
    };
    (!text.is_empty()).then(|| truncate(&text, 40))
}

fn event_payload_message(payload: &serde_json::Value, details: &[(String, String)]) -> String {
    if matches!(payload, serde_json::Value::Object(_)) {
        details
            .iter()
            .take(4)
            .map(|(k, v)| format!("{k}={}", truncate(v, 80)))
            .collect::<Vec<_>>()
            .join(" ")
    } else {
        compact_json_value(payload)
    }
}

fn event_payload_details(payload: &serde_json::Value) -> Vec<(String, String)> {
    match payload {
        serde_json::Value::Object(map) => map
            .iter()
            .take(8)
            .map(|(k, v)| (truncate(k, 32), compact_json_value(v)))
            .collect(),
        serde_json::Value::Null => Vec::new(),
        other => vec![("value".into(), compact_json_value(other))],
    }
}

fn compact_provider_value(value: &serde_json::Value) -> Option<String> {
    match value {
        serde_json::Value::Null => None,
        serde_json::Value::String(s) => {
            let s = s.trim();
            (!s.is_empty()).then(|| s.to_string())
        }
        serde_json::Value::Object(map) => map
            .get("Other")
            .and_then(|v| v.as_str())
            .map(ToString::to_string)
            .or_else(|| Some(compact_json_value(value))),
        other => Some(compact_json_value(other)),
    }
}

fn compact_identity_value(value: &serde_json::Value) -> Option<String> {
    let text = match value {
        serde_json::Value::Null => return None,
        serde_json::Value::String(s) => s.trim().to_string(),
        other => other.to_string(),
    };
    (!text.is_empty()).then(|| truncate(&text, 18))
}

fn push_json_detail(
    details: &mut Vec<(String, String)>,
    key: &str,
    value: Option<&serde_json::Value>,
) {
    let Some(value) = value else {
        return;
    };
    if value.is_null() {
        return;
    }
    let compact = compact_json_value(value);
    if compact.trim().is_empty() || compact == "null" {
        return;
    }
    details.push((key.into(), compact));
}

fn push_usage_details(details: &mut Vec<(String, String)>, usage: &serde_json::Value) {
    for (detail_key, json_keys) in [
        (
            "input_tokens",
            &["input_tokens", "prompt_tokens", "cache_read_input_tokens"][..],
        ),
        (
            "output_tokens",
            &[
                "output_tokens",
                "completion_tokens",
                "reasoning_output_tokens",
            ][..],
        ),
        ("total_tokens", &["total_tokens"][..]),
        ("latency_ms", &["latency_ms", "duration_ms"][..]),
    ] {
        if let Some(value) = json_keys.iter().find_map(|key| usage.get(*key)) {
            push_json_detail(details, detail_key, Some(value));
        }
    }
}

fn push_codex_token_usage(details: &mut Vec<(String, String)>, info: &serde_json::Value) {
    if let Some(last) = info.get("last_token_usage") {
        for (detail_key, json_key) in [
            ("prompt_tokens", "input_tokens"),
            ("completion_tokens", "output_tokens"),
            ("total_tokens", "total_tokens"),
        ] {
            if let Some(value) = last.get(json_key) {
                push_json_detail(details, detail_key, Some(value));
            }
        }
    }
    if let Some(total) = info.get("total_token_usage") {
        for (detail_key, json_key) in [
            ("lifetime_input_tokens", "input_tokens"),
            ("lifetime_output_tokens", "output_tokens"),
            ("lifetime_total_tokens", "total_tokens"),
        ] {
            if let Some(value) = total.get(json_key) {
                push_json_detail(details, detail_key, Some(value));
            }
        }
    }
    push_json_detail(details, "context_window", info.get("model_context_window"));
}

fn claude_tool_use(message: &serde_json::Value) -> Option<&serde_json::Value> {
    message
        .get("content")
        .and_then(|content| content.as_array())
        .and_then(|items| {
            items.iter().find(|item| {
                item.get("type")
                    .and_then(|value| value.as_str())
                    .map(|value| value == "tool_use")
                    .unwrap_or(false)
            })
        })
}

fn claude_tool_message(tool: &serde_json::Value) -> String {
    let name = tool
        .get("name")
        .and_then(|value| value.as_str())
        .unwrap_or("tool");
    let input = tool.get("input");
    let detail = input
        .and_then(|value| value.get("description"))
        .or_else(|| input.and_then(|value| value.get("command")))
        .and_then(|value| value.as_str())
        .map(|value| truncate(value, 120))
        .unwrap_or_else(|| "invoked".to_string());
    format!("{name}: {detail}")
}

fn claude_message_text(message: &serde_json::Value) -> Option<String> {
    message_content_text(message.get("content")?)
}

fn codex_message_text(message: &serde_json::Value) -> Option<String> {
    message_content_text(message.get("content")?)
}

fn message_content_text(content: &serde_json::Value) -> Option<String> {
    match content {
        serde_json::Value::String(text) => non_empty_truncated(text, 120),
        serde_json::Value::Array(items) => {
            let text = items
                .iter()
                .filter_map(|item| {
                    item.get("text")
                        .or_else(|| item.get("content"))
                        .and_then(|value| value.as_str())
                })
                .collect::<Vec<_>>()
                .join(" ");
            non_empty_truncated(&text, 120)
        }
        other => non_empty_truncated(&compact_json_value(other), 120),
    }
}

fn non_empty_truncated(value: &str, max: usize) -> Option<String> {
    let value = value.trim();
    (!value.is_empty()).then(|| truncate(value, max))
}

fn claude_generic_message(value: &serde_json::Value, type_name: &str) -> String {
    for key in ["mode", "lastPrompt", "aiTitle", "subtype", "message"] {
        if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
            return format!("{type_name}: {}", truncate(text, 120));
        }
    }
    format!("claude {type_name}")
}

fn a3s_event_kind(event_type: &str) -> &'static str {
    let lower = event_type.to_ascii_lowercase();
    if lower.contains("tool")
        || lower.contains("bash")
        || lower.contains("command")
        || lower.contains("program_execution")
        || lower.contains("tool_execution")
    {
        "ToolExec"
    } else if lower.contains("llm")
        || lower.contains("token")
        || lower.contains("turn_end")
        || lower.contains("generate")
    {
        "LlmCall"
    } else if lower.contains("permission")
        || lower.contains("security")
        || lower.contains("deny")
        || lower.contains("error")
    {
        "SecurityAction"
    } else if lower.contains("file") {
        "FileAccess"
    } else {
        "AgentEvent"
    }
}

fn a3s_event_message(
    event_type: &str,
    value: &serde_json::Value,
    details: &[(String, String)],
) -> String {
    value
        .get("name")
        .or_else(|| value.get("tool"))
        .or_else(|| value.get("message"))
        .or_else(|| value.get("prompt"))
        .and_then(|value| value.as_str())
        .map(|value| format!("{event_type}: {}", truncate(value, 120)))
        .unwrap_or_else(|| {
            if details.is_empty() {
                event_type.to_string()
            } else {
                format!(
                    "{event_type}: {}",
                    details
                        .iter()
                        .take(3)
                        .map(|(key, value)| format!("{key}={}", truncate(value, 60)))
                        .collect::<Vec<_>>()
                        .join(" ")
                )
            }
        })
}

fn event_scope_label(event: &EventRow) -> String {
    match (&event.session, &event.task) {
        (Some(session), Some(task)) => format!("session {session} task {task}"),
        (Some(session), None) => format!("session {session}"),
        (None, Some(task)) => format!("task {task}"),
        (None, None) => "no session".to_string(),
    }
}

fn event_workspace(event: &EventRow) -> Option<String> {
    event
        .details
        .iter()
        .find(|(key, value)| {
            let key = key.to_ascii_lowercase();
            matches!(
                key.as_str(),
                "cwd" | "workdir" | "working_dir" | "workingdirectory" | "workspace"
            ) && !value.trim().is_empty()
        })
        .map(|(_, value)| value.clone())
}

fn workspace_paths_overlap(a: &str, b: &str) -> bool {
    let Some(a) = normalize_workspace_path(a) else {
        return false;
    };
    let Some(b) = normalize_workspace_path(b) else {
        return false;
    };
    if a == b {
        return true;
    }

    let a_path = Path::new(&a);
    let b_path = Path::new(&b);
    a_path.is_absolute()
        && b_path.is_absolute()
        && (a_path.starts_with(b_path) || b_path.starts_with(a_path))
}

fn normalize_workspace_path(path: &str) -> Option<String> {
    let path = path.trim().trim_matches('"').trim();
    if path.is_empty() || path == "-" {
        None
    } else {
        Some(if path == "/" {
            path.to_string()
        } else {
            path.trim_end_matches('/').to_string()
        })
    }
}

fn is_llm_event_kind(kind: &str) -> bool {
    matches!(kind, "LlmCall" | "LlmApi" | "SslContent")
}

fn event_model(event: &EventRow) -> Option<String> {
    event_detail_value(event, &["model", "model_name", "modelName"])
}

fn event_provider(event: &EventRow) -> Option<String> {
    event_detail_value(event, &["provider", "provider_name", "providerName"])
}

fn event_llm_network(event: &EventRow) -> Option<LlmNetworkSummary> {
    if !is_llm_event_kind(&event.kind) {
        return None;
    }
    Some(LlmNetworkSummary {
        provider: event_provider(event),
        latency_ms: event_detail_millis(event, &["latency", "latency_ms", "latencyMs"]),
        ttft_ms: event_detail_millis(event, &["ttft", "ttft_ms", "ttftMs"]),
        req_bytes: event_detail_u64(event, &["req_bytes", "request_bytes", "reqBytes"])
            .unwrap_or(0),
        resp_bytes: event_detail_u64(event, &["resp_bytes", "response_bytes", "respBytes"])
            .unwrap_or(0),
    })
}

fn event_token_usage(event: &EventRow) -> Option<TokenUsageSummary> {
    let prompt = event_detail_u64(
        event,
        &[
            "prompt_tokens",
            "input_tokens",
            "promptTokens",
            "inputTokens",
        ],
    )
    .unwrap_or(0);
    let completion = event_detail_u64(
        event,
        &[
            "completion_tokens",
            "output_tokens",
            "completionTokens",
            "outputTokens",
        ],
    )
    .unwrap_or(0);
    let total = event_detail_u64(event, &["total_tokens", "totalTokens"])
        .unwrap_or_else(|| prompt.saturating_add(completion));
    (prompt > 0 || completion > 0 || total > 0).then_some(TokenUsageSummary {
        prompt,
        completion,
        total,
    })
}

fn event_detail_value(event: &EventRow, keys: &[&str]) -> Option<String> {
    event
        .details
        .iter()
        .find(|(key, value)| {
            keys.iter().any(|wanted| key.eq_ignore_ascii_case(wanted)) && !value.trim().is_empty()
        })
        .map(|(_, value)| value.trim().to_string())
}

fn event_detail_u64(event: &EventRow, keys: &[&str]) -> Option<u64> {
    event_detail_value(event, keys)
        .and_then(|value| value.trim_matches('"').replace('_', "").parse::<u64>().ok())
}

fn event_detail_u32(details: &[(String, String)], keys: &[&str]) -> Option<u32> {
    details
        .iter()
        .find(|(key, value)| {
            keys.iter().any(|wanted| key.eq_ignore_ascii_case(wanted)) && !value.trim().is_empty()
        })
        .and_then(|(_, value)| value.trim_matches('"').replace('_', "").parse::<u32>().ok())
}

fn event_detail_millis(event: &EventRow, keys: &[&str]) -> Option<u64> {
    event_detail_value(event, keys).and_then(|value| parse_millis_value(&value))
}

fn parse_millis_value(value: &str) -> Option<u64> {
    let value = value.trim().trim_matches('"');
    if value.is_empty() {
        return None;
    }
    if let Some(ms) = value.strip_suffix("ms") {
        return ms
            .trim()
            .parse::<f64>()
            .ok()
            .map(|ms| ms.max(0.0).round() as u64);
    }
    if let Some(sec) = value.strip_suffix('s') {
        return sec
            .trim()
            .parse::<f64>()
            .ok()
            .map(|sec| (sec.max(0.0) * 1000.0).round() as u64);
    }
    if let Ok(number) = value.parse::<f64>() {
        return Some(number.max(0.0).round() as u64);
    }
    let parsed = serde_json::from_str::<serde_json::Value>(value).ok()?;
    let secs = parsed
        .get("secs")
        .or_else(|| parsed.get("seconds"))
        .and_then(json_u64)
        .unwrap_or(0);
    let nanos = parsed
        .get("nanos")
        .or_else(|| parsed.get("nanoseconds"))
        .and_then(json_u64)
        .unwrap_or(0);
    Some(
        secs.saturating_mul(1000)
            .saturating_add((nanos as f64 / 1_000_000.0).round() as u64),
    )
}

async fn run_action(action: Action) -> String {
    match action {
        Action::KillProcess(pid, label) => {
            let status = Command::new("kill")
                .arg("-TERM")
                .arg(pid.to_string())
                .status()
                .await;
            match status {
                Ok(s) if s.success() => format!("sent SIGTERM to PID {pid} · {label}"),
                Ok(s) => format!("kill failed for PID {pid}: {s}"),
                Err(err) => format!("kill failed for PID {pid}: {err}"),
            }
        }
        Action::StartContainer(connector, id, name) => {
            run_container_runtime_action(connector, "start", &id, &name).await
        }
        Action::StopContainer(connector, id, name) => {
            run_container_runtime_action(connector, "stop", &id, &name).await
        }
        Action::RestartContainer(connector, id, name) => {
            run_container_runtime_action(connector, "restart", &id, &name).await
        }
        Action::PauseContainer(connector, id, name) => {
            run_container_runtime_action(connector, "pause", &id, &name).await
        }
        Action::UnpauseContainer(connector, id, name) => {
            run_container_runtime_action(connector, "unpause", &id, &name).await
        }
        Action::RemoveContainer(connector, id, name) => {
            run_container_runtime_action(connector, "rm", &id, &name).await
        }
    }
}

async fn run_container_runtime_action(
    connector: ContainerConnector,
    action: &str,
    id: &str,
    name: &str,
) -> String {
    match connector {
        ContainerConnector::A3sBox => run_a3s_box_container_action(action, id, name).await,
        ContainerConnector::Docker => run_docker_container_action(action, id, name).await,
        ContainerConnector::RunC => run_runc_container_action(action, id, name).await,
    }
}

async fn run_a3s_box_container_action(action: &str, id: &str, name: &str) -> String {
    let command = match action {
        "start" => "start",
        "stop" => "stop",
        "restart" => "restart",
        "pause" => "pause",
        "unpause" => "unpause",
        "rm" => "rm",
        _ => return format!("a3s-box {action} is not supported for {name}"),
    };
    let a3s_box = match ensure_a3s_box_binary().await {
        Ok(path) => path,
        Err(err) => return format!("a3s-box {command} failed for {name}: {err}"),
    };
    let status = Command::new(a3s_box).args([command, id]).status().await;
    match status {
        Ok(s) if s.success() => format!("a3s-box {command} succeeded for {name}"),
        Ok(s) => format!("a3s-box {command} failed for {name}: {s}"),
        Err(err) => format!("a3s-box {command} failed for {name}: {err}"),
    }
}

async fn run_docker_container_action(action: &str, id: &str, name: &str) -> String {
    let status = Command::new("docker").args([action, id]).status().await;
    match status {
        Ok(s) if s.success() => format!("docker {action} succeeded for {name}"),
        Ok(s) => format!("docker {action} failed for {name}: {s}"),
        Err(err) => format!("docker {action} failed for {name}: {err}"),
    }
}

async fn run_runc_container_action(action: &str, id: &str, name: &str) -> String {
    let mut command = runc_command();
    match action {
        "start" => {
            command.args(["start", id]);
        }
        "stop" => {
            command.args(["kill", id, "TERM"]);
        }
        "pause" => {
            command.args(["pause", id]);
        }
        "unpause" => {
            command.args(["resume", id]);
        }
        "rm" => {
            command.args(["delete", id]);
        }
        "restart" => {
            return format!("runc restart is not supported for {name}");
        }
        _ => return format!("runc {action} is not supported for {name}"),
    }

    let status = command.status().await;
    match status {
        Ok(s) if s.success() => format!("runc {action} succeeded for {name}"),
        Ok(s) => format!("runc {action} failed for {name}: {s}"),
        Err(err) => format!("runc {action} failed for {name}: {err}"),
    }
}

fn agent_matches_source(agent: AgentKind, source: &str) -> bool {
    let source = source.to_lowercase().replace(['_', ' '], "-");
    match agent {
        AgentKind::A3sCode => source == "a3s" || source.contains("a3s-code"),
        AgentKind::ClaudeCode => source.contains("claude"),
        AgentKind::Codex => source.contains("codex"),
        AgentKind::Cursor => source.contains("cursor"),
        AgentKind::Gemini => source.contains("gemini"),
    }
}

fn agent_order(agent: AgentKind) -> usize {
    AgentKind::ALL
        .iter()
        .position(|item| *item == agent)
        .unwrap_or(usize::MAX)
}

fn agent_tree_state_label(group: &AgentTreeGroup) -> &'static str {
    if group.activity.high_risk > 0 || group.risk() == Risk::High {
        "HIGH"
    } else if !group.processes.is_empty() {
        "RUN"
    } else if !group.sessions.is_empty() || !group.events.is_empty() {
        "RECENT"
    } else {
        "IDLE"
    }
}

fn agent_tree_label(agent: AgentKind, text: impl AsRef<str>) -> String {
    Style::new().fg(agent.color()).render(text.as_ref())
}

fn agent_kind_for_source(source: &str) -> Option<AgentKind> {
    AgentKind::ALL
        .iter()
        .copied()
        .find(|agent| agent_matches_source(*agent, source))
}

fn sort_choices_for_tab(tab: Tab) -> Vec<SortBy> {
    match tab {
        Tab::Agents => vec![
            SortBy::Cpu,
            SortBy::Mem,
            SortBy::Net,
            SortBy::Pids,
            SortBy::Name,
            SortBy::Tokens,
        ],
        Tab::Sessions => vec![
            SortBy::Cpu,
            SortBy::Mem,
            SortBy::Net,
            SortBy::Name,
            SortBy::Tokens,
        ],
        Tab::Containers => vec![
            SortBy::Cpu,
            SortBy::Mem,
            SortBy::Net,
            SortBy::Block,
            SortBy::Pids,
            SortBy::State,
            SortBy::Id,
            SortBy::Uptime,
            SortBy::Name,
        ],
        Tab::Processes => vec![
            SortBy::Cpu,
            SortBy::Mem,
            SortBy::Pids,
            SortBy::Id,
            SortBy::Name,
        ],
        Tab::Events => Vec::new(),
    }
}

fn sort_choice_label(sort_by: SortBy) -> &'static str {
    match sort_by {
        SortBy::Cpu => "cpu      CPU usage",
        SortBy::Mem => "mem      memory usage",
        SortBy::Net => "net      network I/O",
        SortBy::Block => "block    block I/O",
        SortBy::Pids => "pids     process count",
        SortBy::State => "state    container state",
        SortBy::Id => "id       container id / pid",
        SortBy::Uptime => "uptime   running duration",
        SortBy::Name => "name     name",
        SortBy::Tokens => "tokens   LLM token usage",
    }
}

fn container_sort_column_id(sort_by: SortBy) -> Option<&'static str> {
    match sort_by {
        SortBy::Cpu => Some("containers.cpu"),
        SortBy::Mem => Some("containers.mem"),
        SortBy::Net => Some("containers.net"),
        SortBy::Block => Some("containers.block"),
        SortBy::Pids => Some("containers.pids"),
        SortBy::State => Some("containers.status"),
        SortBy::Id => Some("containers.id"),
        SortBy::Uptime => Some("containers.uptime"),
        SortBy::Name => Some("containers.name"),
        SortBy::Tokens => None,
    }
}

fn connector_choice_label(connector: ContainerConnector) -> &'static str {
    match connector {
        ContainerConnector::A3sBox => "a3s-box A3S Box runtime",
        ContainerConnector::Docker => "docker   Docker Engine",
        ContainerConnector::RunC => "runc     runC containers",
    }
}

fn metric_color(value: f32) -> Color {
    if value >= 80.0 {
        RED
    } else if value >= 50.0 {
        YELLOW
    } else {
        GREEN
    }
}

fn scale_cpu_pct_to_system(value: f32) -> f32 {
    scale_cpu_pct_by(value, system_cpu_count())
}

fn scale_cpu_pct_for_cpus(value: f32, cpus: Option<u32>) -> f32 {
    cpus.and_then(|cpus| usize::try_from(cpus).ok())
        .map(|cpus| scale_cpu_pct_by(value, cpus))
        .unwrap_or_else(|| scale_cpu_pct_to_system(value))
}

fn scale_cpu_pct_by(value: f32, cpus: usize) -> f32 {
    value / cpus.max(1) as f32
}

fn system_cpu_count() -> usize {
    std::thread::available_parallelism()
        .map(usize::from)
        .unwrap_or(1)
}

fn session_rows(events: &[EventRow]) -> Vec<SessionRow> {
    let mut rows: HashMap<(String, String), SessionRow> = HashMap::new();
    for event in events {
        let Some((source, session)) = session_key_for_event(event) else {
            continue;
        };
        let task = event.task.clone().unwrap_or_else(|| "-".to_string());
        let workspace = event_workspace(event).unwrap_or_else(|| "-".to_string());
        let key = (source.clone(), session.clone());
        let row = rows.entry(key).or_insert_with(|| SessionRow {
            source,
            session,
            task: task.clone(),
            workspace: workspace.clone(),
            events: 0,
            tools: 0,
            security: 0,
            files: 0,
            egress: 0,
            llm: 0,
            prompt_tokens: 0,
            completion_tokens: 0,
            total_tokens: 0,
            model: "-".to_string(),
            provider: "-".to_string(),
            latency_ms: 0,
            latency_samples: 0,
            ttft_ms: 0,
            ttft_samples: 0,
            req_bytes: 0,
            resp_bytes: 0,
            high_risk: 0,
            risk: Risk::Low,
            last_kind: event.kind.clone(),
            last_message: event.message.clone(),
        });
        row.events += 1;
        if row.workspace == "-" && workspace != "-" {
            row.workspace = workspace;
        }
        if event.kind == "ToolExec" {
            row.tools += 1;
        }
        if event.kind == "SecurityAction" {
            row.security += 1;
        }
        if matches!(event.kind.as_str(), "FileAccess" | "FileDelete") {
            row.files += 1;
        }
        if event.kind == "Egress" {
            row.egress += 1;
        }
        if is_llm_event_kind(&event.kind) {
            row.llm += 1;
        }
        if let Some(tokens) = event_token_usage(event) {
            row.prompt_tokens += tokens.prompt;
            row.completion_tokens += tokens.completion;
            row.total_tokens += tokens.total;
        }
        if row.model == "-" {
            if let Some(model) = event_model(event) {
                row.model = model;
            }
        }
        if let Some(network) = event_llm_network(event) {
            if row.provider == "-" {
                if let Some(provider) = network.provider {
                    row.provider = provider;
                }
            }
            if let Some(latency_ms) = network.latency_ms {
                row.latency_ms += latency_ms;
                row.latency_samples += 1;
            }
            if let Some(ttft_ms) = network.ttft_ms {
                row.ttft_ms += ttft_ms;
                row.ttft_samples += 1;
            }
            row.req_bytes += network.req_bytes;
            row.resp_bytes += network.resp_bytes;
        }
        if event.risk == Risk::High {
            row.high_risk += 1;
        }
        if risk_rank(event.risk) > risk_rank(row.risk) {
            row.risk = event.risk;
        }
        if row.task == "-" && task != "-" {
            row.task = task;
        }
    }

    let mut rows = rows.into_values().collect::<Vec<_>>();
    rows.sort_by(|a, b| {
        b.high_risk
            .cmp(&a.high_risk)
            .then(b.events.cmp(&a.events))
            .then(a.source.cmp(&b.source))
            .then(a.session.cmp(&b.session))
    });
    rows
}

fn session_key_for_event(event: &EventRow) -> Option<(String, String)> {
    let agent = agent_kind_for_source(&event.source)?;
    let session = event
        .session
        .clone()
        .or_else(|| event.task.clone())
        .unwrap_or_else(|| "no-session".to_string());
    Some((agent.label().to_string(), session))
}

fn session_focus_matches_event(focus: &SessionFocus, event: &EventRow) -> bool {
    session_key_for_event(event)
        .is_some_and(|(source, session)| source == focus.source && session == focus.session)
}

fn sort_sessions(rows: &mut [SessionRow], sort_by: SortBy) {
    match sort_by {
        SortBy::Cpu => rows.sort_by(|a, b| {
            b.high_risk
                .cmp(&a.high_risk)
                .then(b.events.cmp(&a.events))
                .then(a.source.cmp(&b.source))
                .then(a.session.cmp(&b.session))
        }),
        SortBy::Mem => rows.sort_by(|a, b| {
            b.tools
                .cmp(&a.tools)
                .then(b.events.cmp(&a.events))
                .then(a.source.cmp(&b.source))
                .then(a.session.cmp(&b.session))
        }),
        SortBy::Net => rows.sort_by(|a, b| {
            b.egress
                .cmp(&a.egress)
                .then(b.events.cmp(&a.events))
                .then(a.source.cmp(&b.source))
                .then(a.session.cmp(&b.session))
        }),
        SortBy::Block | SortBy::Pids | SortBy::State | SortBy::Id | SortBy::Uptime => {
            rows.sort_by(|a, b| {
                b.events
                    .cmp(&a.events)
                    .then(a.source.cmp(&b.source))
                    .then(a.session.cmp(&b.session))
            })
        }
        SortBy::Name => rows.sort_by(|a, b| {
            a.source
                .cmp(&b.source)
                .then(a.session.cmp(&b.session))
                .then(a.task.cmp(&b.task))
        }),
        SortBy::Tokens => rows.sort_by(|a, b| {
            b.total_tokens
                .cmp(&a.total_tokens)
                .then(b.llm.cmp(&a.llm))
                .then(b.events.cmp(&a.events))
                .then(a.source.cmp(&b.source))
                .then(a.session.cmp(&b.session))
        }),
    }
}

fn risk_rank(risk: Risk) -> u8 {
    match risk {
        Risk::Low => 0,
        Risk::Medium => 1,
        Risk::High => 2,
    }
}

fn process_history_key(pid: u32) -> String {
    format!("process:{pid}")
}

fn agent_tree_history_key(pid: u32) -> String {
    format!("agent-tree:{pid}")
}

fn container_history_key(id: &str) -> String {
    format!("container:{id}")
}

fn process_tree_usage(rows: &[ProcessRow], root_pid: u32) -> ProcessTreeUsage {
    let mut usage = ProcessTreeUsage::default();
    let mut stack = vec![root_pid];
    let mut visited = HashSet::new();

    while let Some(pid) = stack.pop() {
        if !visited.insert(pid) {
            continue;
        }
        let Some(process) = rows.iter().find(|process| process.pid == pid) else {
            continue;
        };
        usage.cpu_pct += process.cpu_pct;
        usage.mem_pct += process.mem_pct;
        if pid != root_pid {
            usage.descendants += 1;
        }
        stack.extend(
            rows.iter()
                .filter(|candidate| candidate.ppid == pid)
                .map(|child| child.pid),
        );
    }

    usage
}

fn push_history(history: &mut MetricHistory, cpu: f32, mem: f32) {
    history.cpu.push(cpu.clamp(0.0, 100.0));
    history.mem.push(mem.clamp(0.0, 100.0));
    cap_f32_history(&mut history.cpu);
    cap_f32_history(&mut history.mem);
}

fn add_aligned_f32_history(total: &mut Vec<f32>, values: &[f32]) {
    if values.is_empty() {
        return;
    }
    if total.len() < values.len() {
        let mut padded = vec![0.0; values.len() - total.len()];
        padded.extend(total.iter().copied());
        *total = padded;
    }
    let offset = total.len() - values.len();
    for (idx, value) in values.iter().enumerate() {
        total[offset + idx] += value;
    }
    cap_f32_history(total);
}

fn push_io_history(history: &mut MetricHistory, net_io_bytes: u64, block_io_bytes: u64) {
    history.net_io_bytes.push(net_io_bytes as f64);
    history.block_io_bytes.push(block_io_bytes as f64);
    cap_f64_history(&mut history.net_io_bytes);
    cap_f64_history(&mut history.block_io_bytes);
}

fn cap_f32_history(values: &mut Vec<f32>) {
    if values.len() > HISTORY_LIMIT {
        let excess = values.len() - HISTORY_LIMIT;
        values.drain(0..excess);
    }
}

fn cap_f64_history(values: &mut Vec<f64>) {
    if values.len() > HISTORY_LIMIT {
        let excess = values.len() - HISTORY_LIMIT;
        values.drain(0..excess);
    }
}

fn observe_raw_cpu_pct(
    history: &mut MetricHistory,
    total_ns: Option<u64>,
    now: Instant,
) -> Option<f32> {
    let total_ns = total_ns?;
    let pct = match (history.cpu_usage_total_ns, history.raw_sample_at) {
        (Some(previous), Some(previous_at)) if total_ns >= previous => {
            let elapsed = now.duration_since(previous_at).as_secs_f64();
            (elapsed > 0.0).then(|| {
                ((total_ns - previous) as f64 / (elapsed * 1_000_000_000.0) * 100.0) as f32
            })
        }
        _ => None,
    };
    history.cpu_usage_total_ns = Some(total_ns);
    history.raw_sample_at = Some(now);
    pct.map(|value| value.clamp(0.0, 10_000.0))
}

fn parse_percent(s: &str) -> Option<f32> {
    s.trim().trim_end_matches('%').parse().ok()
}

fn json_u64(value: &serde_json::Value) -> Option<u64> {
    value
        .as_u64()
        .or_else(|| value.as_i64().and_then(|v| (v >= 0).then_some(v as u64)))
        .or_else(|| value.as_f64().and_then(|v| (v >= 0.0).then_some(v as u64)))
}

fn json_field_u64(value: &serde_json::Value, fields: &[&str]) -> u64 {
    fields
        .iter()
        .find_map(|field| value.get(*field).and_then(json_u64))
        .unwrap_or(0)
}

fn format_byte_pair(first: u64, second: u64) -> String {
    format!("{} / {}", format_bytes(first), format_bytes(second))
}

fn container_net_total(container: &ContainerRow) -> u64 {
    parse_byte_pair_total(&container.net_io)
}

fn container_block_total(container: &ContainerRow) -> u64 {
    parse_byte_pair_total(&container.block_io)
}

fn container_pid_count(container: &ContainerRow) -> u64 {
    container.pids.trim().parse().unwrap_or(0)
}

fn container_state_label(status: &str) -> String {
    let lower = status.trim().to_ascii_lowercase();
    if lower.contains("paused") {
        "paused".into()
    } else if lower.starts_with("up") || lower == "running" || lower.contains("running") {
        "running".into()
    } else if lower.contains("restarting") {
        "restart".into()
    } else if lower.contains("exited") || lower.contains("stopped") {
        "exited".into()
    } else if lower.contains("created") {
        "created".into()
    } else if lower.contains("dead") {
        "dead".into()
    } else {
        status
            .split_whitespace()
            .next()
            .filter(|value| !value.is_empty())
            .unwrap_or("-")
            .to_ascii_lowercase()
    }
}

fn container_state_color(status: &str) -> Color {
    match container_state_label(status).as_str() {
        "running" => GREEN,
        "restart" => ORANGE,
        "paused" => YELLOW,
        "exited" | "stopped" => Color::BrightBlack,
        "created" => CYAN,
        "dead" => RED,
        _ => CYAN,
    }
}

fn container_state_summary(rows: &[ContainerRow]) -> ContainerStateSummary {
    let mut summary = ContainerStateSummary {
        total: rows.len(),
        ..ContainerStateSummary::default()
    };

    for row in rows {
        match container_state_label(&row.status).as_str() {
            "running" => summary.running += 1,
            "restart" => summary.restarting += 1,
            "paused" => summary.paused += 1,
            "exited" | "stopped" => summary.exited += 1,
            "created" => summary.created += 1,
            "dead" => summary.dead += 1,
            _ => summary.other += 1,
        }
    }

    summary
}

impl ContainerStateSummary {
    fn header_label(self) -> String {
        if self.total == 0 {
            return "0".into();
        }
        let mut parts = Vec::new();
        push_state_count(&mut parts, "run", self.running);
        push_state_count(&mut parts, "restart", self.restarting);
        push_state_count(&mut parts, "pause", self.paused);
        push_state_count(&mut parts, "exit", self.exited);
        push_state_count(&mut parts, "create", self.created);
        push_state_count(&mut parts, "dead", self.dead);
        push_state_count(&mut parts, "other", self.other);
        if parts.is_empty() {
            self.total.to_string()
        } else {
            format!("{} {}", self.total, parts.join(" "))
        }
    }
}

fn push_state_count(parts: &mut Vec<String>, label: &str, count: usize) {
    if count > 0 {
        parts.push(format!("{label}:{count}"));
    }
}

fn container_state_summary_json(summary: ContainerStateSummary) -> serde_json::Value {
    serde_json::json!({
        "total": summary.total,
        "running": summary.running,
        "restarting": summary.restarting,
        "paused": summary.paused,
        "exited": summary.exited,
        "created": summary.created,
        "dead": summary.dead,
        "other": summary.other,
    })
}

fn container_state_rank(container: &ContainerRow) -> u8 {
    match container_state_label(&container.status).as_str() {
        "running" => 5,
        "restart" => 4,
        "paused" => 3,
        "exited" | "stopped" => 2,
        "created" => 1,
        _ => 0,
    }
}

fn container_uptime_label(container: &ContainerRow) -> String {
    let status = container.status.trim();
    let lower = status.to_ascii_lowercase();
    if lower.starts_with("up ") {
        let value = status
            .trim_start_matches("Up ")
            .split(" (")
            .next()
            .unwrap_or(status)
            .trim();
        if !value.is_empty() {
            return truncate(value, 14);
        }
    }
    if lower == "running" && container.inspect.started != "-" {
        return truncate(&container.inspect.started, 14);
    }
    "-".into()
}

fn container_uptime_seconds(container: &ContainerRow) -> u64 {
    parse_uptime_seconds(&container.status).unwrap_or(0)
}

fn parse_uptime_seconds(status: &str) -> Option<u64> {
    let lower = status.to_ascii_lowercase();
    let rest = lower.strip_prefix("up ")?;
    let rest = rest.split(" (").next().unwrap_or(rest).trim();
    let mut parts = rest.split_whitespace();
    let first = parts.next()?;
    let amount = match first {
        "a" | "an" => 1,
        "about" => match parts.next()? {
            "a" | "an" => 1,
            value => value.parse::<u64>().ok()?,
        },
        value => value.parse::<u64>().ok()?,
    };
    let unit = parts.next().unwrap_or("second");
    let seconds = if unit.starts_with("second") {
        amount
    } else if unit.starts_with("minute") {
        amount.saturating_mul(60)
    } else if unit.starts_with("hour") {
        amount.saturating_mul(60 * 60)
    } else if unit.starts_with("day") {
        amount.saturating_mul(24 * 60 * 60)
    } else if unit.starts_with("week") {
        amount.saturating_mul(7 * 24 * 60 * 60)
    } else if unit.starts_with("month") {
        amount.saturating_mul(30 * 24 * 60 * 60)
    } else if unit.starts_with("year") {
        amount.saturating_mul(365 * 24 * 60 * 60)
    } else {
        return None;
    };
    Some(seconds)
}

fn parse_byte_pair_total(value: &str) -> u64 {
    value.split('/').filter_map(parse_human_bytes).sum()
}

fn parse_human_bytes(value: &str) -> Option<u64> {
    let value = value.trim();
    if value.is_empty() || value == "-" {
        return None;
    }

    let mut number = String::new();
    let mut unit = String::new();
    let mut seen_number = false;
    for ch in value.chars() {
        if ch.is_ascii_digit() || ch == '.' {
            seen_number = true;
            number.push(ch);
        } else if seen_number && ch.is_ascii_alphabetic() {
            unit.push(ch);
        } else if seen_number && ch.is_whitespace() {
            continue;
        } else if seen_number {
            break;
        }
    }
    let amount = number.parse::<f64>().ok()?;
    let multiplier = match unit.to_ascii_lowercase().as_str() {
        "" | "b" => 1.0,
        "k" | "kb" => 1_000.0,
        "m" | "mb" => 1_000_000.0,
        "g" | "gb" => 1_000_000_000.0,
        "t" | "tb" => 1_000_000_000_000.0,
        "kib" => 1024.0,
        "mib" => 1024.0 * 1024.0,
        "gib" => 1024.0 * 1024.0 * 1024.0,
        "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
        _ => return None,
    };
    Some((amount.max(0.0) * multiplier).round() as u64)
}

fn format_bytes(bytes: u64) -> String {
    const KIB: f64 = 1024.0;
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];

    let mut value = bytes as f64;
    let mut unit = 0;
    while value >= KIB && unit < UNITS.len() - 1 {
        value /= KIB;
        unit += 1;
    }

    if unit == 0 {
        format!("{bytes}B")
    } else if value >= 10.0 {
        format!("{value:.1}{}", UNITS[unit])
    } else {
        format!("{value:.2}{}", UNITS[unit])
    }
}

fn parse_duration(s: &str) -> anyhow::Result<Duration> {
    if let Some(ms) = s.strip_suffix("ms") {
        return Ok(Duration::from_millis(ms.parse()?));
    }
    if let Some(sec) = s.strip_suffix('s') {
        let seconds: f64 = sec.parse()?;
        return Ok(Duration::from_secs_f64(seconds));
    }
    Ok(Duration::from_millis(s.parse()?))
}

fn compact_json_value(v: &serde_json::Value) -> String {
    match v {
        serde_json::Value::String(s) => truncate(s, 80),
        _ => truncate(&v.to_string(), 80),
    }
}

fn display_workspace(path: Option<&str>) -> String {
    let Some(path) = path.map(str::trim).filter(|path| !path.is_empty()) else {
        return "-".to_string();
    };
    truncate(path, 72)
}

fn display_model(model: &str) -> String {
    let model = model.trim();
    if model.is_empty() || model == "-" {
        "-".to_string()
    } else {
        truncate(model, 40)
    }
}

fn display_provider(provider: &str) -> String {
    let provider = provider.trim();
    if provider.is_empty() || provider == "-" {
        "-".to_string()
    } else {
        truncate(provider, 28)
    }
}

fn normalize_ports(ports: &str) -> String {
    let ports = ports.trim();
    if ports.is_empty() {
        "-".to_string()
    } else {
        ports.to_string()
    }
}

fn display_ports(ports: &str) -> String {
    let ports = normalize_ports(ports);
    if ports == "-" {
        ports
    } else {
        truncate(&ports, 72)
    }
}

fn container_web_url(container: &ContainerRow) -> Option<String> {
    first_published_host_port(&container.ports).map(|port| format!("http://localhost:{port}/"))
}

fn first_published_host_port(ports: &str) -> Option<u16> {
    normalize_ports(ports)
        .split(',')
        .find_map(parse_published_host_port)
}

fn parse_published_host_port(port: &str) -> Option<u16> {
    let port = port.trim();
    if port.is_empty() || port == "-" {
        return None;
    }

    if let Some((host, _guest)) = port.split_once("->") {
        return parse_host_side_port(host);
    }

    let without_proto = port.split('/').next().unwrap_or(port).trim();
    let parts = without_proto
        .split(':')
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>();

    match parts.len() {
        0 | 1 => None,
        2 => parse_nonzero_port(parts[0]),
        _ => parse_nonzero_port(parts[parts.len().saturating_sub(2)]),
    }
}

fn parse_host_side_port(host: &str) -> Option<u16> {
    let host = host.split('/').next().unwrap_or(host).trim();
    let candidate = host.rsplit(':').find(|part| !part.trim().is_empty())?;
    parse_nonzero_port(candidate)
}

fn parse_nonzero_port(value: &str) -> Option<u16> {
    let cleaned = value
        .trim()
        .trim_start_matches('[')
        .trim_end_matches(']')
        .trim();
    let port = cleaned.parse::<u16>().ok()?;
    (port > 0).then_some(port)
}

fn format_avg_ms(total_ms: u64, samples: u64) -> String {
    match total_ms.checked_div(samples) {
        Some(avg) => format_duration_ms(avg),
        None => "-".to_string(),
    }
}

fn format_duration_ms(ms: u64) -> String {
    if ms >= 1000 {
        format!("{:.1}s", ms as f64 / 1000.0)
    } else {
        format!("{ms}ms")
    }
}

fn format_count(value: u64) -> String {
    if value >= 1_000_000 {
        format!("{:.1}M", value as f64 / 1_000_000.0)
    } else if value >= 1_000 {
        format!("{:.1}K", value as f64 / 1_000.0)
    } else {
        value.to_string()
    }
}

fn push_agent_more_leaf(
    children: &mut Vec<TreeNode>,
    total: usize,
    shown: usize,
    label: &str,
    agent: AgentKind,
) {
    if total > shown {
        children.push(TreeNode::leaf(agent_tree_label(
            agent,
            format!("... {} more {}", total.saturating_sub(shown), label),
        )));
    }
}

fn display_cmd(command: &str) -> String {
    truncate(command, 64)
}

fn short_id(id: &str) -> &str {
    id.get(..12.min(id.len())).unwrap_or(id)
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    if max <= 1 {
        return "…".to_string();
    }
    let mut out = s.chars().take(max - 1).collect::<String>();
    out.push('…');
    out
}

fn pad_plain(s: &str, width: usize) -> String {
    let len = s.chars().count();
    if len >= width {
        truncate(s, width)
    } else {
        format!("{s}{}", " ".repeat(width - len))
    }
}

fn pad_line(s: &str, width: usize) -> String {
    let visible = a3s_tui::style::visible_len(s);
    if visible >= width {
        s.to_string()
    } else {
        format!("{s}{}", " ".repeat(width - visible))
    }
}

fn invert_screen(screen: &str) -> String {
    format!(
        "\x1b[7m{}\x1b[0m",
        screen.replace("\x1b[0m", "\x1b[0m\x1b[7m")
    )
}

fn format_event_pid(pid: Option<u32>) -> String {
    pid.map(|pid| pid.to_string())
        .unwrap_or_else(|| "-".to_string())
}

fn format_optional_pct(value: Option<f32>) -> String {
    value
        .map(|value| format!("{value:.1}"))
        .unwrap_or_else(|| "-".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn all_columns_config() -> TopConfig {
        TopConfig {
            hidden_columns: HashSet::new(),
            ..TopConfig::default()
        }
    }

    #[test]
    fn detects_known_agents() {
        assert_eq!(detect_agent("/usr/bin/a3s code"), Some(AgentKind::A3sCode));
        assert_eq!(
            detect_agent("node /bin/claude"),
            Some(AgentKind::ClaudeCode)
        );
        assert_eq!(detect_agent("codex exec task"), Some(AgentKind::Codex));
    }

    #[test]
    fn parses_process_rows() {
        let row = parse_process_line(" 123  1  2.5  0.7  01:02:03 codex exec hello").unwrap();
        assert_eq!(row.pid, 123);
        assert_eq!(row.ppid, 1);
        assert_eq!(row.agent, Some(AgentKind::Codex));
        assert_eq!(row.cpu_pct, 2.5);
        assert!(row.cwd.is_none());
    }

    #[test]
    fn parses_lsof_cwd_output() {
        let text = "p123\nn/Users/roylin/code/a3s\n";
        assert_eq!(
            parse_lsof_cwd(text).as_deref(),
            Some("/Users/roylin/code/a3s")
        );
    }

    #[test]
    fn parses_durations() {
        assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
        assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
        assert_eq!(parse_duration("1500").unwrap(), Duration::from_millis(1500));
    }

    #[test]
    fn defaults_to_agents_view_with_a3s_box_connector() {
        let options = TopOptions::default();

        assert_eq!(options.tab, Tab::Agents);
        assert_eq!(options.config.connector, ContainerConnector::A3sBox);
    }

    #[test]
    fn parses_all_container_option() {
        let options = parse_options(vec![
            "--containers".into(),
            "--all".into(),
            "--filter".into(),
            "codex".into(),
            "--sort".into(),
            "mem".into(),
            "--risk".into(),
            "high".into(),
            "--kind".into(),
            "security".into(),
            "--connector".into(),
            "runc".into(),
            "--reverse".into(),
            "--invert".into(),
            "--compact".into(),
            "--watch".into(),
            "2s".into(),
        ])
        .unwrap();

        assert_eq!(options.tab, Tab::Containers);
        assert!(options.force_all_containers);
        assert!(!options.force_active_containers);
        assert_eq!(options.filter.as_deref(), Some("codex"));
        assert_eq!(options.sort_by, Some(SortBy::Mem));
        assert_eq!(options.risk_filter, Some(RiskFilter::High));
        assert_eq!(options.kind_filter, Some(KindFilter::Security));
        assert_eq!(options.connector, Some(ContainerConnector::RunC));
        assert_eq!(options.reverse_sort, Some(true));
        assert!(options.invert_colors);
        assert!(options.compact_columns);
        assert!(options.watch);
        assert_eq!(options.interval, Duration::from_secs(2));
    }

    #[test]
    fn parses_ctop_style_short_options_and_start_help() {
        let options = parse_options(vec![
            "-f".into(),
            "api".into(),
            "-s".into(),
            "pids".into(),
            "-r".into(),
            "-h".into(),
        ])
        .unwrap();

        assert_eq!(options.filter.as_deref(), Some("api"));
        assert_eq!(options.sort_by, Some(SortBy::Pids));
        assert_eq!(options.reverse_sort, Some(true));
        assert!(options.start_help);

        let app = TopApp::new(options);
        assert!(app.help);
    }

    #[test]
    fn parses_ctop_style_single_container_target() {
        let options = parse_options(vec!["web".into()]).unwrap();

        assert_eq!(options.tab, Tab::Containers);
        assert_eq!(options.container_query.as_deref(), Some("web"));

        let options = parse_options(vec!["--container".into(), "abcdef".into()]).unwrap();
        assert_eq!(options.tab, Tab::Containers);
        assert_eq!(options.container_query.as_deref(), Some("abcdef"));

        assert!(parse_options(vec!["web".into(), "api".into()])
            .unwrap_err()
            .to_string()
            .contains("only one container target"));
    }

    #[test]
    fn single_container_target_defaults_to_all_unless_active_is_forced() {
        let mut options = TopOptions {
            container_query: Some("stopped-job".into()),
            config: TopConfig {
                show_all_containers: false,
                ..TopConfig::default()
            },
            ..TopOptions::default()
        };

        apply_cli_overrides(&mut options, false, false);

        assert_eq!(options.tab, Tab::Containers);
        assert!(options.config.show_all_containers);

        let mut options = TopOptions {
            container_query: Some("running-only".into()),
            force_active_containers: true,
            config: TopConfig {
                show_all_containers: true,
                ..TopConfig::default()
            },
            ..TopOptions::default()
        };

        apply_cli_overrides(&mut options, false, true);

        assert_eq!(options.tab, Tab::Containers);
        assert!(!options.config.show_all_containers);
    }

    #[test]
    fn parses_llm_kind_option() {
        let options =
            parse_options(vec!["--events".into(), "--kind".into(), "llm".into()]).unwrap();

        assert_eq!(options.tab, Tab::Events);
        assert_eq!(options.kind_filter, Some(KindFilter::Llm));
        assert!(KindFilter::Llm.matches("LlmApi"));
        assert!(KindFilter::Llm.matches("LlmCall"));
        assert!(!KindFilter::Other.matches("LlmApi"));
    }

    #[test]
    fn parses_tokens_sort_option() {
        let options = parse_options(vec!["--sort".into(), "tokens".into()]).unwrap();

        assert_eq!(options.sort_by, Some(SortBy::Tokens));
        assert_eq!(SortBy::Mem.next(), SortBy::Net);
        assert_eq!(SortBy::Net.next(), SortBy::Block);
        assert_eq!(SortBy::Block.next(), SortBy::Pids);
        assert_eq!(SortBy::Pids.next(), SortBy::State);
        assert_eq!(SortBy::State.next(), SortBy::Id);
        assert_eq!(SortBy::Id.next(), SortBy::Uptime);
        assert_eq!(SortBy::Uptime.next(), SortBy::Name);
        assert_eq!(SortBy::Name.next(), SortBy::Tokens);
        assert_eq!(SortBy::Tokens.next(), SortBy::Cpu);
    }

    #[test]
    fn parses_container_io_sort_options() {
        assert_eq!(
            parse_options(vec!["--sort".into(), "net".into()])
                .unwrap()
                .sort_by,
            Some(SortBy::Net)
        );
        assert_eq!(SortBy::from_label("block"), Some(SortBy::Block));
        assert_eq!(SortBy::from_label("io"), Some(SortBy::Block));
        assert_eq!(SortBy::from_label("pids"), Some(SortBy::Pids));
        assert_eq!(SortBy::from_label("state"), Some(SortBy::State));
        assert_eq!(SortBy::from_label("id"), Some(SortBy::Id));
        assert_eq!(SortBy::from_label("uptime"), Some(SortBy::Uptime));
    }

    #[test]
    fn parses_sessions_option() {
        let options = parse_options(vec!["--sessions".into()]).unwrap();

        assert_eq!(options.tab, Tab::Sessions);
    }

    #[test]
    fn parses_json_option() {
        let options = parse_options(vec!["--json".into(), "--containers".into()]).unwrap();

        assert!(options.json);
        assert_eq!(options.tab, Tab::Containers);
    }

    #[test]
    fn parses_json_watch_count_option() {
        let options = parse_options(vec![
            "--json".into(),
            "--watch".into(),
            "25ms".into(),
            "--count".into(),
            "3".into(),
        ])
        .unwrap();

        assert!(options.json);
        assert!(options.watch);
        assert_eq!(options.interval, Duration::from_millis(25));
        assert_eq!(options.json_count, Some(3));
        assert!(parse_options(vec!["--count".into(), "0".into()]).is_err());
    }

    #[test]
    fn invert_screen_reapplies_reverse_after_resets() {
        let rendered = invert_screen("left\x1b[31mred\x1b[0mright");

        assert!(rendered.starts_with("\x1b[7m"));
        assert!(rendered.contains("\x1b[0m\x1b[7mright"));
        assert!(rendered.ends_with("\x1b[0m"));
    }

    #[test]
    fn parses_top_config() {
        let config = parse_top_config(
            r#"{
                "show_all_containers": true,
                "show_header": false,
                "reverse_sort": true,
                "sort_by": "mem",
                "risk_filter": "high",
                "kind_filter": "tool",
                "connector": "runc",
                "filter": "codex",
                "hidden_columns": ["containers.net", "events.message"]
            }"#,
        )
        .unwrap();

        assert!(config.show_all_containers);
        assert!(!config.show_header);
        assert!(config.reverse_sort);
        assert_eq!(config.sort_by, SortBy::Mem);
        assert_eq!(config.risk_filter, RiskFilter::High);
        assert_eq!(config.kind_filter, KindFilter::Tool);
        assert_eq!(config.connector, ContainerConnector::RunC);
        assert_eq!(config.filter, "codex");
        assert!(config.hidden_columns.contains("containers.net"));
        assert!(config.hidden_columns.contains("events.message"));
    }

    #[test]
    fn cli_overrides_loaded_config() {
        let mut options = TopOptions {
            force_all_containers: true,
            force_active_containers: false,
            filter: Some("claude".into()),
            sort_by: Some(SortBy::Name),
            risk_filter: Some(RiskFilter::High),
            kind_filter: Some(KindFilter::Security),
            connector: Some(ContainerConnector::RunC),
            reverse_sort: Some(true),
            show_header: Some(false),
            compact_columns: true,
            config: TopConfig {
                filter: "codex".into(),
                sort_by: SortBy::Cpu,
                risk_filter: RiskFilter::All,
                kind_filter: KindFilter::All,
                hidden_columns: HashSet::new(),
                ..TopConfig::default()
            },
            ..TopOptions::default()
        };

        apply_cli_overrides(&mut options, true, false);

        assert!(options.config.show_all_containers);
        assert_eq!(options.config.filter, "claude");
        assert_eq!(options.config.sort_by, SortBy::Name);
        assert_eq!(options.config.risk_filter, RiskFilter::High);
        assert_eq!(options.config.kind_filter, KindFilter::Security);
        assert_eq!(options.config.connector, ContainerConnector::RunC);
        assert!(options.config.reverse_sort);
        assert!(!options.config.show_header);
        assert!(options.config.hidden_columns.contains("agents.session"));
        assert!(options.config.hidden_columns.contains("containers.ports"));
        assert!(!options.config.hidden_columns.contains("agents.command"));
    }

    #[test]
    fn ctop_active_flag_overrides_saved_all_container_config() {
        let options = parse_options(vec!["-a".into()]).unwrap();
        assert!(options.force_active_containers);
        assert!(!options.force_all_containers);

        let mut options = TopOptions {
            force_active_containers: true,
            config: TopConfig {
                show_all_containers: true,
                ..TopConfig::default()
            },
            ..TopOptions::default()
        };

        apply_cli_overrides(&mut options, false, true);

        assert!(!options.config.show_all_containers);
    }

    #[test]
    fn container_visibility_flags_use_last_value() {
        let options = parse_options(vec!["-a".into(), "--all".into()]).unwrap();
        assert!(options.force_all_containers);
        assert!(!options.force_active_containers);

        let options = parse_options(vec!["--all".into(), "--active".into()]).unwrap();
        assert!(options.force_active_containers);
        assert!(!options.force_all_containers);
    }

    #[test]
    fn config_json_sorts_hidden_columns() {
        let mut config = TopConfig::default();
        config.hidden_columns.insert("events.source".into());
        config.hidden_columns.insert("agents.cpu".into());

        let text = top_config_json(&config);
        let agents_idx = text.find("agents.cpu").unwrap();
        let events_idx = text.find("events.source").unwrap();

        assert!(agents_idx < events_idx);
        assert!(text.contains("\"risk_filter\": \"all\""));
        assert!(text.contains("\"kind_filter\": \"all\""));
    }

    #[test]
    fn default_columns_are_compact_but_core_agent_fields_remain() {
        let config = TopConfig::default();
        assert!(config.hidden_columns.contains("agents.session"));
        assert!(config.hidden_columns.contains("agents.task"));
        assert!(config.hidden_columns.contains("agents.tools"));
        assert!(config.hidden_columns.contains("containers.ports"));
        assert!(config.hidden_columns.contains("containers.health"));
        assert!(config.hidden_columns.contains("containers.image"));
        assert!(config.hidden_columns.contains("containers.mem_usage"));
        assert!(config.hidden_columns.contains("containers.cpus"));
        assert!(config.hidden_columns.contains("events.pid"));
        assert!(!config.hidden_columns.contains("agents.agent"));
        assert!(!config.hidden_columns.contains("agents.cpu"));
        assert!(!config.hidden_columns.contains("agents.command"));
        assert!(!config.hidden_columns.contains("containers.status"));
        assert!(!config.hidden_columns.contains("containers.name"));
        assert!(!config.hidden_columns.contains("containers.id"));
        assert!(!config.hidden_columns.contains("containers.cpu"));
        assert!(!config.hidden_columns.contains("containers.mem"));
        assert!(!config.hidden_columns.contains("containers.net"));
        assert!(!config.hidden_columns.contains("containers.block"));
        assert!(!config.hidden_columns.contains("containers.pids"));
        assert!(!config.hidden_columns.contains("containers.uptime"));

        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 260;
        app.snapshot.processes = vec![
            process_row(42, 1, "codex exec task"),
            process_row(100, 42, "bash -lc git status"),
        ];
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "ToolExec",
            "git status",
            Risk::Medium,
        )];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("Agents"));
        assert!(plain.contains("codex"));
        assert!(plain.contains("Sessions (1)"));
        assert!(plain.contains("Processes (1 system · 1 agent)"));
        assert!(plain.contains("Events (1)"));
        assert!(plain.contains("sess-a"));
        assert!(plain.contains("task-a"));
        assert!(plain.contains("> pid 100"));
    }

    #[test]
    fn default_container_columns_align_with_ctop_and_show_cid() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 260;
        app.snapshot.containers = vec![container_row(
            "abcdef1234567890",
            "a3s-box-dev",
            "Up 2 minutes",
            Some(12.5),
            Some(30.0),
        )];
        app.record_history(&mut app.snapshot.clone());
        let plain = a3s_tui::style::strip_ansi(&app.table());

        for header in [
            "STATUS", "NAME", "CID", "CPU", "MEM", "NET I/O", "IO", "PIDS", "UPTIME",
        ] {
            assert!(plain.contains(header), "missing {header} in {plain}");
        }
        assert!(plain.contains("abcdef123456"));
        assert!(plain.contains("12.5%"));
        assert!(plain.contains("30.0%"));
        assert!(plain.contains("2 minutes"));
        assert!(!plain.contains("CPUS"));
        assert!(!plain.contains("PORTS"));
        assert!(!plain.contains("HEALTH"));
        assert!(!plain.contains("IMAGE"));
        assert!(!plain.contains("MEM USAGE"));
    }

    #[test]
    fn scaled_cpu_column_is_configurable_like_ctop_cpus() {
        assert_eq!(scale_cpu_pct_by(200.0, 4), 50.0);
        assert_eq!(scale_cpu_pct_by(42.0, 0), 42.0);
        assert_eq!(scale_cpu_pct_for_cpus(200.0, Some(2)), 100.0);

        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 300;
        let mut row = container_row(
            "abcdef1234567890",
            "a3s-box-dev",
            "Up 2 minutes",
            Some(100.0),
            Some(30.0),
        );
        row.cpu_count = Some(2);
        app.snapshot.containers = vec![row];
        app.record_history(&mut app.snapshot.clone());

        let plain = a3s_tui::style::strip_ansi(&app.table());
        let scaled = format!("{:.1}%", scale_cpu_pct_for_cpus(100.0, Some(2)));

        assert!(plain.contains("CPUS"), "{plain}");
        assert!(plain.contains(&scaled), "{plain}");
    }

    #[test]
    fn narrow_container_table_keeps_ctop_identity_and_core_metrics() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 80;
        app.snapshot.containers = vec![container_row(
            "abcdef1234567890",
            "a3s-box-dev",
            "Up 2 minutes",
            Some(12.5),
            Some(30.0),
        )];
        app.record_history(&mut app.snapshot.clone());
        let plain = a3s_tui::style::strip_ansi(&app.table());

        for header in ["STATUS", "NAME", "CID", "CPU", "MEM"] {
            assert!(plain.contains(header), "missing {header} in {plain}");
        }
        assert!(plain.contains("abcdef123456"));
        assert!(plain.contains("12.5%"));
        assert!(plain.contains("30.0%"));
        assert!(!plain.contains("NET I/O"));
        assert!(!plain.contains("UPTIME"));
    }

    #[test]
    fn container_table_marks_current_sort_column() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 260;
        app.snapshot.containers = vec![container_row(
            "abcdef1234567890",
            "a3s-box-dev",
            "Up 2 minutes",
            Some(12.5),
            Some(30.0),
        )];
        app.record_history(&mut app.snapshot.clone());

        let plain = a3s_tui::style::strip_ansi(&app.table());
        let header = plain.lines().next().unwrap();
        assert!(header.contains("CPU↓"), "{header}");
        assert!(!header.contains("MEM↓"), "{header}");

        app.sort_by = SortBy::Mem;
        app.reverse_sort = true;
        let plain = a3s_tui::style::strip_ansi(&app.table());
        let header = plain.lines().next().unwrap();
        assert!(header.contains("MEM↑"), "{header}");
        assert!(!header.contains("CPU↑"), "{header}");
    }

    #[test]
    fn snapshot_json_includes_agents_sessions_containers_and_events() {
        let mut process = process_row(42, 1, "codex exec task");
        process.cwd = Some("/work/a3s".into());
        process.cpu_pct = 12.5;
        let child = process_row(100, 42, "bash -lc cargo test");
        let grandchild = process_row(101, 100, "cargo test");
        let mut container = container_row("abcdef123456", "app", "Up", Some(1.0), Some(2.0));
        container.inspect.health = "healthy".into();
        let mut app = TopApp::new(TopOptions::default());
        let mut snapshot = TopSnapshot {
            processes: vec![process, child, grandchild],
            containers: vec![container],
            events: vec![
                parse_observer_line(
                    r#"{"identity":{"agent":"codex","session":"sess-a","task":"task-a"},"event":{"ToolExec":{"pid":42,"argv":["git","status"],"cwd":"/work/a3s"}}}"#,
                )
                .unwrap(),
                parse_observer_line(
                    r#"{"identity":{"agent":"codex","session":"sess-b","task":"task-b"},"event":{"SecurityAction":{"pid":100,"ppid":42,"action":"ptrace","cwd":"/work/a3s"}}}"#,
                )
                .unwrap(),
            ],
            ..Default::default()
        };
        app.record_history(&mut snapshot);
        app.snapshot = snapshot;

        let value = top_snapshot_json(&app, 123);

        assert_eq!(value["schema"], "a3s.top.snapshot.v1");
        assert_eq!(value["collected_at_unix_ms"], 123);
        assert_eq!(value["summary"]["agents"], 1);
        assert_eq!(value["summary"]["sessions"], 2);
        assert_eq!(value["summary"]["containers"], 1);
        assert_eq!(value["summary"]["container_states"]["running"], 1);
        assert_eq!(value["summary"]["container_states"]["total"], 1);
        assert_eq!(value["summary"]["raw_container_states"]["running"], 1);
        assert_eq!(value["agents"][0]["agent"], "codex");
        assert_eq!(value["agents"][0]["activity"]["tools"], 1);
        assert_eq!(value["agents"][0]["activity"]["sessions"], 2);
        assert_eq!(value["agents"][0]["top_session"], "sess-b");
        assert_eq!(value["agents"][0]["top_task"], "task-b");
        assert_eq!(value["agents"][0]["sessions"][0]["session"], "sess-b");
        assert_eq!(value["agents"][0]["sessions"][0]["security"], 1);
        assert_eq!(value["agents"][0]["sessions"][1]["session"], "sess-a");
        assert_eq!(value["agents"][0]["sessions"][1]["tools"], 1);
        assert_eq!(value["agents"][0]["history"]["cpu_pct"][0], 12.5);
        assert_eq!(value["agents"][0]["recent_events"][0]["kind"], "ToolExec");
        assert_eq!(value["sessions"][0]["workspace"], "/work/a3s");
        assert_eq!(value["containers"][0]["cid"], "abcdef123456");
        assert_eq!(value["containers"][0]["short_id"], "abcdef123456");
        assert_eq!(value["containers"][0]["inspect"]["health"], "healthy");
        assert_eq!(value["containers"][0]["history"]["cpu_pct"][0], 1.0);
        assert_eq!(value["processes"][0]["history"]["cpu_pct"][0], 12.5);
        assert_eq!(value["agents"][0]["subtree"]["descendants"], 2);
        assert_eq!(value["agents"][0]["process_tree"]["pid"], 42);
        assert_eq!(
            value["agents"][0]["process_tree"]["children"][0]["pid"],
            100
        );
        assert_eq!(
            value["agents"][0]["process_tree"]["children"][0]["children"][0]["pid"],
            101
        );
        let details = value["events"][0]["details"].as_array().unwrap();
        assert!(details.iter().any(|detail| detail["key"] == "argv"));
    }

    #[test]
    fn keeps_one_visible_column_per_tab() {
        let mut config = TopConfig::default();
        for id in [
            "containers.status",
            "containers.name",
            "containers.id",
            "containers.cpu",
            "containers.cpus",
            "containers.mem",
            "containers.net",
            "containers.block",
            "containers.pids",
            "containers.uptime",
            "containers.image",
            "containers.mem_usage",
            "containers.ports",
            "containers.health",
        ] {
            config.hidden_columns.insert(id.into());
        }
        let app = TopApp::new(TopOptions {
            config,
            ..TopOptions::default()
        });

        assert!(app.column_visible("containers.status"));
    }

    #[test]
    fn column_panel_can_restore_compact_defaults_for_current_tab() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.hidden_columns.insert("events.source".into());

        assert!(app.column_visible("agents.session"));
        assert!(app.column_visible("agents.tools"));
        assert!(!app.column_visible("events.source"));

        app.open_column_panel();
        app.handle_key(KeyEvent {
            code: KeyCode::Char('d'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.column_panel.is_none());
        assert!(!app.column_visible("agents.session"));
        assert!(!app.column_visible("agents.tools"));
        assert!(app.column_visible("agents.agent"));
        assert!(app.column_visible("agents.command"));
        assert!(!app.column_visible("events.source"));
    }

    #[test]
    fn column_panel_uses_shared_menu_panel_and_accepts_number_shortcuts() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 42;

        assert!(app.column_visible("containers.id"));
        app.open_column_panel();
        let rendered = app.column_panel_view();
        let plain = a3s_tui::style::strip_ansi(&rendered);
        assert!(plain.contains("columns · Containers"), "{plain}");
        assert!(plain.contains("> 1. [✓] Status"), "{plain}");
        assert!(plain.contains("  2. [✓] Name"), "{plain}");
        assert!(plain.contains("  3. [✓] CID"), "{plain}");
        assert!(rendered.contains("\x1b["), "selected row should be styled");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 42),
            "{plain}"
        );

        app.handle_key(KeyEvent {
            code: KeyCode::Char('3'),
            modifiers: KeyModifiers::empty(),
        });
        app.handle_key(KeyEvent {
            code: KeyCode::Enter,
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.column_panel.is_none());
        assert!(!app.column_visible("containers.id"));
        assert!(app.column_visible("containers.name"));
    }

    #[test]
    fn help_view_mentions_compact_column_restore() {
        let mut app = TopApp::new(TopOptions::default());
        app.width = 72;

        let rendered = app.help_view();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("Keys"));
        assert!(plain.contains("c then d"));
        assert!(plain.contains("restore compact columns"));
        assert!(plain.contains("s / r"));
        assert!(plain.contains("clear filter"));
        assert!(rendered.contains("\x1b["), "help view should be styled");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 72),
            "{plain}"
        );
    }

    #[test]
    fn confirm_view_uses_shared_confirm_and_fits_narrow_width() {
        let mut app = TopApp::new(TopOptions::default());
        app.width = 36;
        app.confirm = Some(Action::RemoveContainer(
            ContainerConnector::Docker,
            "container-1".into(),
            "very-long-container-name-that-needs-wrapping".into(),
        ));

        let rendered = app.confirm_view();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("Remove container?"), "{plain}");
        assert!(plain.contains("very-long-container"), "{plain}");
        assert!(plain.contains("[ y / Enter ] confirm"), "{plain}");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 36),
            "{plain}"
        );
    }

    #[test]
    fn shifted_uppercase_keys_resolve() {
        let keymap = top_keymap();
        let key = |c| KeyEvent {
            code: KeyCode::Char(c),
            modifiers: KeyModifiers::SHIFT,
        };

        assert_eq!(keymap.resolve(&key('S')), Some(TopKey::SaveConfig));
        assert_eq!(keymap.resolve(&key('K')), Some(TopKey::Kill));
        assert_eq!(keymap.resolve(&key('H')), Some(TopKey::ToggleHeader));
        assert_eq!(keymap.resolve(&key('R')), Some(TopKey::ToggleReverse));
        assert_eq!(keymap.resolve(&key('!')), Some(TopKey::ToggleRiskFilter));
        assert_eq!(keymap.resolve(&key('C')), Some(TopKey::Connector));
        assert_eq!(keymap.resolve(&key('G')), Some(TopKey::ToggleKindFilter));
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Char('g'),
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::ToggleKindFilter)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Char('f'),
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::Filter)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Char('r'),
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::ToggleReverse)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Char('w'),
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::OpenBrowser)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Char('h'),
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::Help)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Char('p'),
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::TogglePause)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::Home,
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::Home)
        );
        assert_eq!(
            keymap.resolve(&KeyEvent {
                code: KeyCode::End,
                modifiers: KeyModifiers::empty(),
            }),
            Some(TopKey::End)
        );
    }

    #[test]
    fn home_end_jump_selection_and_p_toggles_pause() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![
            container_row("aaa111", "alpha", "Up", Some(1.0), Some(1.0)),
            container_row("bbb222", "beta", "Up", Some(1.0), Some(1.0)),
            container_row("ccc333", "gamma", "Up", Some(1.0), Some(1.0)),
        ];

        app.handle_key(KeyEvent {
            code: KeyCode::End,
            modifiers: KeyModifiers::empty(),
        });
        assert_eq!(app.selected, 2);

        app.handle_key(KeyEvent {
            code: KeyCode::Home,
            modifiers: KeyModifiers::empty(),
        });
        assert_eq!(app.selected, 0);

        app.handle_key(KeyEvent {
            code: KeyCode::Char('p'),
            modifiers: KeyModifiers::empty(),
        });
        assert!(app.paused);
    }

    #[test]
    fn ctop_arrow_keys_open_container_view_and_logs() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![container_row(
            "abcdef",
            "app",
            "Up 2 minutes",
            Some(1.0),
            Some(2.0),
        )];

        let focus_cmd = app.handle_key(KeyEvent {
            code: KeyCode::Right,
            modifiers: KeyModifiers::empty(),
        });

        assert!(focus_cmd.is_some());
        assert_eq!(app.focused_container.as_deref(), Some("abcdef"));
        assert!(app
            .container_processes
            .as_ref()
            .is_some_and(|panel| panel.container_id == "abcdef" && panel.loading));

        app.focused_container = None;
        app.container_processes = None;
        let log_cmd = app.handle_key(KeyEvent {
            code: KeyCode::Left,
            modifiers: KeyModifiers::empty(),
        });

        assert!(log_cmd.is_some());
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.container_id, "abcdef");
        assert_eq!(log.container_name, "app");
        assert!(log.loading);
        assert_eq!(app.tab, Tab::Containers);
    }

    #[test]
    fn primary_tab_order_is_agents_then_containers() {
        assert_eq!(
            Tab::ALL,
            [
                Tab::Agents,
                Tab::Containers,
                Tab::Sessions,
                Tab::Events,
                Tab::Processes,
            ]
        );
        assert_eq!(Tab::PRIMARY, [Tab::Agents, Tab::Containers]);
        assert_eq!(Tab::Agents.next(), Tab::Containers);
        assert_eq!(Tab::Containers.next(), Tab::Agents);
        assert_eq!(Tab::Agents.prev(), Tab::Containers);
    }

    #[test]
    fn arrow_keys_switch_primary_agents_and_containers_tabs() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });

        app.handle_key(KeyEvent {
            code: KeyCode::Tab,
            modifiers: KeyModifiers::empty(),
        });
        assert_eq!(app.tab, Tab::Containers);

        app.handle_key(KeyEvent {
            code: KeyCode::BackTab,
            modifiers: KeyModifiers::empty(),
        });
        assert_eq!(app.tab, Tab::Agents);
    }

    #[test]
    fn tabs_use_shared_component_for_filter_and_focus_suffixes() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 72;
        app.filter = "api".into();
        app.focused_container = Some("abcdef123456".into());
        app.snapshot.containers = vec![container_row(
            "abcdef123456",
            "very-long-api-container-name",
            "Up",
            Some(1.0),
            Some(2.0),
        )];

        let rendered = app.tabs();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("Agents"), "{plain}");
        assert!(plain.contains("Containers"), "{plain}");
        assert!(plain.contains("/api"), "{plain}");
        assert!(plain.contains("focus:very-long-api-cont"), "{plain}");
        assert!(rendered.contains("\x1b["), "active tab should be styled");
        assert_eq!(a3s_tui::style::visible_len(&rendered), 72);
    }

    #[test]
    fn tabs_append_non_primary_active_tab_and_fit_width() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.width = 36;
        app.filter = "security".into();

        let rendered = app.tabs();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("Agents"), "{plain}");
        assert!(plain.contains("Containers"), "{plain}");
        assert!(plain.contains("Events"), "{plain}");
        assert!(a3s_tui::style::visible_len(&rendered) <= 36, "{plain}");
    }

    #[test]
    fn lowercase_r_reverses_sort_without_restarting_container() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![container_row(
            "abcdef123456",
            "a3s-box-dev",
            "Up 2 minutes",
            Some(12.5),
            Some(30.0),
        )];

        app.handle_key(KeyEvent {
            code: KeyCode::Char('r'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.reverse_sort);
        assert!(app.confirm.is_none());
        assert!(app.container_menu.is_none());

        app.handle_key(KeyEvent {
            code: KeyCode::Char('r'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(!app.reverse_sort);
        assert!(app.confirm.is_none());
    }

    #[test]
    fn parses_observer_events() {
        let line = r#"{"timestamp":"2026-06-26T08:00:00Z","identity":{"agent":"codex","task":"task-1","session":"sess-1"},"provider":null,"event":{"ToolExec":{"pid":2,"argv":["git","status"],"cwd":"/tmp"}}}"#;
        let row = parse_observer_line(line).unwrap();
        assert_eq!(row.ts, "2026-06-26T08:00:00Z");
        assert_eq!(row.source, "codex");
        assert_eq!(row.session.as_deref(), Some("sess-1"));
        assert_eq!(row.task.as_deref(), Some("task-1"));
        assert_eq!(row.pid, Some(2));
        assert!(row.ppid.is_none());
        assert_eq!(row.kind, "ToolExec");
        assert_eq!(row.risk, Risk::Medium);
        assert!(row.message.contains("argv=[\"git\",\"status\"]"));
        assert!(row
            .details
            .iter()
            .any(|(key, value)| key == "cwd" && value == "/tmp"));
    }

    #[test]
    fn parses_numeric_observer_timestamps() {
        let row = parse_observer_line(
            r#"{"ts":1782460800000,"identity":{"agent":"codex"},"event":{"ToolExec":{"argv":["pwd"]}}}"#,
        )
        .unwrap();

        assert_eq!(row.ts, "1782460800000");
    }

    #[test]
    fn events_table_renders_process_scope_columns() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 360;
        app.snapshot.events = vec![parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"pid":77,"ppid":42,"argv":["git","status"],"cwd":"/tmp/a3s"}}}"#,
        )
        .unwrap()];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("PID"));
        assert!(plain.contains("PPID"));
        assert!(plain.contains("77"));
        assert!(plain.contains("42"));
    }

    #[test]
    fn events_process_scope_columns_are_configurable() {
        let app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        let choices = app.column_choices(Tab::Events);

        assert!(choices.iter().any(|choice| choice.id == "events.pid"));
        assert!(choices.iter().any(|choice| choice.id == "events.ppid"));
    }

    #[test]
    fn event_filter_matches_payload_details() {
        let line = r#"{"identity":{"agent":"codex","task":"task-1","session":"sess-1"},"event":{"ToolExec":{"a":1,"b":2,"c":3,"d":4,"z":"needle-value"}}}"#;
        let row = parse_observer_line(line).unwrap();
        assert!(!row.message.contains("needle-value"));

        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.filter = "needle".into();
        app.snapshot.events = vec![row];

        assert_eq!(app.filtered_events().len(), 1);
    }

    #[test]
    fn container_lifecycle_events_track_a3s_box_state_changes() {
        let mut previous =
            container_row("abcdef1234567890", "dev", "running", Some(1.0), Some(2.0));
        previous.connector = ContainerConnector::A3sBox;
        previous.inspect.health = "healthy".into();
        let mut current = previous.clone();
        current.status = "paused".into();

        let events =
            container_lifecycle_events(ContainerConnector::A3sBox, &[previous], &[current]);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].source, "a3s-box");
        assert_eq!(events[0].kind, "Container");
        assert_eq!(events[0].risk, Risk::Medium);
        assert!(events[0].message.contains("pause dev"));
        assert!(events[0]
            .details
            .contains(&("action".into(), "pause".into())));
        assert!(events[0]
            .details
            .contains(&("cid".into(), "abcdef123456".into())));
        assert!(events[0]
            .details
            .contains(&("previous_status".into(), "running".into())));
    }

    #[test]
    fn runtime_container_events_are_merged_into_snapshots() {
        let mut previous =
            container_row("abcdef1234567890", "dev", "running", Some(1.0), Some(2.0));
        previous.connector = ContainerConnector::A3sBox;
        let mut current = previous.clone();
        current.status = "paused".into();
        let snapshot = TopSnapshot {
            containers: vec![current],
            ..Default::default()
        };
        let mut app = TopApp::new(TopOptions::default());
        app.last_refresh = Some(Instant::now());
        app.snapshot.containers = vec![previous];

        app.apply_snapshot(
            snapshot,
            ObserverState::default(),
            ContainerConnector::A3sBox,
        );

        assert_eq!(app.snapshot.events.len(), 1);
        assert_eq!(app.snapshot.events[0].source, "a3s-box");
        assert_eq!(app.snapshot.events[0].kind, "Container");
        let value = top_snapshot_json(&app, 123);
        assert_eq!(value["events"][0]["source"], "a3s-box");
        assert_eq!(value["events"][0]["kind"], "Container");
        assert_eq!(value["events"][0]["details"][1]["key"], "action");
        assert_eq!(value["events"][0]["details"][1]["value"], "pause");
    }

    #[test]
    fn counts_process_descendants() {
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.processes = vec![
            process_row(1, 0, "codex"),
            process_row(2, 1, "node child"),
            process_row(3, 2, "sh grandchild"),
            process_row(4, 0, "other"),
        ];

        assert_eq!(app.descendant_count(1), 2);
        assert_eq!(app.descendant_count(4), 0);
    }

    #[test]
    fn scopes_agent_activity_to_process_tree() {
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.processes = vec![
            process_row(10, 1, "codex exec left"),
            process_row(20, 1, "codex exec right"),
            process_row(11, 10, "bash child"),
            process_row(21, 20, "bash child"),
        ];
        app.snapshot.events = vec![
            parse_observer_line(
                r#"{"identity":{"agent":"codex","session":"left"},"event":{"ToolExec":{"pid":11,"ppid":10,"argv":["git","status"],"cwd":"/tmp"}}}"#,
            )
            .unwrap(),
            parse_observer_line(
                r#"{"identity":{"agent":"codex","session":"right"},"event":{"SecurityAction":{"pid":999,"ppid":20,"action":"ptrace","target":"other"}}}"#,
            )
            .unwrap(),
            event_row(
                "codex",
                Some("ambiguous"),
                None,
                "ToolExec",
                "no pid",
                Risk::Medium,
            ),
        ];

        let left = app.agent_activity_for_process(&app.snapshot.processes[0]);
        let right = app.agent_activity_for_process(&app.snapshot.processes[1]);
        let aggregate = app.agent_activity(AgentKind::Codex);

        assert_eq!(left.events, 1);
        assert_eq!(left.tools, 1);
        assert_eq!(left.high_risk, 0);
        assert_eq!(right.events, 1);
        assert_eq!(right.security, 1);
        assert_eq!(right.high_risk, 1);
        assert_eq!(aggregate.events, 3);
    }

    #[test]
    fn scopes_pidless_agent_events_by_workspace() {
        let mut left = process_row(10, 1, "codex exec left");
        left.cwd = Some("/work/left".into());
        let mut right = process_row(20, 1, "codex exec right");
        right.cwd = Some("/work/right".into());
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.processes = vec![left, right];
        app.snapshot.events = vec![
            parse_observer_line(
                r#"{"identity":{"agent":"codex","session":"left"},"event":{"ToolExec":{"argv":["git","status"],"cwd":"/work/left/crate"}}}"#,
            )
            .unwrap(),
            parse_observer_line(
                r#"{"identity":{"agent":"codex","session":"right"},"event":{"SecurityAction":{"action":"ptrace","target":"other","cwd":"/work/right"}}}"#,
            )
            .unwrap(),
            event_row(
                "codex",
                Some("ambiguous"),
                None,
                "ToolExec",
                "no pid or cwd",
                Risk::Medium,
            ),
        ];

        let left = app.agent_activity_for_process(&app.snapshot.processes[0]);
        let right = app.agent_activity_for_process(&app.snapshot.processes[1]);

        assert_eq!(left.events, 1);
        assert_eq!(left.tools, 1);
        assert_eq!(right.events, 1);
        assert_eq!(right.security, 1);
        assert_eq!(right.high_risk, 1);
    }

    #[test]
    fn pidless_workspace_events_do_not_duplicate_across_matching_agents() {
        let mut first = process_row(10, 1, "codex exec first");
        first.cwd = Some("/work/shared".into());
        let mut second = process_row(20, 1, "codex exec second");
        second.cwd = Some("/work/shared".into());
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.processes = vec![first, second];
        app.snapshot.events = vec![parse_observer_line(
            r#"{"identity":{"agent":"codex","session":"shared"},"event":{"ToolExec":{"argv":["git","status"],"cwd":"/work/shared"}}}"#,
        )
        .unwrap()];

        assert_eq!(
            app.agent_activity_for_process(&app.snapshot.processes[0])
                .events,
            0
        );
        assert_eq!(
            app.agent_activity_for_process(&app.snapshot.processes[1])
                .events,
            0
        );
    }

    #[test]
    fn workspace_path_matching_uses_path_boundaries() {
        assert!(workspace_paths_overlap("/work/a3s", "/work/a3s/crates/cli"));
        assert!(workspace_paths_overlap("/work/a3s/", "\"/work/a3s\""));
        assert!(!workspace_paths_overlap("/work/a3s", "/work/a3s-other"));
        assert!(!workspace_paths_overlap("-", "/work/a3s"));
    }

    #[test]
    fn finds_recent_agent_events() {
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.events = vec![
            EventRow {
                ts: "recent".into(),
                source: "codex".into(),
                session: Some("sess-a".into()),
                task: Some("task-a".into()),
                pid: None,
                ppid: None,
                kind: "ToolExec".into(),
                message: "git status".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
            EventRow {
                ts: "recent".into(),
                source: "claude".into(),
                session: Some("sess-b".into()),
                task: None,
                pid: None,
                ppid: None,
                kind: "FileAccess".into(),
                message: "README.md".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
        ];

        let events = app.recent_agent_events(AgentKind::Codex);
        let activity = app.agent_activity(AgentKind::Codex);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].message, "git status");
        assert_eq!(activity.events, 1);
        assert_eq!(activity.sessions, 1);
        assert_eq!(activity.tools, 1);
    }

    #[test]
    fn aggregates_agent_activity_across_source_aliases() {
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.events = vec![
            EventRow {
                ts: "recent".into(),
                source: "claude-code".into(),
                session: Some("sess-a".into()),
                task: Some("task-a".into()),
                pid: None,
                ppid: None,
                kind: "ToolExec".into(),
                message: "bash".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
            EventRow {
                ts: "recent".into(),
                source: "claude".into(),
                session: Some("sess-a".into()),
                task: Some("task-b".into()),
                pid: None,
                ppid: None,
                kind: "SecurityAction".into(),
                message: "ptrace".into(),
                details: Vec::new(),
                risk: Risk::High,
            },
            EventRow {
                ts: "recent".into(),
                source: "codex".into(),
                session: Some("other".into()),
                task: None,
                pid: None,
                ppid: None,
                kind: "ToolExec".into(),
                message: "git status".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
        ];

        let activity = app.agent_activity(AgentKind::ClaudeCode);

        assert_eq!(activity.events, 2);
        assert_eq!(activity.sessions, 1);
        assert_eq!(activity.tools, 1);
        assert_eq!(activity.security, 1);
        assert_eq!(activity.files, 0);
        assert_eq!(activity.egress, 0);
        assert_eq!(activity.high_risk, 1);
    }

    #[test]
    fn groups_observer_events_into_session_rows() {
        let events = vec![
            EventRow {
                ts: "recent".into(),
                source: "codex".into(),
                session: Some("sess-a".into()),
                task: Some("task-a".into()),
                pid: None,
                ppid: None,
                kind: "ToolExec".into(),
                message: "git status".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
            EventRow {
                ts: "recent".into(),
                source: "codex".into(),
                session: Some("sess-a".into()),
                task: Some("task-b".into()),
                pid: None,
                ppid: None,
                kind: "SecurityAction".into(),
                message: "ptrace".into(),
                details: Vec::new(),
                risk: Risk::High,
            },
            EventRow {
                ts: "recent".into(),
                source: "collector".into(),
                session: None,
                task: None,
                pid: None,
                ppid: None,
                kind: "warning".into(),
                message: "ignored".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
            EventRow {
                ts: "recent".into(),
                source: "codex".into(),
                session: Some("sess-a".into()),
                task: Some("task-c".into()),
                pid: None,
                ppid: None,
                kind: "FileAccess".into(),
                message: "README.md".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
            EventRow {
                ts: "recent".into(),
                source: "codex".into(),
                session: Some("sess-a".into()),
                task: Some("task-d".into()),
                pid: None,
                ppid: None,
                kind: "Egress".into(),
                message: "example.com".into(),
                details: Vec::new(),
                risk: Risk::Medium,
            },
        ];

        let rows = session_rows(&events);

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].source, "codex");
        assert_eq!(rows[0].session, "sess-a");
        assert_eq!(rows[0].events, 4);
        assert_eq!(rows[0].tools, 1);
        assert_eq!(rows[0].security, 1);
        assert_eq!(rows[0].files, 1);
        assert_eq!(rows[0].egress, 1);
        assert_eq!(rows[0].high_risk, 1);
        assert_eq!(rows[0].risk, Risk::High);
        assert_eq!(rows[0].workspace, "-");
    }

    #[test]
    fn sessions_extract_workspace_from_event_payload() {
        let event = parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"argv":["cargo","test"],"cwd":"/Users/roylin/code/a3s"}}}"#,
        )
        .unwrap();

        let rows = session_rows(&[event]);

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].workspace, "/Users/roylin/code/a3s");
    }

    #[test]
    fn aggregates_llm_model_and_tokens() {
        let event = parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"LlmApi":{"pid":7,"is_request":false,"model":"gpt-4o","prompt_tokens":12,"completion_tokens":34}}}"#,
        )
        .unwrap();
        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.events = vec![event.clone()];

        let activity = app.agent_activity(AgentKind::Codex);
        assert_eq!(activity.llm, 1);
        assert_eq!(activity.prompt_tokens, 12);
        assert_eq!(activity.completion_tokens, 34);
        assert_eq!(activity.total_tokens, 46);
        assert_eq!(activity.model, "gpt-4o");

        let sessions = session_rows(&[event]);
        assert_eq!(sessions[0].llm, 1);
        assert_eq!(sessions[0].total_tokens, 46);
        assert_eq!(sessions[0].model, "gpt-4o");
    }

    #[test]
    fn extracts_llm_provider_latency_and_wire_metrics() {
        let event = parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"provider":"OpenAi","event":{"LlmCall":{"pid":7,"sni":"api.openai.com","peer":"1.2.3.4","req_bytes":1024,"resp_bytes":2048,"latency":{"secs":1,"nanos":500000000},"ttft":{"secs":0,"nanos":250000000}}}}"#,
        )
        .unwrap();
        let network = event_llm_network(&event).unwrap();

        assert_eq!(network.provider.as_deref(), Some("OpenAi"));
        assert_eq!(network.latency_ms, Some(1500));
        assert_eq!(network.ttft_ms, Some(250));
        assert_eq!(network.req_bytes, 1024);
        assert_eq!(network.resp_bytes, 2048);

        let mut app = TopApp::new(TopOptions::default());
        app.snapshot.events = vec![event.clone()];
        let activity = app.agent_activity(AgentKind::Codex);
        assert_eq!(activity.provider, "OpenAi");
        assert_eq!(activity.latency_ms, 1500);
        assert_eq!(activity.latency_samples, 1);
        assert_eq!(activity.ttft_ms, 250);
        assert_eq!(activity.req_bytes, 1024);
        assert_eq!(activity.resp_bytes, 2048);

        let sessions = session_rows(&[event]);
        assert_eq!(sessions[0].provider, "OpenAi");
        assert_eq!(sessions[0].latency_ms, 1500);
        assert_eq!(sessions[0].ttft_ms, 250);
        assert_eq!(sessions[0].req_bytes, 1024);
        assert_eq!(sessions[0].resp_bytes, 2048);
    }

    #[test]
    fn header_summarizes_llm_usage() {
        let mut app = TopApp::new(TopOptions::default());
        app.width = 360;
        app.snapshot.events = vec![parse_observer_line(
            r#"{"identity":{"agent":"codex","session":"sess-a"},"event":{"LlmApi":{"pid":7,"prompt_tokens":1200,"completion_tokens":300}}}"#,
        )
        .unwrap()];

        let plain = a3s_tui::style::strip_ansi(&app.header());

        assert!(plain.contains("llm:1"));
        assert!(plain.contains("tok:1.5K"));
    }

    #[test]
    fn header_summarizes_container_states() {
        let mut app = TopApp::new(TopOptions::default());
        app.width = 360;
        app.snapshot.containers = vec![
            container_row("run", "run", "Up 2 minutes", Some(1.0), Some(1.0)),
            container_row("pause", "pause", "Up 4 minutes (Paused)", None, None),
            container_row("exit", "exit", "Exited (0) 1 hour ago", None, None),
            container_row("dead", "dead", "dead", None, None),
        ];

        let plain = a3s_tui::style::strip_ansi(&app.header());

        assert!(plain.contains("boxes:4 run:1 pause:1 exit:1 dead:1"));
    }

    #[test]
    fn header_uses_shared_status_bar_and_preserves_right_status() {
        let mut app = TopApp::new(TopOptions::default());
        app.width = 44;
        app.paused = true;
        app.snapshot.containers = vec![
            container_row("run", "run", "Up 2 minutes", Some(1.0), Some(1.0)),
            container_row("pause", "pause", "Up 4 minutes (Paused)", None, None),
            container_row("exit", "exit", "Exited (0) 1 hour ago", None, None),
        ];
        app.snapshot.processes = vec![process_row(42, 1, "codex")];

        let rendered = app.header();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.starts_with(" a3s top"), "{plain}");
        assert!(plain.ends_with("paused"), "{plain}");
        assert!(plain.contains('…'), "{plain}");
        assert_eq!(a3s_tui::style::visible_len(&rendered), 44);
    }

    #[test]
    fn tokens_sort_prioritizes_busy_agents_and_sessions() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.sort_by = SortBy::Tokens;
        app.snapshot.processes = vec![
            process_row(10, 1, "claude worker"),
            process_row(20, 1, "codex worker"),
        ];
        app.snapshot.events = vec![
            parse_observer_line(
                r#"{"identity":{"agent":"claude","session":"sess-claude"},"event":{"LlmApi":{"pid":10,"model":"claude-3","prompt_tokens":5,"completion_tokens":5}}}"#,
            )
            .unwrap(),
            parse_observer_line(
                r#"{"identity":{"agent":"codex","session":"sess-codex"},"event":{"LlmApi":{"pid":20,"model":"gpt-4o","prompt_tokens":100,"completion_tokens":50}}}"#,
            )
            .unwrap(),
        ];

        let agents = app.filtered_agents();
        assert_eq!(agents[0].agent, Some(AgentKind::Codex));

        app.tab = Tab::Sessions;
        let sessions = app.filtered_sessions();
        assert_eq!(sessions[0].session, "sess-codex");
        assert_eq!(sessions[0].total_tokens, 150);
    }

    #[test]
    fn sessions_table_renders_grouped_activity() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 180;
        app.snapshot.events = vec![EventRow {
            ts: "recent".into(),
            source: "claude-code".into(),
            session: Some("sess-a".into()),
            task: Some("task-a".into()),
            pid: None,
            ppid: None,
            kind: "ToolExec".into(),
            message: "bash".into(),
            details: Vec::new(),
            risk: Risk::Medium,
        }];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("AGENT"));
        assert!(plain.contains("claude"));
        assert!(plain.contains("sess-a"));
        assert!(plain.contains("task-a"));
        assert!(plain.contains("CWD"));
        assert!(plain.contains("Tool"));
        assert!(plain.contains("SEC"));
        assert!(plain.contains("FILE"));
        assert!(plain.contains("NET"));
    }

    #[test]
    fn sessions_table_renders_llm_usage() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 240;
        app.snapshot.events = vec![parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"provider":"OpenAi","event":{"LlmApi":{"pid":7,"model":"gpt-4o","prompt_tokens":1200,"completion_tokens":3400,"latency_ms":1500}}}"#,
        )
        .unwrap()];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("LLM"));
        assert!(plain.contains("TOK"));
        assert!(plain.contains("MODEL"));
        assert!(plain.contains("4.6K"));
        assert!(plain.contains("gpt-4o"));
        assert!(plain.contains("PROV"));
        assert!(plain.contains("OpenAi"));
        assert!(plain.contains("1.5s"));
    }

    #[test]
    fn agents_tree_groups_sessions_processes_and_events_by_agent() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 360;
        app.snapshot.processes = vec![
            process_row(42, 1, "codex exec task"),
            process_row(100, 42, "bash -lc cargo test"),
        ];
        app.snapshot.events = vec![
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-a"),
                "SecurityAction",
                "ptrace",
                Risk::High,
            ),
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-b"),
                "FileAccess",
                "README.md",
                Risk::Medium,
            ),
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-c"),
                "Egress",
                "example.com",
                Risk::Medium,
            ),
        ];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("Agents"));
        assert!(plain.contains("codex"));
        assert!(plain.contains("Sessions (1)"));
        assert!(plain.contains("Processes (1 system · 1 agent)"));
        assert!(plain.contains("Events (3)"));
        assert!(plain.contains("sess-a"));
        assert!(plain.contains("task-a"));
        assert!(plain.contains("> pid 100"));
        assert!(plain.contains("SecurityAction"));
        assert!(plain.contains("FileAccess"));
        assert!(plain.contains("Egress"));
    }

    #[test]
    fn agents_tree_uses_agent_theme_colors() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 360;
        app.snapshot.processes = vec![
            process_row(42, 1, "claude code task"),
            process_row(84, 1, "codex exec task"),
        ];

        let rendered = app.table();
        let claude_prefix = agent_tree_label(AgentKind::ClaudeCode, "> claude");
        let codex_prefix = agent_tree_label(AgentKind::Codex, "  codex");

        assert!(rendered.contains(claude_prefix.trim_end_matches("\u{1b}[0m")));
        assert!(rendered.contains(codex_prefix.trim_end_matches("\u{1b}[0m")));
        assert!(a3s_tui::style::strip_ansi(&rendered).contains("codex"));
    }

    #[test]
    fn agents_filter_uses_working_directory() {
        let mut row = process_row(42, 1, "codex exec task");
        row.cwd = Some("/Users/roylin/code/a3s".into());
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 280;
        app.snapshot.processes = vec![row];

        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("Processes (0 system · 1 agent)"));

        app.filter = "code/a3s".into();
        assert_eq!(app.filtered_agents().len(), 1);
    }

    #[test]
    fn agents_filter_matches_related_sessions_and_events() {
        let mut codex = process_row(42, 1, "codex exec task");
        codex.cwd = Some("/work/a3s".into());
        let mut claude = process_row(84, 1, "claude code other");
        claude.cwd = Some("/work/other".into());
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.snapshot.processes = vec![codex, claude];
        app.snapshot.events = vec![
            parse_observer_line(
                r#"{"identity":{"agent":"codex","session":"sess-target","task":"task-target"},"provider":"openai","event":{"LlmCall":{"pid":42,"model":"gpt-5","prompt_tokens":10,"completion_tokens":5,"cwd":"/work/a3s"}}}"#,
            )
            .unwrap(),
            parse_observer_line(
                r#"{"identity":{"agent":"claude","session":"other"},"event":{"ToolExec":{"pid":84,"argv":["pwd"],"cwd":"/work/other"}}}"#,
            )
            .unwrap(),
        ];

        app.filter = "sess-target".into();
        assert_eq!(app.filtered_agents().len(), 1);
        assert_eq!(app.filtered_agents()[0].pid, 42);

        app.filter = "gpt-5".into();
        assert_eq!(app.filtered_agents().len(), 1);
        assert_eq!(app.filtered_agents()[0].pid, 42);

        app.filter = "prompt_tokens".into();
        assert_eq!(app.filtered_agents().len(), 1);
        assert_eq!(app.filtered_agents()[0].pid, 42);

        app.filter = "other".into();
        assert_eq!(app.filtered_agents().len(), 1);
        assert_eq!(app.filtered_agents()[0].pid, 84);
    }

    #[test]
    fn process_tree_usage_sums_descendants() {
        let rows = vec![
            ProcessRow {
                cpu_pct: 1.0,
                mem_pct: 2.0,
                ..process_row(42, 1, "codex exec task")
            },
            ProcessRow {
                cpu_pct: 30.0,
                mem_pct: 4.0,
                ..process_row(100, 42, "cargo test")
            },
            ProcessRow {
                cpu_pct: 5.0,
                mem_pct: 1.0,
                ..process_row(101, 100, "rustc")
            },
            ProcessRow {
                cpu_pct: 99.0,
                mem_pct: 99.0,
                ..process_row(200, 1, "other")
            },
        ];

        let usage = process_tree_usage(&rows, 42);

        assert_eq!(usage.descendants, 2);
        assert_eq!(usage.cpu_pct, 36.0);
        assert_eq!(usage.mem_pct, 7.0);
    }

    #[test]
    fn agents_tree_processes_show_agent_started_system_processes() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 260;
        app.snapshot.processes = vec![
            process_row(42, 1, "codex exec task"),
            ProcessRow {
                cpu_pct: 30.0,
                mem_pct: 4.0,
                ..process_row(100, 42, "bash -lc cargo test")
            },
            ProcessRow {
                cpu_pct: 5.0,
                mem_pct: 1.0,
                ..process_row(101, 100, "rustc")
            },
            process_row(200, 1, "unrelated"),
        ];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("Processes (2 system · 1 agent)"));
        assert!(plain.contains("> pid 100 · ppid 42 · CPU 30.0% · MEM 4.0%"));
        assert!(plain.contains("pid 101 · ppid 100 · CPU 5.0% · MEM 1.0%"));
        assert!(!plain.contains("> pid 42"));
        assert!(!plain.contains("pid 200"));
    }

    #[test]
    fn agents_tree_uses_process_tree_resource_totals() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 220;
        app.snapshot.processes = vec![
            ProcessRow {
                cpu_pct: 1.0,
                mem_pct: 2.0,
                ..process_row(42, 1, "codex exec task")
            },
            ProcessRow {
                cpu_pct: 30.0,
                mem_pct: 4.0,
                ..process_row(100, 42, "cargo test")
            },
        ];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("31.0"));
        assert!(plain.contains("6.0"));
        assert!(plain.contains("children 1"));
    }

    #[test]
    fn agents_tree_selection_can_land_on_session_only_agent() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 220;
        app.filter = "sess-a".into();
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "LlmCall",
            "thinking",
            Risk::Low,
        )];

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert_eq!(app.visible_len(), 1);
        assert!(plain.contains("> codex"));
        assert!(plain.contains("S1 P0 E1"));
        assert!(plain.contains("Sessions (1)"));
        assert!(plain.contains("Processes (0 system · 0 agent)"));
        assert!(plain.contains("Events (1)"));
    }

    #[test]
    fn o_focuses_session_only_agent_from_agents_tree() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.filter = "sess-a".into();
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "ToolExec",
            "bash",
            Risk::Medium,
        )];

        app.handle_key(KeyEvent {
            code: KeyCode::Char('o'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.tab, Tab::Sessions);
        assert_eq!(
            app.focused_session,
            Some(SessionFocus {
                source: "codex".into(),
                session: "sess-a".into(),
            })
        );
    }

    #[test]
    fn agents_tree_renders_separate_resource_trends() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 220;
        app.snapshot.processes = vec![
            ProcessRow {
                cpu_pct: 1.0,
                mem_pct: 2.0,
                ..process_row(42, 1, "codex exec task")
            },
            ProcessRow {
                cpu_pct: 30.0,
                mem_pct: 4.0,
                ..process_row(100, 42, "cargo test")
            },
        ];
        app.history.insert(
            agent_tree_history_key(42),
            MetricHistory {
                cpu: vec![1.0, 31.0],
                mem: vec![2.0, 6.0],
                ..MetricHistory::default()
            },
        );

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("Resources"));
        assert!(plain.contains("CPU  31.0%"));
        assert!(plain.contains("MEM   6.0%"));
    }

    #[test]
    fn agents_tree_does_not_double_count_nested_agent_processes() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 220;
        app.snapshot.processes = vec![
            ProcessRow {
                cpu_pct: 1.0,
                mem_pct: 2.0,
                ..process_row(42, 1, "codex exec parent")
            },
            ProcessRow {
                cpu_pct: 30.0,
                mem_pct: 4.0,
                ..process_row(100, 42, "codex exec child")
            },
            ProcessRow {
                cpu_pct: 5.0,
                mem_pct: 1.0,
                ..process_row(101, 100, "cargo test")
            },
        ];

        let group = app
            .agent_tree_groups()
            .into_iter()
            .find(|group| group.agent == AgentKind::Codex)
            .unwrap();

        assert_eq!(group.usage.cpu_pct, 36.0);
        assert_eq!(group.usage.mem_pct, 7.0);
        assert_eq!(group.usage.descendants, 2);
    }

    #[test]
    fn agent_cpu_sort_uses_process_tree_totals() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.sort_by = SortBy::Cpu;
        app.snapshot.processes = vec![
            ProcessRow {
                cpu_pct: 1.0,
                ..process_row(10, 1, "codex exec slow-root")
            },
            ProcessRow {
                cpu_pct: 50.0,
                ..process_row(11, 10, "cargo test")
            },
            ProcessRow {
                cpu_pct: 20.0,
                ..process_row(20, 1, "claude")
            },
        ];

        let rows = app.filtered_agents();

        assert_eq!(rows[0].pid, 10);
    }

    #[test]
    fn risk_filter_applies_to_events_and_sessions() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.risk_filter = RiskFilter::High;
        app.snapshot.events = vec![
            event_row(
                "codex",
                Some("safe"),
                Some("task-a"),
                "ToolExec",
                "git status",
                Risk::Medium,
            ),
            event_row(
                "codex",
                Some("danger"),
                Some("task-b"),
                "SecurityAction",
                "ptrace",
                Risk::High,
            ),
        ];

        let events = app.filtered_events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].session.as_deref(), Some("danger"));

        app.tab = Tab::Sessions;
        let sessions = app.filtered_sessions();
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].session, "danger");
    }

    #[test]
    fn kind_filter_applies_to_events_sessions_and_session_focus() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.kind_filter = KindFilter::Security;
        app.snapshot.events = vec![
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-a"),
                "ToolExec",
                "git status",
                Risk::Medium,
            ),
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-b"),
                "SecurityAction",
                "ptrace",
                Risk::High,
            ),
            event_row(
                "codex",
                Some("sess-b"),
                Some("task-c"),
                "FileAccess",
                "README.md",
                Risk::Medium,
            ),
        ];

        let events = app.filtered_events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].kind, "SecurityAction");

        app.tab = Tab::Sessions;
        let sessions = app.filtered_sessions();
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].session, "sess-a");
        assert_eq!(sessions[0].events, 1);
        assert_eq!(sessions[0].last_kind, "SecurityAction");

        app.focused_session = Some(SessionFocus {
            source: "codex".into(),
            session: "sess-a".into(),
        });
        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("ptrace"));
        assert!(!plain.contains("git status"));
    }

    #[test]
    fn high_risk_filter_keeps_agents_with_high_risk_activity() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.risk_filter = RiskFilter::High;
        app.snapshot.processes = vec![process_row(42, 1, "codex exec task")];
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "SecurityAction",
            "ptrace",
            Risk::High,
        )];

        let agents = app.filtered_agents();
        assert_eq!(agents.len(), 1);
        assert_eq!(agents[0].pid, 42);
    }

    #[test]
    fn filter_keys_cycle_and_reset_selection() {
        let mut app = TopApp::new(TopOptions::default());
        app.selected = 3;
        app.detail = true;

        app.handle_key(KeyEvent {
            code: KeyCode::Char('!'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.risk_filter, RiskFilter::Medium);
        assert_eq!(app.selected, 0);
        assert!(!app.detail);

        app.selected = 2;
        app.detail = true;
        app.handle_key(KeyEvent {
            code: KeyCode::Char('g'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.kind_filter, KindFilter::Tool);
        assert_eq!(app.selected, 0);
        assert!(!app.detail);
    }

    #[test]
    fn filter_editor_esc_clears_and_ctrl_keys_edit() {
        let mut app = TopApp::new(TopOptions::default());
        let key = |code| KeyEvent {
            code,
            modifiers: KeyModifiers::empty(),
        };
        let ctrl = |c| KeyEvent {
            code: KeyCode::Char(c),
            modifiers: KeyModifiers::CONTROL,
        };

        app.filter = "codex".into();
        app.selected = 4;
        app.detail = true;
        app.handle_key(key(KeyCode::Char('/')));
        app.handle_key(ctrl('u'));
        app.handle_key(key(KeyCode::Char('a')));
        app.handle_key(key(KeyCode::Char('p')));
        app.handle_key(key(KeyCode::Char('i')));
        app.handle_key(key(KeyCode::Esc));

        assert_eq!(app.filter, "");
        assert!(!app.editing_filter);
        assert_eq!(app.selected, 0);
        assert!(!app.detail);
        assert!(app.filter_before_edit.is_none());

        app.handle_key(key(KeyCode::Char('/')));
        app.handle_key(ctrl('u'));
        for c in "agent run".chars() {
            app.handle_key(key(KeyCode::Char(c)));
        }
        app.handle_key(ctrl('w'));
        app.handle_key(key(KeyCode::Enter));

        assert_eq!(app.filter, "agent ");
        assert!(!app.editing_filter);
        assert!(app.filter_before_edit.is_none());
    }

    #[test]
    fn sort_key_opens_select_panel_and_applies_choice() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.sort_by = SortBy::Cpu;

        app.handle_key(KeyEvent {
            code: KeyCode::Char('s'),
            modifiers: KeyModifiers::empty(),
        });

        let panel = app.sort_panel.as_ref().unwrap();
        assert_eq!(panel.select.selected_index(), 0);
        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("sort by"));
        assert!(plain.contains("state"));
        assert!(plain.contains("uptime"));
        assert!(!plain.contains("tokens"));

        app.handle_key(KeyEvent {
            code: KeyCode::Down,
            modifiers: KeyModifiers::empty(),
        });
        app.handle_key(KeyEvent {
            code: KeyCode::Enter,
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.sort_by, SortBy::Mem);
        assert!(app.sort_panel.is_none());
    }

    #[test]
    fn sort_panel_accepts_number_shortcuts() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.sort_by = SortBy::Cpu;
        app.open_sort_panel();

        let plain = a3s_tui::style::strip_ansi(&app.sort_panel_view());
        assert!(plain.contains("> 1 cpu"));
        assert!(plain.contains("  2 mem"));
        assert!(plain.contains("  3 net"));

        app.handle_key(KeyEvent {
            code: KeyCode::Char('4'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.sort_by, SortBy::Block);
        assert!(app.sort_panel.is_none());
    }

    #[test]
    fn sort_and_connector_panels_use_shared_menu_panel_and_fit_width() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 34;

        app.open_sort_panel();
        let sort_rendered = app.sort_panel_view();
        let sort_plain = a3s_tui::style::strip_ansi(&sort_rendered);
        assert!(sort_plain.contains("sort by"), "{sort_plain}");
        assert!(sort_plain.contains("> 1 cpu"), "{sort_plain}");
        assert!(
            sort_rendered.contains("\x1b["),
            "selected sort row should be styled"
        );
        assert!(
            sort_rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 34),
            "{sort_plain}"
        );

        app.open_connector_panel();
        let connector_rendered = app.connector_panel_view();
        let connector_plain = a3s_tui::style::strip_ansi(&connector_rendered);
        assert!(
            connector_plain.contains("container connector"),
            "{connector_plain}"
        );
        assert!(connector_plain.contains("> 1 a3s-box"), "{connector_plain}");
        assert!(
            connector_rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 34),
            "{connector_plain}"
        );
    }

    #[test]
    fn sort_panel_choices_are_scoped_to_current_tab() {
        let mut agents = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        agents.open_sort_panel();
        assert_eq!(
            agents.sort_panel.as_ref().unwrap().choices,
            vec![
                SortBy::Cpu,
                SortBy::Mem,
                SortBy::Net,
                SortBy::Pids,
                SortBy::Name,
                SortBy::Tokens,
            ]
        );

        let mut processes = TopApp::new(TopOptions {
            tab: Tab::Processes,
            ..TopOptions::default()
        });
        processes.open_sort_panel();
        let choices = &processes.sort_panel.as_ref().unwrap().choices;
        assert!(choices.contains(&SortBy::Id));
        assert!(!choices.contains(&SortBy::Tokens));

        let mut events = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        events.open_sort_panel();
        assert!(events.sort_panel.is_none());
        assert!(events.note.as_deref().unwrap().contains("newest-first"));
    }

    #[test]
    fn esc_closes_sort_panel_without_changing_sort() {
        let mut app = TopApp::new(TopOptions::default());
        app.sort_by = SortBy::Tokens;

        app.handle_key(KeyEvent {
            code: KeyCode::Char('s'),
            modifiers: KeyModifiers::empty(),
        });
        app.handle_key(KeyEvent {
            code: KeyCode::Esc,
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.sort_by, SortBy::Tokens);
        assert!(app.sort_panel.is_none());
    }

    #[test]
    fn connector_key_opens_select_panel_and_switches_runtime() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.focused_container = Some("abcdef".into());
        app.container_processes = Some(ContainerProcessPanel {
            container_id: "abcdef".into(),
            container_name: "app".into(),
            rows: Vec::new(),
            scroll: 0,
            error: None,
            loading: false,
        });

        app.handle_key(KeyEvent {
            code: KeyCode::Char('C'),
            modifiers: KeyModifiers::SHIFT,
        });

        let panel = app.connector_panel.as_ref().unwrap();
        assert_eq!(panel.select.selected_index(), 0);
        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("container connector"));
        assert!(plain.contains("a3s-box"));
        assert!(plain.contains("docker"));
        assert!(plain.contains("runc"));

        app.handle_key(KeyEvent {
            code: KeyCode::Down,
            modifiers: KeyModifiers::empty(),
        });
        let cmd = app.handle_key(KeyEvent {
            code: KeyCode::Enter,
            modifiers: KeyModifiers::empty(),
        });

        assert!(cmd.is_some());
        assert_eq!(app.connector, ContainerConnector::Docker);
        assert!(app.connector_panel.is_none());
        assert!(app.focused_container.is_none());
        assert!(app.container_processes.is_none());
    }

    #[test]
    fn connector_panel_accepts_number_shortcuts() {
        let mut app = TopApp::new(TopOptions::default());
        app.open_connector_panel();

        let plain = a3s_tui::style::strip_ansi(&app.connector_panel_view());
        assert!(plain.contains("> 1 a3s-box"));
        assert!(plain.contains("  2 docker"));
        assert!(plain.contains("  3 runc"));

        let cmd = app.handle_key(KeyEvent {
            code: KeyCode::Char('3'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(cmd.is_some());
        assert_eq!(app.connector, ContainerConnector::RunC);
        assert!(app.connector_panel.is_none());
    }

    #[test]
    fn esc_closes_connector_panel_without_changing_runtime() {
        let mut app = TopApp::new(TopOptions::default());
        app.connector = ContainerConnector::RunC;

        app.handle_key(KeyEvent {
            code: KeyCode::Char('C'),
            modifiers: KeyModifiers::SHIFT,
        });
        app.handle_key(KeyEvent {
            code: KeyCode::Esc,
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.connector, ContainerConnector::RunC);
        assert!(app.connector_panel.is_none());
    }

    #[test]
    fn focused_session_renders_event_stream_without_unrelated_events() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            ..TopOptions::default()
        });
        app.width = 72;
        app.snapshot.events = vec![
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-a"),
                "ToolExec",
                "git status",
                Risk::Medium,
            ),
            event_row(
                "codex",
                Some("sess-b"),
                Some("task-b"),
                "ToolExec",
                "npm test",
                Risk::Medium,
            ),
            event_row(
                "claude-code",
                Some("sess-a"),
                Some("task-c"),
                "FileAccess",
                "README.md",
                Risk::Medium,
            ),
        ];
        app.focused_session = Some(SessionFocus {
            source: "codex".into(),
            session: "sess-a".into(),
        });

        let rendered = app.table();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("session view codex"));
        assert!(plain.contains("sess-a"));
        assert!(plain.contains("task task-a"), "{plain}");
        assert!(plain.contains("git status"));
        assert!(!plain.contains("npm test"));
        assert!(!plain.contains("README.md"));
        assert!(rendered.contains("\x1b["), "session focus should be styled");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 72),
            "{plain}"
        );
    }

    #[test]
    fn focused_session_renders_event_process_scope() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            ..TopOptions::default()
        });
        app.width = 180;
        app.snapshot.events = vec![parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"pid":77,"ppid":42,"argv":["git","status"],"cwd":"/tmp/a3s"}}}"#,
        )
        .unwrap()];
        app.focused_session = Some(SessionFocus {
            source: "codex".into(),
            session: "sess-a".into(),
        });

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("PID"));
        assert!(plain.contains("PPID"));
        assert!(plain.contains("77"));
        assert!(plain.contains("42"));
    }

    #[test]
    fn o_focuses_selected_session() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            ..TopOptions::default()
        });
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "ToolExec",
            "git status",
            Risk::Medium,
        )];

        app.handle_key(KeyEvent {
            code: KeyCode::Char('o'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(
            app.focused_session,
            Some(SessionFocus {
                source: "codex".into(),
                session: "sess-a".into(),
            })
        );
        assert_eq!(app.visible_len(), 1);

        app.handle_key(KeyEvent {
            code: KeyCode::Char('o'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.focused_session.is_none());
    }

    #[test]
    fn o_on_event_focuses_event_session() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.snapshot.events = vec![
            event_row(
                "codex",
                Some("sess-a"),
                Some("task-a"),
                "ToolExec",
                "git status",
                Risk::Medium,
            ),
            event_row(
                "codex",
                Some("sess-b"),
                Some("task-b"),
                "ToolExec",
                "npm test",
                Risk::Medium,
            ),
        ];

        app.handle_key(KeyEvent {
            code: KeyCode::Char('o'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.tab, Tab::Sessions);
        assert_eq!(
            app.focused_session,
            Some(SessionFocus {
                source: "codex".into(),
                session: "sess-a".into(),
            })
        );

        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("session view codex"));
        assert!(plain.contains("git status"));
        assert!(!plain.contains("npm test"));
    }

    #[test]
    fn o_on_non_agent_event_sets_note() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.snapshot.events = vec![event_row(
            "collector",
            None,
            None,
            "warning",
            "docker unavailable",
            Risk::Medium,
        )];

        app.handle_key(KeyEvent {
            code: KeyCode::Char('o'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.tab, Tab::Events);
        assert!(app.focused_session.is_none());
        assert_eq!(
            app.note.as_deref(),
            Some("event focus is available for coding-agent events")
        );
    }

    #[test]
    fn event_detail_shows_identity_scope_and_focus_hint() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.detail = true;
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "SecurityAction",
            "action=ptrace pid=3 target=other",
            Risk::High,
        )];

        let plain = a3s_tui::style::strip_ansi(&app.details());

        assert!(plain.contains("event codex"));
        assert!(plain.contains("SecurityAction"));
        assert!(plain.contains("session sess-a task task-a"));
        assert!(plain.contains("source codex · session sess-a · task task-a"));
        assert!(plain.contains("actions o session focus"));
    }

    #[test]
    fn event_detail_shows_payload_fields() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Events,
            ..TopOptions::default()
        });
        app.detail = true;
        app.snapshot.events = vec![parse_observer_line(
            r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"pid":7,"argv":["git","status"],"cwd":"/tmp/a3s"}}}"#,
        )
        .unwrap()];

        let plain = a3s_tui::style::strip_ansi(&app.details());

        assert!(plain.contains("detail argv [\"git\",\"status\"]"));
        assert!(plain.contains("detail cwd /tmp/a3s"));
        assert!(plain.contains("detail pid 7"));
    }

    #[test]
    fn session_focus_detail_shows_selected_event() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            ..TopOptions::default()
        });
        app.detail = true;
        app.focused_session = Some(SessionFocus {
            source: "codex".into(),
            session: "sess-a".into(),
        });
        app.snapshot.events = vec![event_row(
            "codex",
            Some("sess-a"),
            Some("task-a"),
            "ToolExec",
            "argv=[\"git\",\"status\"]",
            Risk::Medium,
        )];

        let plain = a3s_tui::style::strip_ansi(&app.details());

        assert!(plain.contains("event codex"));
        assert!(plain.contains("ToolExec"));
        assert!(plain.contains("argv=[\"git\",\"status\"]"));
        assert!(plain.contains("actions o session focus"));
    }

    #[test]
    fn container_menu_reflects_running_state() {
        let mut running = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0));
        running.ports = "8080:80".into();
        let stopped = container_row("123456", "job", "Exited (0) 1 hour ago", None, None);
        let paused = container_row(
            "999999",
            "db",
            "Up 4 minutes (Paused)",
            Some(0.0),
            Some(1.0),
        );

        let running_actions = container_menu_items(&running)
            .into_iter()
            .map(|item| item.action)
            .collect::<Vec<_>>();
        let stopped_actions = container_menu_items(&stopped)
            .into_iter()
            .map(|item| item.action)
            .collect::<Vec<_>>();
        let paused_actions = container_menu_items(&paused)
            .into_iter()
            .map(|item| item.action)
            .collect::<Vec<_>>();

        assert!(running_actions.contains(&ContainerMenuAction::ExecShell));
        assert!(running_actions.contains(&ContainerMenuAction::OpenBrowser));
        assert!(running_actions.contains(&ContainerMenuAction::Pause));
        assert!(running_actions.contains(&ContainerMenuAction::Stop));
        assert!(!running_actions.contains(&ContainerMenuAction::Remove));
        assert!(stopped_actions.contains(&ContainerMenuAction::Start));
        assert!(stopped_actions.contains(&ContainerMenuAction::Remove));
        assert!(paused_actions.contains(&ContainerMenuAction::Unpause));
        assert!(!paused_actions.contains(&ContainerMenuAction::ExecShell));
    }

    #[test]
    fn runc_menu_hides_docker_only_actions() {
        let mut row = container_row("abcdef", "app", "running", Some(1.0), Some(2.0));
        row.connector = ContainerConnector::RunC;

        let actions = container_menu_items(&row)
            .into_iter()
            .map(|item| item.action)
            .collect::<Vec<_>>();

        assert!(actions.contains(&ContainerMenuAction::Focus));
        assert!(actions.contains(&ContainerMenuAction::Pause));
        assert!(actions.contains(&ContainerMenuAction::Stop));
        assert!(!actions.contains(&ContainerMenuAction::Logs));
        assert!(!actions.contains(&ContainerMenuAction::ExecShell));
        assert!(!actions.contains(&ContainerMenuAction::Restart));
    }

    #[test]
    fn w_opens_first_published_container_web_port() {
        let external_action = Arc::new(Mutex::new(None));
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            external_action: external_action.clone(),
            ..TopOptions::default()
        });
        let mut row = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0));
        row.ports = "8080:80".into();
        app.snapshot.containers = vec![row];

        let cmd = app.handle_key(KeyEvent {
            code: KeyCode::Char('w'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(cmd.is_some());
        let action = external_action.lock().unwrap().clone();
        assert!(matches!(
            action,
            Some(ExternalAction::OpenBrowser { url, name })
                if url == "http://localhost:8080/" && name == "app"
        ));
    }

    #[test]
    fn w_without_published_port_sets_note() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![container_row(
            "abcdef",
            "app",
            "Up 2 minutes",
            Some(1.0),
            Some(2.0),
        )];

        let cmd = app.handle_key(KeyEvent {
            code: KeyCode::Char('w'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(cmd.is_none());
        assert_eq!(
            app.note.as_deref(),
            Some("app has no published web port to open")
        );
    }

    #[test]
    fn a3s_box_menu_exposes_box_runtime_actions() {
        let mut row = container_row("abcdef", "app", "running", Some(1.0), Some(2.0));
        row.connector = ContainerConnector::A3sBox;
        row.ports = "8080:80".into();

        let actions = container_menu_items(&row)
            .into_iter()
            .map(|item| item.action)
            .collect::<Vec<_>>();

        assert!(actions.contains(&ContainerMenuAction::Logs));
        assert!(actions.contains(&ContainerMenuAction::ExecShell));
        assert!(actions.contains(&ContainerMenuAction::OpenBrowser));
        assert!(actions.contains(&ContainerMenuAction::Restart));
        assert!(actions.contains(&ContainerMenuAction::Pause));
        assert!(actions.contains(&ContainerMenuAction::Stop));
    }

    #[test]
    fn enter_opens_container_menu_and_x_toggles_detail() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![container_row(
            "abcdef",
            "app",
            "Up 2 minutes",
            Some(1.0),
            Some(2.0),
        )];

        app.handle_key(KeyEvent {
            code: KeyCode::Enter,
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.container_menu.is_some());

        app.container_menu = None;
        app.handle_key(KeyEvent {
            code: KeyCode::Char('x'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.detail);
    }

    #[test]
    fn container_detail_uses_shared_detail_panel_and_fits_width() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 64;
        app.detail = true;
        let mut row = container_row("abcdef123456", "app", "Up 2 minutes", Some(1.0), Some(2.0));
        row.ports = "0.0.0.0:3000->3000/tcp".into();
        row.net_io = "1.00KiB / 2.00KiB".into();
        row.block_io = "4.00KiB / 8.00KiB".into();
        app.snapshot.containers = vec![row];

        let rendered = app.details();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("container app (abcdef123456)"), "{plain}");
        assert!(plain.contains("image img"), "{plain}");
        assert!(plain.contains("status Up 2 minutes"), "{plain}");
        assert!(plain.contains("cpu 1.0%"), "{plain}");
        assert!(plain.contains("actions"), "{plain}");
        assert!(plain.contains("Enter menu"), "{plain}");
        assert!(
            rendered.contains("\x1b["),
            "container detail should be styled"
        );
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 64),
            "{plain}"
        );
    }

    #[test]
    fn container_menu_r_shortcut_restarts_only_inside_menu() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![container_row(
            "abcdef123456",
            "app",
            "Up 2 minutes",
            Some(1.0),
            Some(2.0),
        )];

        app.open_container_menu();
        let plain = a3s_tui::style::strip_ansi(&app.container_menu_view());
        assert!(plain.contains("r  Restart container"));

        app.handle_key(KeyEvent {
            code: KeyCode::Char('r'),
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.container_menu.is_none());
        assert!(!app.reverse_sort);
        assert!(matches!(
            app.confirm,
            Some(Action::RestartContainer(_, _, ref name)) if name.contains("app")
        ));
    }

    #[test]
    fn container_menu_view_uses_shared_menu_panel_and_fits_width() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 42;
        app.height = 12;
        app.snapshot.containers = vec![container_row(
            "abcdef1234567890",
            "very-long-container-name",
            "Up 2 minutes",
            Some(1.0),
            Some(2.0),
        )];

        app.open_container_menu();
        let rendered = app.container_menu_view();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("container menu"), "{plain}");
        assert!(plain.contains("image"), "{plain}");
        assert!(plain.contains("status"), "{plain}");
        assert!(plain.contains("r  Restart container"), "{plain}");
        assert!(rendered.contains("\x1b["), "selected row should be styled");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 42),
            "{plain}"
        );
    }

    #[test]
    fn container_menu_shortcuts_follow_available_actions() {
        let running = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0));
        let mut web = running.clone();
        web.ports = "8080:80".into();
        let stopped = container_row("123456", "job", "Exited (0) 1 hour ago", None, None);
        let paused = container_row(
            "999999",
            "db",
            "Up 4 minutes (Paused)",
            Some(0.0),
            Some(1.0),
        );

        let running_keys = container_menu_items(&running)
            .into_iter()
            .map(|item| (item.key, item.action))
            .collect::<Vec<_>>();
        let web_keys = container_menu_items(&web)
            .into_iter()
            .map(|item| (item.key, item.action))
            .collect::<Vec<_>>();
        let stopped_keys = container_menu_items(&stopped)
            .into_iter()
            .map(|item| (item.key, item.action))
            .collect::<Vec<_>>();
        let paused_keys = container_menu_items(&paused)
            .into_iter()
            .map(|item| (item.key, item.action))
            .collect::<Vec<_>>();

        assert!(running_keys.contains(&('o', ContainerMenuAction::Focus)));
        assert!(running_keys.contains(&('l', ContainerMenuAction::Logs)));
        assert!(running_keys.contains(&('e', ContainerMenuAction::ExecShell)));
        assert!(!running_keys.contains(&('w', ContainerMenuAction::OpenBrowser)));
        assert!(web_keys.contains(&('w', ContainerMenuAction::OpenBrowser)));
        assert!(running_keys.contains(&('p', ContainerMenuAction::Pause)));
        assert!(running_keys.contains(&('s', ContainerMenuAction::Stop)));
        assert!(running_keys.contains(&('r', ContainerMenuAction::Restart)));
        assert!(stopped_keys.contains(&('s', ContainerMenuAction::Start)));
        assert!(stopped_keys.contains(&('d', ContainerMenuAction::Remove)));
        assert!(paused_keys.contains(&('u', ContainerMenuAction::Unpause)));
        assert!(paused_keys.contains(&('s', ContainerMenuAction::Stop)));
    }

    #[test]
    fn focused_container_renders_single_view() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.height = 42;
        app.width = 96;
        let mut row = container_row("abcdef", "app", "Up 2 minutes", Some(42.0), Some(30.0));
        row.inspect = ContainerInspect {
            health: "healthy".into(),
            restarts: "2".into(),
            restart_policy: "unless-stopped".into(),
            created: "2026-06-26 08:00:00".into(),
            started: "2026-06-26 08:01:00".into(),
            exit: "-".into(),
            mounts: "1 mount: /data(rw)".into(),
            env: "4 vars".into(),
            labels: "1 label: com.example.role".into(),
            networks: "1 net: bridge 172.17.0.2".into(),
        };
        row.cpu_count = Some(2);
        row.net_io = "1.00KiB / 2.00KiB".into();
        row.block_io = "4.00KiB / 8.00KiB".into();
        row.pids = "7".into();
        row.ports = "0.0.0.0:3000->3000/tcp".into();
        app.snapshot.containers = vec![row];
        app.focused_container = Some("abcdef".into());

        let rendered = app.table();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("single view app"));
        assert!(plain.contains("status Up 2 minutes"));
        assert!(plain.contains("health healthy"));
        assert!(plain.contains("image"));
        assert!(plain.contains("CPU"));
        assert!(plain.contains("MEM"));
        assert!(plain.contains("NET trend"));
        assert!(plain.contains("IO trend"));
        assert!(plain.contains("RESOURCE"));
        assert!(plain.contains("NET I/O"));
        assert!(plain.contains("CPUS"));
        assert!(plain.contains("PIDS"));
        assert!(plain.contains("0.0.0.0:3000->3000/tcp"));
        assert!(plain.contains("HEALTH"));
        assert!(plain.contains("healthy"));
        assert!(plain.contains("RESTART POLICY"));
        assert!(plain.contains("unless-stopped"));
        assert!(!plain.contains("CONTAINER"));
        assert!(
            rendered.contains("\x1b["),
            "container focus should be styled"
        );
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 96),
            "{plain}"
        );
    }

    #[test]
    fn focused_container_footer_surfaces_ctop_actions() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.focused_container = Some("abcdef".into());

        assert_eq!(
            app.footer_help_text(),
            "Esc list · ↑/↓ proc · Enter actions(start/stop/restart/pause) · l logs · e shell · w browser · K stop"
        );

        app.container_menu = Some(ContainerMenu {
            container: container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0)),
            items: vec![ContainerMenuItem {
                action: ContainerMenuAction::Restart,
                key: 'r',
                label: "Restart container".into(),
            }],
            select: Select::new(vec!["Restart container"]),
        });
        assert_eq!(
            app.footer_help_text(),
            "Enter run action · ↑/↓ select · Esc close menu"
        );
    }

    #[test]
    fn single_container_target_matches_name_cid_short_cid_and_prefix() {
        let row = container_row(
            "abcdef1234567890",
            "web",
            "Up 2 minutes",
            Some(1.0),
            Some(2.0),
        );

        assert!(container_matches_query(&row, "web"));
        assert!(container_matches_query(&row, "abcdef1234567890"));
        assert!(container_matches_query(&row, "abcdef123456"));
        assert!(container_matches_query(&row, "abcdef"));
        assert!(!container_matches_query(&row, "api"));

        let mut app = TopApp::new(TopOptions {
            container_query: Some("web".into()),
            ..TopOptions::default()
        });
        app.snapshot.containers = vec![
            row.clone(),
            container_row(
                "1111111234567890",
                "api",
                "Up 1 minute",
                Some(1.0),
                Some(2.0),
            ),
        ];

        let rows = app.filtered_containers();
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].name, "web");
        assert_eq!(top_snapshot_json(&app, 0)["config"]["container"], "web");
    }

    #[test]
    fn single_container_target_collects_name_and_id_candidates() {
        assert_eq!(container_target_filters(None), vec![None]);
        assert_eq!(container_target_filters(Some("  ")), vec![None]);
        assert_eq!(
            container_target_filters(Some("web")),
            vec![Some("name=web".into()), Some("id=web".into())]
        );
    }

    #[test]
    fn dedupes_container_candidates_from_runtime_filters() {
        let mut rows = vec![
            container_row(
                "abcdef1234567890",
                "web",
                "Up 2 minutes",
                Some(1.0),
                Some(2.0),
            ),
            container_row(
                "abcdef1234567890",
                "web",
                "Up 2 minutes",
                Some(1.0),
                Some(2.0),
            ),
            container_row(
                "1111111234567890",
                "api",
                "Up 1 minute",
                Some(1.0),
                Some(2.0),
            ),
        ];

        dedupe_container_rows(&mut rows);

        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].name, "web");
        assert_eq!(rows[1].name, "api");
    }

    #[test]
    fn containers_table_renders_and_filters_ports() {
        let mut row = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0));
        row.ports = "0.0.0.0:3000->3000/tcp".into();
        row.inspect.health = "healthy".into();
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            config: all_columns_config(),
            ..TopOptions::default()
        });
        app.width = 260;
        app.snapshot.containers = vec![row];

        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("PORTS"));
        assert!(plain.contains("HEALTH"));
        assert!(plain.contains("CID"));
        assert!(plain.contains("0.0.0.0:3000->3000/tcp"));
        assert!(plain.contains("healthy"));

        app.filter = "3000".into();
        assert_eq!(app.filtered_containers().len(), 1);
        app.filter = "healthy".into();
        assert_eq!(app.filtered_containers().len(), 1);
        app.filter = "abcdef".into();
        assert_eq!(app.filtered_containers().len(), 1);
    }

    #[test]
    fn focused_container_renders_process_table() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 160;
        app.height = 32;
        app.snapshot.containers = vec![container_row(
            "abcdef",
            "app",
            "Up 2 minutes",
            Some(42.0),
            Some(30.0),
        )];
        app.focused_container = Some("abcdef".into());
        app.container_processes = Some(ContainerProcessPanel {
            container_id: "abcdef".into(),
            container_name: "app".into(),
            rows: vec![ContainerProcessRow {
                pid: "123".into(),
                ppid: "1".into(),
                cpu_pct: Some(2.5),
                mem_pct: Some(0.7),
                elapsed: "00:01".into(),
                command: "node server.js".into(),
            }],
            scroll: 0,
            error: None,
            loading: false,
        });

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("PID"));
        assert!(plain.contains("PPID"));
        assert!(plain.contains("node server.js"));
        assert!(plain.contains("2.5"));
    }

    #[test]
    fn focused_container_scrolls_process_table() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.width = 160;
        app.height = 32;
        app.snapshot.containers = vec![container_row(
            "abcdef",
            "app",
            "Up 2 minutes",
            Some(42.0),
            Some(30.0),
        )];
        app.focused_container = Some("abcdef".into());
        let rows = (0..30)
            .map(|idx| ContainerProcessRow {
                pid: format!("{}", 100 + idx),
                ppid: "1".into(),
                cpu_pct: Some(idx as f32),
                mem_pct: Some(0.5),
                elapsed: "00:01".into(),
                command: format!("worker-{idx:02}"),
            })
            .collect::<Vec<_>>();
        app.container_processes = Some(ContainerProcessPanel {
            container_id: "abcdef".into(),
            container_name: "app".into(),
            rows,
            scroll: 0,
            error: None,
            loading: false,
        });

        app.handle_key(KeyEvent {
            code: KeyCode::Down,
            modifiers: KeyModifiers::empty(),
        });
        assert_eq!(app.container_processes.as_ref().unwrap().scroll, 1);

        app.handle_key(KeyEvent {
            code: KeyCode::PageDown,
            modifiers: KeyModifiers::empty(),
        });
        assert!(app.container_processes.as_ref().unwrap().scroll > 1);

        app.handle_key(KeyEvent {
            code: KeyCode::End,
            modifiers: KeyModifiers::empty(),
        });
        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("worker-29"));
        assert!(!plain.contains("worker-00"));

        app.handle_key(KeyEvent {
            code: KeyCode::Home,
            modifiers: KeyModifiers::empty(),
        });
        let plain = a3s_tui::style::strip_ansi(&app.table());
        assert!(plain.contains("worker-00"));
    }

    #[test]
    fn log_scroll_controls_follow_mode() {
        let mut app = TopApp::new(TopOptions::default());
        app.height = 10;
        app.log = Some(LogPanel {
            connector: ContainerConnector::Docker,
            container_id: "abcdef".into(),
            container_name: "app".into(),
            text: numbered_lines(10),
            scroll: 7,
            timestamps: false,
            loading: false,
            refreshing: false,
            follow: true,
        });

        app.handle_key(KeyEvent {
            code: KeyCode::Up,
            modifiers: KeyModifiers::empty(),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 6);
        assert!(!log.follow);

        app.handle_key(KeyEvent {
            code: KeyCode::Down,
            modifiers: KeyModifiers::empty(),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 7);
        assert!(log.follow);

        app.handle_key(KeyEvent {
            code: KeyCode::Home,
            modifiers: KeyModifiers::empty(),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 0);
        assert!(!log.follow);

        app.handle_key(KeyEvent {
            code: KeyCode::End,
            modifiers: KeyModifiers::empty(),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 7);
        assert!(log.follow);
    }

    #[test]
    fn log_follow_key_toggles_tail_mode() {
        let mut app = TopApp::new(TopOptions::default());
        app.height = 10;
        app.log = Some(LogPanel {
            connector: ContainerConnector::Docker,
            container_id: "abcdef".into(),
            container_name: "app".into(),
            text: numbered_lines(10),
            scroll: 2,
            timestamps: false,
            loading: false,
            refreshing: false,
            follow: false,
        });

        app.handle_key(KeyEvent {
            code: KeyCode::Char('f'),
            modifiers: KeyModifiers::empty(),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 7);
        assert!(log.follow);

        app.handle_key(KeyEvent {
            code: KeyCode::Char('f'),
            modifiers: KeyModifiers::empty(),
        });
        assert!(!app.log.as_ref().unwrap().follow);
    }

    #[test]
    fn log_refresh_command_marks_panel_refreshing() {
        let mut app = TopApp::new(TopOptions::default());
        app.log = Some(LogPanel {
            connector: ContainerConnector::Docker,
            container_id: "abcdef".into(),
            container_name: "app".into(),
            text: "line".into(),
            scroll: 0,
            timestamps: false,
            loading: false,
            refreshing: false,
            follow: true,
        });

        assert!(app.open_log_refresh_cmd().is_some());
        assert!(app.log.as_ref().unwrap().refreshing);
        assert!(app.open_log_refresh_cmd().is_none());
    }

    #[test]
    fn log_refresh_key_marks_panel_refreshing() {
        let mut app = TopApp::new(TopOptions::default());
        app.log = Some(LogPanel {
            connector: ContainerConnector::Docker,
            container_id: "abcdef".into(),
            container_name: "app".into(),
            text: "line".into(),
            scroll: 0,
            timestamps: false,
            loading: false,
            refreshing: false,
            follow: true,
        });

        assert!(app
            .handle_key(KeyEvent {
                code: KeyCode::Char('r'),
                modifiers: KeyModifiers::empty(),
            })
            .is_some());
        assert!(app.log.as_ref().unwrap().refreshing);
    }

    #[test]
    fn logs_view_uses_shared_log_view_and_fits_width() {
        let mut app = TopApp::new(TopOptions::default());
        app.width = 64;
        app.height = 10;
        app.log = Some(LogPanel {
            connector: ContainerConnector::Docker,
            container_id: "abcdef".into(),
            container_name: "app".into(),
            text: numbered_lines(6),
            scroll: 2,
            timestamps: true,
            loading: false,
            refreshing: false,
            follow: false,
        });

        let rendered = app.logs_view();
        let plain = a3s_tui::style::strip_ansi(&rendered);
        assert!(plain.contains("logs app (abcdef)"), "{plain}");
        assert!(plain.contains("tail 200"), "{plain}");
        assert!(plain.contains("timestamps:on"), "{plain}");
        assert!(plain.contains("follow:off"), "{plain}");
        assert!(plain.contains("line-2"), "{plain}");
        assert!(!plain.contains("line-0"), "{plain}");
        assert!(rendered.contains("\x1b["), "log view should be styled");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 64),
            "{plain}"
        );

        app.log.as_mut().unwrap().loading = true;
        let loading = a3s_tui::style::strip_ansi(&app.logs_view());
        assert!(loading.contains("loading container logs"), "{loading}");
        assert!(!loading.contains("line-2"), "{loading}");
    }

    #[test]
    fn log_results_follow_or_preserve_scroll() {
        let mut app = TopApp::new(TopOptions::default());
        app.height = 10;
        app.log = Some(LogPanel {
            connector: ContainerConnector::Docker,
            container_id: "abcdef".into(),
            container_name: "app".into(),
            text: numbered_lines(10),
            scroll: 7,
            timestamps: false,
            loading: false,
            refreshing: true,
            follow: true,
        });

        app.update(Msg::ContainerLogs {
            connector: ContainerConnector::Docker,
            id: "abcdef".into(),
            name: "app".into(),
            timestamps: false,
            result: Ok(numbered_lines(12)),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 9);
        assert!(log.follow);
        assert!(!log.refreshing);

        app.log.as_mut().unwrap().follow = false;
        app.log.as_mut().unwrap().scroll = 4;
        app.update(Msg::ContainerLogs {
            connector: ContainerConnector::Docker,
            id: "abcdef".into(),
            name: "app".into(),
            timestamps: false,
            result: Ok(numbered_lines(12)),
        });
        let log = app.log.as_ref().unwrap();
        assert_eq!(log.scroll, 4);
        assert!(!log.follow);
    }

    #[test]
    fn focused_agent_renders_agent_view() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 96;
        app.snapshot.processes = vec![ProcessRow {
            cpu_pct: 33.0,
            mem_pct: 12.0,
            ..process_row(42, 1, "codex exec task")
        }];
        app.snapshot.events = vec![EventRow {
            ts: "recent".into(),
            source: "codex".into(),
            session: Some("sess-a".into()),
            task: Some("task-a".into()),
            pid: Some(42),
            ppid: None,
            kind: "ToolExec".into(),
            message: "git status".into(),
            details: Vec::new(),
            risk: Risk::Medium,
        }];
        app.focused_agent_pid = Some(42);

        let rendered = app.table();
        let plain = a3s_tui::style::strip_ansi(&rendered);

        assert!(plain.contains("agent view codex"));
        assert!(plain.contains("pid 42"));
        assert!(plain.contains("ppid 1"));
        assert!(plain.contains("activity events 1"));
        assert!(plain.contains("TIME"));
        assert!(plain.contains("KIND"));
        assert!(plain.contains("PID"));
        assert!(plain.contains("ToolExec"));
        assert!(plain.contains("SESSION"));
        assert!(plain.contains("sess-a"));
        assert!(plain.contains("task-a"));
        assert!(plain.contains("git status"));
        assert!(plain.contains("command codex exec task"));
        assert!(!plain.contains("AGENT"));
        assert!(rendered.contains("\x1b["), "agent focus should be styled");
        assert!(
            rendered
                .lines()
                .all(|line| a3s_tui::style::visible_len(line) <= 96),
            "{plain}"
        );
    }

    #[test]
    fn focused_agent_renders_child_process_tree() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.width = 120;
        app.height = 32;
        app.snapshot.processes = vec![
            ProcessRow {
                cpu_pct: 33.0,
                mem_pct: 12.0,
                ..process_row(42, 1, "codex exec task")
            },
            ProcessRow {
                cpu_pct: 4.0,
                mem_pct: 1.5,
                ..process_row(100, 42, "bash -lc cargo test")
            },
            ProcessRow {
                cpu_pct: 2.0,
                mem_pct: 0.5,
                ..process_row(101, 100, "git status --short")
            },
        ];
        app.focused_agent_pid = Some(42);

        let plain = a3s_tui::style::strip_ansi(&app.table());

        assert!(plain.contains("subtree cpu 39.0% mem 14.0%"));
        assert!(plain.contains("42  cpu 33.0% mem 12.0%  codex exec task"));
        assert!(plain.contains("100  cpu 4.0% mem 1.5%  bash -lc cargo test"));
        assert!(plain.contains("101  cpu 2.0% mem 0.5%  git status --short"));
        assert!(plain.contains("└── 100"));
    }

    #[test]
    fn o_focuses_selected_agent() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.snapshot.processes = vec![process_row(42, 1, "codex exec task")];

        app.handle_key(KeyEvent {
            code: KeyCode::Char('o'),
            modifiers: KeyModifiers::empty(),
        });

        assert_eq!(app.focused_agent_pid, Some(42));
        assert_eq!(app.tab, Tab::Agents);
    }

    #[test]
    fn esc_closes_container_focus_before_detail() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.focused_container = Some("abcdef".into());
        app.detail = true;

        app.handle_key(KeyEvent {
            code: KeyCode::Esc,
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.focused_container.is_none());
    }

    #[test]
    fn esc_closes_agent_focus_before_detail() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Agents,
            ..TopOptions::default()
        });
        app.focused_agent_pid = Some(42);
        app.detail = true;

        app.handle_key(KeyEvent {
            code: KeyCode::Esc,
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.focused_agent_pid.is_none());
    }

    #[test]
    fn esc_closes_session_focus_before_detail() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Sessions,
            ..TopOptions::default()
        });
        app.focused_session = Some(SessionFocus {
            source: "codex".into(),
            session: "sess-a".into(),
        });
        app.detail = true;

        app.handle_key(KeyEvent {
            code: KeyCode::Esc,
            modifiers: KeyModifiers::empty(),
        });

        assert!(app.focused_session.is_none());
    }

    #[test]
    fn sorts_containers_by_memory_percent() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        app.sort_by = SortBy::Mem;
        app.snapshot.containers = vec![
            container_row("low", "low", "Up", Some(1.0), Some(5.0)),
            container_row("high", "high", "Up", Some(1.0), Some(42.0)),
        ];

        let rows = app.filtered_containers();

        assert_eq!(rows[0].name, "high");
    }

    #[test]
    fn sorts_containers_by_ctop_state_id_and_uptime_fields() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        let exited = container_row("ccc333", "exited", "Exited (0) 1 hour ago", None, None);
        let running_short = container_row("aaa111", "short", "Up 2 minutes", Some(1.0), Some(1.0));
        let running_long = container_row("bbb222", "long", "Up 3 hours", Some(1.0), Some(1.0));

        app.snapshot.containers = vec![exited.clone(), running_short.clone(), running_long.clone()];
        app.sort_by = SortBy::State;
        assert_eq!(app.filtered_containers()[0].name, "long");

        app.snapshot.containers = vec![running_long.clone(), exited.clone(), running_short.clone()];
        app.sort_by = SortBy::Id;
        assert_eq!(app.filtered_containers()[0].id, "aaa111");

        app.snapshot.containers = vec![running_short, exited, running_long];
        app.sort_by = SortBy::Uptime;
        assert_eq!(app.filtered_containers()[0].name, "long");
        assert_eq!(parse_uptime_seconds("Up 2 minutes"), Some(120));
        assert_eq!(container_state_label("Up 4 minutes (Paused)"), "paused");
    }

    #[test]
    fn container_state_colors_match_runtime_state() {
        assert_eq!(container_state_color("running"), GREEN);
        assert_eq!(container_state_color("Up 4 minutes"), GREEN);
        assert_eq!(container_state_color("restarting"), ORANGE);
        assert_eq!(container_state_color("paused"), YELLOW);
        assert_eq!(
            container_state_color("Exited (0) 1 hour ago"),
            Color::BrightBlack
        );
        assert_eq!(container_state_color("created"), CYAN);
        assert_eq!(container_state_color("dead"), RED);
    }

    #[test]
    fn summarizes_container_states() {
        let rows = vec![
            container_row("run", "run", "Up 2 minutes", Some(1.0), Some(1.0)),
            container_row("restart", "restart", "Restarting (1)", None, None),
            container_row("pause", "pause", "paused", None, None),
            container_row("exit", "exit", "stopped", None, None),
            container_row("create", "create", "created", None, None),
            container_row("dead", "dead", "dead", None, None),
            container_row("other", "other", "unknown", None, None),
        ];

        let summary = container_state_summary(&rows);

        assert_eq!(summary.total, 7);
        assert_eq!(summary.running, 1);
        assert_eq!(summary.restarting, 1);
        assert_eq!(summary.paused, 1);
        assert_eq!(summary.exited, 1);
        assert_eq!(summary.created, 1);
        assert_eq!(summary.dead, 1);
        assert_eq!(summary.other, 1);
        assert_eq!(
            summary.header_label(),
            "7 run:1 restart:1 pause:1 exit:1 create:1 dead:1 other:1"
        );
    }

    #[test]
    fn parses_human_byte_pairs_for_container_sorting() {
        assert_eq!(parse_human_bytes("1.5kB"), Some(1500));
        assert_eq!(parse_human_bytes("1.00KiB"), Some(1024));
        assert_eq!(parse_byte_pair_total("1.00KiB / 2.00KiB"), 3072);
        assert_eq!(parse_byte_pair_total("-"), 0);
    }

    #[test]
    fn parses_container_process_tables() {
        let rows = parse_container_process_table(
            "PID PPID %CPU %MEM ELAPSED COMMAND\n123 1 2.5 0.7 00:01 node server.js\n",
        );

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].pid, "123");
        assert_eq!(rows[0].ppid, "1");
        assert_eq!(rows[0].cpu_pct, Some(2.5));
        assert_eq!(rows[0].mem_pct, Some(0.7));
        assert_eq!(rows[0].elapsed, "00:01");
        assert_eq!(rows[0].command, "node server.js");

        let rows = parse_container_process_table(
            "UID PID PPID C STIME TTY TIME CMD\nroot 321 1 0 10:00 ? 00:00:01 sleep infinity\n",
        );
        assert_eq!(rows[0].pid, "321");
        assert_eq!(rows[0].command, "sleep infinity");
    }

    #[test]
    fn parses_runc_process_ids() {
        let rows = parse_runc_processes("PID\n123\n456\n");

        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].pid, "123");
        assert_eq!(rows[1].command, "runc process");
    }

    #[test]
    fn sorts_containers_by_network_block_and_pids() {
        let mut app = TopApp::new(TopOptions {
            tab: Tab::Containers,
            ..TopOptions::default()
        });
        let mut low = container_row("low", "low", "Up", Some(1.0), Some(1.0));
        low.net_io = "1kB / 1kB".into();
        low.block_io = "5kB / 1kB".into();
        low.pids = "2".into();
        let mut high = container_row("high", "high", "Up", Some(1.0), Some(1.0));
        high.net_io = "10kB / 2kB".into();
        high.block_io = "1kB / 1kB".into();
        high.pids = "9".into();
        app.snapshot.containers = vec![low.clone(), high.clone()];

        app.sort_by = SortBy::Net;
        assert_eq!(app.filtered_containers()[0].name, "high");

        app.snapshot.containers = vec![low.clone(), high.clone()];
        app.sort_by = SortBy::Block;
        assert_eq!(app.filtered_containers()[0].name, "low");

        app.snapshot.containers = vec![low, high];
        app.sort_by = SortBy::Pids;
        assert_eq!(app.filtered_containers()[0].name, "high");
    }

    #[test]
    fn maps_container_menu_actions_to_confirmable_actions() {
        let container = container_row("abcdef", "app", "Up", Some(1.0), Some(2.0));

        assert!(matches!(
            container_action(container.clone(), ContainerMenuAction::Pause),
            Action::PauseContainer(ContainerConnector::Docker, _, name) if name.contains("app")
        ));
        assert!(matches!(
            container_action(container, ContainerMenuAction::Remove),
            Action::RemoveContainer(ContainerConnector::Docker, _, name) if name.contains("app")
        ));
    }

    #[test]
    fn parses_runc_list_json() {
        let rows = parse_runc_list(
            r#"[
                {
                    "id": "example",
                    "pid": 1234,
                    "status": "running",
                    "bundle": "/containers/example",
                    "rootfs": "/containers/example/rootfs",
                    "created": "2026-06-26T00:00:00Z"
                }
            ]"#,
        )
        .unwrap();

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].connector, ContainerConnector::RunC);
        assert_eq!(rows[0].name, "example");
        assert_eq!(rows[0].status, "running");
        assert_eq!(rows[0].pids, "1234");
        assert!(rows[0].image.contains("rootfs:"));
    }

    #[test]
    fn runc_global_args_follow_ctop_env_vars() {
        let old_root = std::env::var_os("RUNC_ROOT");
        let old_systemd = std::env::var_os("RUNC_SYSTEMD_CGROUP");

        std::env::remove_var("RUNC_ROOT");
        std::env::remove_var("RUNC_SYSTEMD_CGROUP");
        assert_eq!(runc_global_args(), vec!["--root", "/run/runc"]);

        std::env::set_var("RUNC_ROOT", "/tmp/custom-runc");
        assert_eq!(runc_global_args(), vec!["--root", "/tmp/custom-runc"]);

        std::env::set_var("RUNC_SYSTEMD_CGROUP", "true");
        assert_eq!(
            runc_global_args(),
            vec!["--root", "/tmp/custom-runc", "--systemd-cgroup"]
        );

        std::env::set_var("RUNC_SYSTEMD_CGROUP", "0");
        assert_eq!(runc_global_args(), vec!["--root", "/tmp/custom-runc"]);

        restore_var("RUNC_ROOT", old_root);
        restore_var("RUNC_SYSTEMD_CGROUP", old_systemd);
    }

    #[test]
    fn runc_container_filters_match_active_all_and_single_target() {
        let mut active_rows = vec![
            container_row("abcdef1234567890", "api", "running", None, None),
            container_row("1111111234567890", "paused-box", "paused", None, None),
            container_row("2222221234567890", "stopped-box", "stopped", None, None),
            container_row("3333331234567890", "created-box", "created", None, None),
        ];
        filter_runc_container_rows(&mut active_rows, false, None);
        let names = active_rows
            .iter()
            .map(|row| row.name.as_str())
            .collect::<Vec<_>>();
        assert_eq!(names, vec!["api", "paused-box"]);

        let mut all_target_rows = vec![
            container_row("abcdef1234567890", "api", "running", None, None),
            container_row("2222221234567890", "stopped-box", "stopped", None, None),
        ];
        filter_runc_container_rows(&mut all_target_rows, true, Some("222222"));
        assert_eq!(all_target_rows.len(), 1);
        assert_eq!(all_target_rows[0].name, "stopped-box");

        let mut active_target_rows = vec![
            container_row("abcdef1234567890", "api", "running", None, None),
            container_row("2222221234567890", "stopped-box", "stopped", None, None),
        ];
        filter_runc_container_rows(&mut active_target_rows, false, Some("stopped-box"));
        assert!(active_target_rows.is_empty());

        let mut name_target_rows = vec![
            container_row("abcdef1234567890", "api", "running", None, None),
            container_row("1111111234567890", "worker", "running", None, None),
        ];
        filter_runc_container_rows(&mut name_target_rows, false, Some("api"));
        assert_eq!(name_target_rows.len(), 1);
        assert_eq!(name_target_rows[0].id, "abcdef1234567890");
    }

    #[test]
    fn parses_docker_container_ports() {
        let rows = parse_docker_container_list(
            "abcdef\tapp\tnginx:latest\tUp 2 minutes\t0.0.0.0:8080->80/tcp, :::8080->80/tcp\n123456\tworker\talpine\tUp 1 second\t\n",
        );

        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].ports, "0.0.0.0:8080->80/tcp, :::8080->80/tcp");
        assert_eq!(rows[1].ports, "-");
    }

    #[test]
    fn derives_browser_url_from_a3s_box_and_docker_ports() {
        let mut row = container_row("abcdef", "app", "running", Some(1.0), Some(2.0));

        row.ports = "8080:80".into();
        assert_eq!(
            container_web_url(&row).as_deref(),
            Some("http://localhost:8080/")
        );

        row.ports = "127.0.0.1:3000:80".into();
        assert_eq!(
            container_web_url(&row).as_deref(),
            Some("http://localhost:3000/")
        );

        row.ports = "80/tcp, 0.0.0.0:9090->90/tcp".into();
        assert_eq!(
            container_web_url(&row).as_deref(),
            Some("http://localhost:9090/")
        );

        row.ports = ":::8443->443/tcp".into();
        assert_eq!(
            container_web_url(&row).as_deref(),
            Some("http://localhost:8443/")
        );

        row.ports = "0:80".into();
        assert_eq!(container_web_url(&row), None);
    }

    #[test]
    fn parses_a3s_box_ps_and_stats_output() {
        let rows = parse_a3s_box_ps(
            "abc123\tdev\talpine:latest\trunning\t8080:80\t2 minutes ago\tsleep 3600\n",
        );

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].connector, ContainerConnector::A3sBox);
        assert_eq!(rows[0].id, "abc123");
        assert_eq!(rows[0].name, "dev");
        assert_eq!(rows[0].status, "running");
        assert_eq!(rows[0].ports, "8080:80");
        assert_eq!(rows[0].inspect.created, "2 minutes ago");
        assert_eq!(rows[0].inspect.started, "2 minutes ago");
        assert!(rows[0].inspect.labels.contains("sleep 3600"));

        let json_rows = parse_a3s_box_ps_json(
            r#"[
                {
                    "id": "abcdef1234567890",
                    "short_id": "abcdef123456",
                    "name": "dev",
                    "image": "alpine:latest",
                    "status": "running (healthy)",
                    "raw_status": "running",
                    "created": "2 minutes ago",
                    "created_at": "2026-06-26T08:00:00Z",
                    "started_at": "2026-06-26T08:01:00Z",
                    "ports": ["8080:80"],
                    "command": "sleep 3600",
                    "labels": {"role": "api"},
                    "health": "healthy",
                    "pid": 4242
                }
            ]"#,
        );
        assert_eq!(json_rows.len(), 1);
        assert_eq!(json_rows[0].id, "abcdef1234567890");
        assert_eq!(json_rows[0].name, "dev");
        assert_eq!(json_rows[0].status, "running");
        assert_eq!(json_rows[0].ports, "8080:80");
        assert_eq!(json_rows[0].pids, "-");
        assert_eq!(json_rows[0].inspect.health, "healthy");
        assert_eq!(json_rows[0].inspect.created, "2 minutes ago");
        assert_eq!(json_rows[0].inspect.started, "2026-06-26 08:01:00");
        assert!(json_rows[0].inspect.labels.contains("sleep 3600"));
        assert!(json_rows[0].inspect.labels.contains("1 label"));

        let stats = parse_a3s_box_stats(
            "BOX ID NAME STATUS CPU % MEM USAGE / LIMIT MEM % PID NET I/O IO\nabc123 dev running 12.50% 64.0 MB / 512.0 MB 12.5% 4242 1.0 KB / 2.0 KB 4.0 KB / 8.0 KB\n",
        );
        let by_id = stats.get("abc123").unwrap();
        assert_eq!(by_id.cpu_pct, Some(12.5));
        assert_eq!(by_id.mem_pct, Some(12.5));
        assert_eq!(by_id.mem_usage, "64.0 MB / 512.0 MB");
        assert_eq!(by_id.net_io, "1.0 KB / 2.0 KB");
        assert_eq!(by_id.block_io, "4.0 KB / 8.0 KB");
        assert_eq!(by_id.pid.as_deref(), Some("4242"));
        assert!(stats.contains_key("dev"));

        let legacy = parse_a3s_box_stats(
            "BOX ID NAME STATUS CPU % MEM USAGE / LIMIT MEM % PID\nabc123 dev running 12.50% 64.0 MB / 512.0 MB 12.5% 4242\n",
        );
        assert_eq!(legacy.get("abc123").unwrap().net_io, "-");
        assert_eq!(legacy.get("abc123").unwrap().block_io, "-");

        let legacy_io = parse_a3s_box_stats(
            "BOX ID NAME STATUS CPU % MEM USAGE / LIMIT MEM % PID IO\nabc123 dev running 12.50% 64.0 MB / 512.0 MB 12.5% 4242 4.0 KB / 8.0 KB\n",
        );
        assert_eq!(legacy_io.get("abc123").unwrap().net_io, "-");
        assert_eq!(legacy_io.get("abc123").unwrap().block_io, "4.0 KB / 8.0 KB");

        let json_stats = parse_a3s_box_stats_json(
            r#"[
                {
                    "id": "abc123",
                    "short_id": "abc123",
                    "name": "dev",
                    "status": "running",
                    "pid": 4242,
                    "cpus": 2,
                    "cpu_percent": 12.5,
                    "memory_bytes": 67108864,
                    "memory_limit_bytes": 536870912,
                    "memory_percent": 12.5,
                    "network_rx_bytes": 1024,
                    "network_tx_bytes": 2048,
                    "block_read_bytes": 4096,
                    "block_write_bytes": 8192,
                    "pids_current": 7
                }
            ]"#,
        );
        let by_id = json_stats.get("abc123").unwrap();
        assert_eq!(by_id.cpu_pct, Some(12.5));
        assert_eq!(by_id.cpu_count, Some(2));
        assert_eq!(by_id.mem_pct, Some(12.5));
        assert_eq!(by_id.mem_usage, "64.0 MB / 512.0 MB");
        assert_eq!(by_id.net_io, "1.0 KB / 2.0 KB");
        assert_eq!(by_id.block_io, "4.0 KB / 8.0 KB");
        assert_eq!(by_id.pid.as_deref(), Some("4242"));
        assert_eq!(by_id.pids_current, Some(7));
        assert!(json_stats.contains_key("dev"));
    }

    #[cfg(unix)]
    #[test]
    fn command_output_error_prefers_stderr_for_visible_collector_errors() {
        use std::os::unix::process::ExitStatusExt;

        let output = std::process::Output {
            status: std::process::ExitStatus::from_raw(1 << 8),
            stdout: b"stdout fallback".to_vec(),
            stderr: b"box runtime unavailable\n".to_vec(),
        };

        let message = command_output_error("a3s-box ps", &output);

        assert!(message.contains("a3s-box ps exited with status"));
        assert!(message.contains("box runtime unavailable"));
        assert!(!message.contains("stdout fallback"));
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn a3s_box_ps_rows_surfaces_command_failures() {
        use std::os::unix::fs::PermissionsExt;

        let root =
            std::env::temp_dir().join(format!("a3s-top-box-ps-fail-test-{}", std::process::id()));
        let binary = root.join("a3s-box");
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(
            &binary,
            b"#!/bin/sh\necho 'box ps failed visibly' >&2\nexit 42\n",
        )
        .unwrap();
        std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();

        let err = a3s_box_ps_rows(&binary, false, None)
            .await
            .unwrap_err()
            .to_string();

        let _ = std::fs::remove_dir_all(root);
        assert!(err.contains("a3s-box ps --format json exited with status"));
        assert!(err.contains("box ps failed visibly"));
        assert!(err.contains("fallback table failed"));
    }

    #[test]
    fn counts_a3s_box_processes_without_probe_process() {
        let rows = parse_container_process_table(
            "PID PPID %CPU %MEM ELAPSED COMMAND\n1 0 0.0 0.1 02:00 /sbin/init\n42 1 1.5 0.3 00:01 node server.js\n99 1 0.0 0.0 00:00 ps -eo pid,ppid,pcpu,pmem,etime,args\n",
        );

        assert_eq!(a3s_box_process_count_from_rows(&rows), 2);

        let filtered = filter_a3s_box_probe_processes(rows);
        assert_eq!(filtered.len(), 2);
        assert!(filtered
            .iter()
            .all(|row| row.command != "ps -eo pid,ppid,pcpu,pmem,etime,args"));
    }

    #[test]
    fn parses_a3s_box_top_json_output() {
        let rows = parse_a3s_box_top_json(
            r#"[
                {
                    "pid": "1",
                    "ppid": "0",
                    "cpu_percent": 0.0,
                    "memory_percent": 0.1,
                    "elapsed": "02:00",
                    "command": "/sbin/init"
                },
                {
                    "pid": 42,
                    "ppid": 1,
                    "cpu_percent": "1.5%",
                    "memory_percent": "0.3%",
                    "elapsed": "00:01",
                    "command": "node server.js"
                }
            ]"#,
        )
        .unwrap();

        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].pid, "1");
        assert_eq!(rows[0].ppid, "0");
        assert_eq!(rows[0].cpu_pct, Some(0.0));
        assert_eq!(rows[0].mem_pct, Some(0.1));
        assert_eq!(rows[1].pid, "42");
        assert_eq!(rows[1].ppid, "1");
        assert_eq!(rows[1].cpu_pct, Some(1.5));
        assert_eq!(rows[1].mem_pct, Some(0.3));
        assert_eq!(rows[1].command, "node server.js");
    }

    #[test]
    fn parses_a3s_box_inspect_metadata() {
        let rows = parse_a3s_box_inspect(
            r#"[
                {
                    "id": "abcdef1234567890",
                    "name": "dev",
                    "image": "alpine:latest",
                    "status": "running",
                    "created_at": "2026-06-26T08:00:00Z",
                    "started_at": "2026-06-26T08:01:00Z",
                    "restart_policy": "on-failure",
                    "restart_count": 3,
                    "max_restart_count": 5,
                    "exit_code": 137,
                    "health_status": "healthy",
                    "volumes": ["/host:/guest"],
                    "volume_names": ["data"],
                    "env": {
                        "TOKEN": "secret",
                        "RUST_LOG": "debug"
                    },
                    "labels": {
                        "com.example.role": "api"
                    },
                    "network_name": "bridge",
                    "add_host": ["db:10.0.0.2"],
                    "status_detail": {
                        "health": "healthy",
                        "restart_count": 3
                    },
                    "State": {
                        "Status": "running",
                        "Running": true,
                        "Paused": false,
                        "ExitCode": 0
                    }
                }
            ]"#,
        );
        let inspect = rows.get("abcdef1234567890").unwrap();

        assert_eq!(rows.get("abcdef123456").unwrap(), inspect);
        assert_eq!(inspect.health, "healthy");
        assert_eq!(inspect.restarts, "3");
        assert_eq!(inspect.restart_policy, "on-failure:5");
        assert_eq!(inspect.created, "2026-06-26 08:00:00");
        assert_eq!(inspect.started, "2026-06-26 08:01:00");
        assert_eq!(inspect.exit, "-");
        assert_eq!(inspect.env, "2 vars");
        assert!(inspect.mounts.contains("/host:/guest"));
        assert!(inspect.mounts.contains("data"));
        assert!(inspect.labels.contains("com.example.role"));
        assert!(inspect.networks.contains("bridge"));
        assert!(inspect.networks.contains("db:10.0.0.2"));
        assert!(!inspect.env.contains("secret"));
    }

    #[test]
    fn parses_docker_inspect_metadata() {
        let rows = parse_docker_inspect(
            r#"[
                {
                    "Id": "abcdef1234567890",
                    "Created": "2026-06-26T08:00:00.123456789Z",
                    "RestartCount": 3,
                    "State": {
                        "Status": "running",
                        "StartedAt": "2026-06-26T08:01:00Z",
                        "FinishedAt": "0001-01-01T00:00:00Z",
                        "ExitCode": 0,
                        "OOMKilled": false,
                        "Dead": false,
                        "Health": {"Status": "healthy", "FailingStreak": 0}
                    },
                    "HostConfig": {
                        "RestartPolicy": {"Name": "on-failure", "MaximumRetryCount": 5}
                    },
                    "Config": {
                        "Env": ["TOKEN=secret", "RUST_LOG=debug"],
                        "Labels": {
                            "com.example.role": "api",
                            "maintainer": "a3s"
                        }
                    },
                    "Mounts": [
                        {"Type": "bind", "Source": "/host/data", "Destination": "/data", "RW": true},
                        {"Type": "volume", "Name": "cfg", "Destination": "/cfg", "RW": false}
                    ],
                    "NetworkSettings": {
                        "Networks": {
                            "bridge": {"IPAddress": "172.17.0.2"}
                        }
                    }
                }
            ]"#,
        );
        let inspect = rows.get("abcdef1234567890").unwrap();

        assert_eq!(rows.get("abcdef123456").unwrap(), inspect);
        assert_eq!(inspect.health, "healthy");
        assert_eq!(inspect.restarts, "3");
        assert_eq!(inspect.restart_policy, "on-failure:5");
        assert_eq!(inspect.created, "2026-06-26 08:00:00");
        assert_eq!(inspect.started, "2026-06-26 08:01:00");
        assert_eq!(inspect.exit, "-");
        assert_eq!(inspect.env, "2 vars");
        assert!(inspect.mounts.contains("/data(rw)"));
        assert!(inspect.mounts.contains("/cfg(ro)"));
        assert!(inspect.labels.contains("com.example.role"));
        assert!(inspect.networks.contains("bridge 172.17.0.2"));
        assert!(!inspect.env.contains("secret"));
    }

    #[test]
    fn parses_runc_stats_json() {
        let stats = parse_runc_stats_event(
            r#"{
                "type": "stats",
                "id": "example",
                "data": {
                    "cpu": {"usage": {"total": 2000000000}},
                    "memory": {"usage": {"usage": 1048576, "limit": 4194304}},
                    "pids": {"current": 7},
                    "network_interfaces": [
                        {"Name": "eth0", "RxBytes": 1024, "TxBytes": 2048},
                        {"Name": "lo", "RxBytes": 512, "TxBytes": 256}
                    ],
                    "blkio": {
                        "ioServiceBytesRecursive": [
                            {"op": "Read", "value": 4096},
                            {"op": "Write", "value": 8192}
                        ]
                    }
                }
            }"#,
        )
        .unwrap();

        assert_eq!(stats.cpu_usage_total_ns, Some(2_000_000_000));
        assert_eq!(stats.memory_usage, Some(1_048_576));
        assert_eq!(stats.memory_limit, Some(4_194_304));
        assert_eq!(stats.pids_current, Some(7));
        assert_eq!(stats.net_rx, 1536);
        assert_eq!(stats.net_tx, 2304);
        assert_eq!(stats.block_read, 4096);
        assert_eq!(stats.block_write, 8192);
    }

    #[test]
    fn applies_runc_stats_to_container_row() {
        let mut row = container_row("example", "example", "running", None, None);
        row.connector = ContainerConnector::RunC;

        apply_runc_stats(
            &mut row,
            &RuncStats {
                memory_usage: Some(1_048_576),
                memory_limit: Some(4_194_304),
                cpu_usage_total_ns: Some(2_000_000_000),
                pids_current: Some(7),
                net_rx: 1024,
                net_tx: 2048,
                block_read: 4096,
                block_write: 8192,
            },
        );

        assert_eq!(row.mem_pct, Some(25.0));
        assert_eq!(row.cpu_usage_total_ns, Some(2_000_000_000));
        assert_eq!(row.mem_usage, "1.00MiB / 4.00MiB");
        assert_eq!(row.net_io, "1.00KiB / 2.00KiB");
        assert_eq!(row.block_io, "4.00KiB / 8.00KiB");
        assert_eq!(row.pids, "7");
    }

    #[test]
    fn records_metric_history_and_prunes_stale_keys() {
        let mut app = TopApp::new(TopOptions::default());
        let mut snapshot = TopSnapshot::default();
        snapshot.processes.push(ProcessRow {
            cpu_pct: 12.0,
            mem_pct: 3.0,
            ..process_row(42, 0, "codex")
        });
        snapshot.processes.push(ProcessRow {
            cpu_pct: 4.0,
            mem_pct: 2.0,
            ..process_row(100, 42, "cargo test")
        });
        snapshot.containers.push(ContainerRow {
            connector: ContainerConnector::Docker,
            id: "abcdef".into(),
            name: "app".into(),
            image: "img".into(),
            status: "Up".into(),
            inspect: ContainerInspect::default(),
            cpu_pct: Some(8.0),
            cpu_count: None,
            cpu_usage_total_ns: None,
            mem_pct: Some(9.0),
            mem_usage: "1MiB / 10MiB".into(),
            net_io: "1.00KiB / 2.00KiB".into(),
            block_io: "4.00KiB / 8.00KiB".into(),
            pids: "1".into(),
            ports: "-".into(),
        });

        app.record_history(&mut snapshot);

        assert_eq!(app.metric_history("process:42").cpu, vec![12.0]);
        assert_eq!(app.metric_history("agent-tree:42").cpu, vec![16.0]);
        assert_eq!(app.metric_history("agent-tree:42").mem, vec![5.0]);
        assert_eq!(app.metric_history("container:abcdef").mem, vec![9.0]);
        assert_eq!(
            app.metric_history("container:abcdef").net_io_bytes,
            vec![3072.0]
        );
        assert_eq!(
            app.metric_history("container:abcdef").block_io_bytes,
            vec![12288.0]
        );

        app.record_history(&mut TopSnapshot::default());
        assert!(app.history.is_empty());
    }

    #[test]
    fn derives_cpu_percent_from_runc_raw_totals() {
        let now = Instant::now();
        let mut history = MetricHistory::default();

        assert_eq!(
            observe_raw_cpu_pct(&mut history, Some(1_000_000_000), now),
            None
        );

        let pct = observe_raw_cpu_pct(
            &mut history,
            Some(1_500_000_000),
            now + Duration::from_secs(1),
        )
        .unwrap();

        assert!((pct - 50.0).abs() < 0.01);
    }

    #[test]
    fn caps_metric_history() {
        let mut history = MetricHistory::default();
        for value in 0..(HISTORY_LIMIT + 5) {
            push_history(&mut history, value as f32, value as f32);
        }

        assert_eq!(history.cpu.len(), HISTORY_LIMIT);
        assert_eq!(history.cpu[0], 5.0);
    }

    #[test]
    fn observer_chunk_is_incremental_and_newest_first() {
        let mut file = ObserverFileState::default();
        let first =
            r#"{"identity":{"agent":"codex"},"event":{"ToolExec":{"argv":["git","status"]}}}"#;
        let second =
            r#"{"identity":{"agent":"claude"},"event":{"FileAccess":{"path":"README.md"}}}"#;

        let rows = append_observer_chunk(&mut file, first);
        assert!(rows.is_empty());
        assert_eq!(file.pending, first);

        let rows = append_observer_chunk(&mut file, &format!("\n{second}\n"));

        assert!(file.pending.is_empty());
        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].source, "claude");
        assert_eq!(rows[1].source, "codex");
    }

    #[test]
    fn observer_rows_merge_newest_first_across_files() {
        let mut state = ObserverState {
            paths: vec![PathBuf::from("left.ndjson"), PathBuf::from("right.ndjson")],
            ..ObserverState::default()
        };
        let mut left = ObserverFileState::default();
        let mut right = ObserverFileState::default();
        push_observer_rows(
            &mut state,
            append_observer_chunk(
                &mut left,
                r#"{"identity":{"agent":"codex"},"event":{"ToolExec":{"argv":["git","status"]}}}
"#,
            ),
        );
        push_observer_rows(
            &mut state,
            append_observer_chunk(
                &mut right,
                r#"{"identity":{"agent":"claude"},"event":{"FileAccess":{"path":"README.md"}}}
"#,
            ),
        );

        assert_eq!(state.events.len(), 2);
        assert_eq!(state.events[0].source, "claude");
        assert_eq!(state.events[1].source, "codex");
        assert_eq!(observer_status_label(&state), "obs:2 files");
    }

    #[test]
    fn observer_auto_discovers_known_agent_logs_and_dedupes_explicit_paths() {
        let root =
            std::env::temp_dir().join(format!("a3s-top-observer-auto-test-{}", std::process::id()));
        let explicit = root.join(".a3s").join("observer").join("events.ndjson");
        let claude = root
            .join(".claude")
            .join("projects")
            .join("-tmp-work")
            .join("session.jsonl");
        let codex = root
            .join(".codex")
            .join("sessions")
            .join("2026")
            .join("06")
            .join("26")
            .join("rollout.jsonl");
        let a3s = root
            .join(".a3s")
            .join("tui-sessions")
            .join("runs")
            .join("tui-default.json");
        let a3s_workspace = root
            .join(".a3s")
            .join("workspace")
            .join("users")
            .join("u")
            .join("sessions")
            .join("s")
            .join(".sessions")
            .join("runs")
            .join("s.json");
        write_file(&explicit, "{}\n");
        write_file(&claude, "{}\n");
        write_file(&codex, "{}\n");
        write_file(&a3s, "[]\n");
        write_file(&a3s_workspace, "[]\n");

        let old_home = std::env::var_os("HOME");
        let old_log = std::env::var_os("A3S_TOP_OBSERVER_LOG");
        let old_logs = std::env::var_os("A3S_TOP_OBSERVER_LOGS");
        let old_auto = std::env::var_os("A3S_TOP_OBSERVER_AUTO");
        std::env::set_var("HOME", &root);
        std::env::set_var("A3S_TOP_OBSERVER_AUTO", "1");
        std::env::set_var(
            "A3S_TOP_OBSERVER_LOGS",
            explicit.to_string_lossy().to_string(),
        );
        std::env::remove_var("A3S_TOP_OBSERVER_LOG");

        let paths = observer_paths();

        restore_var("HOME", old_home);
        restore_var("A3S_TOP_OBSERVER_LOG", old_log);
        restore_var("A3S_TOP_OBSERVER_LOGS", old_logs);
        restore_var("A3S_TOP_OBSERVER_AUTO", old_auto);
        let _ = std::fs::remove_dir_all(root);

        assert_eq!(
            paths.paths.iter().filter(|path| *path == &explicit).count(),
            1
        );
        assert!(paths.paths.contains(&claude));
        assert!(paths.paths.contains(&codex));
        assert!(paths.paths.contains(&a3s));
        assert!(paths.paths.contains(&a3s_workspace));
        assert!(!paths.auto_paths.contains(&explicit));
        assert!(paths.auto_paths.contains(&claude));
    }

    #[test]
    fn observer_status_labels_auto_sources() {
        let auto = PathBuf::from("/tmp/claude.jsonl");
        let state = ObserverState {
            paths: vec![auto.clone()],
            auto_paths: HashSet::from([auto]),
            ..ObserverState::default()
        };

        assert_eq!(observer_status_label(&state), "obs:auto:claude.jsonl");
    }

    #[test]
    fn observer_timestamp_sort_key_normalizes_iso_and_numeric_millis() {
        assert_eq!(
            observer_timestamp_sort_key("2026-06-26T08:00:00Z"),
            Some(1_782_460_800_000)
        );
        assert_eq!(
            observer_timestamp_sort_key("1782460800000"),
            Some(1_782_460_800_000)
        );
        assert!(observer_timestamp_sort_key("now").unwrap() > 1_782_460_800_000);
    }

    #[test]
    fn observer_trimming_keeps_newest_events_across_sources() {
        let mut events = (0..220)
            .map(|idx| EventRow {
                ts: format!("{}", 1_000_000 + idx),
                source: "a3s-code".into(),
                session: Some("old".into()),
                task: None,
                pid: None,
                ppid: None,
                kind: "AgentEvent".into(),
                message: format!("old-{idx}"),
                details: Vec::new(),
                risk: Risk::Low,
            })
            .collect::<Vec<_>>();
        events.push(EventRow {
            ts: "2026-06-26T08:00:00Z".into(),
            source: "codex".into(),
            session: Some("new".into()),
            task: None,
            pid: None,
            ppid: None,
            kind: "LlmCall".into(),
            message: "new codex event".into(),
            details: Vec::new(),
            risk: Risk::Low,
        });

        trim_observer_events(&mut events);

        assert_eq!(events.len(), OBSERVER_EVENT_LIMIT);
        assert_eq!(events[0].source, "codex");
        assert!(events
            .iter()
            .any(|event| event.message == "new codex event"));
    }

    #[test]
    fn parses_claude_jsonl_tool_use() {
        let row = parse_observer_line(
            r#"{
                "type":"assistant",
                "timestamp":"2026-06-26T08:00:00Z",
                "sessionId":"claude-session",
                "cwd":"/work/a3s",
                "message":{
                    "model":"claude-opus-4",
                    "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15},
                    "content":[
                        {"type":"tool_use","name":"Bash","input":{"command":"cargo test","description":"Run tests"}}
                    ]
                }
            }"#,
        )
        .unwrap();

        assert_eq!(row.source, "claude");
        assert_eq!(row.session.as_deref(), Some("claude-session"));
        assert_eq!(row.kind, "ToolExec");
        assert_eq!(row.risk, Risk::Medium);
        assert!(row.message.contains("Bash"));
        assert_eq!(
            event_detail_value(&row, &["model"]).as_deref(),
            Some("claude-opus-4")
        );
        assert_eq!(event_token_usage(&row).unwrap().total, 15);
        assert_eq!(event_workspace(&row).as_deref(), Some("/work/a3s"));
    }

    #[test]
    fn parses_codex_jsonl_token_usage() {
        let row = parse_observer_line(
            r#"{
                "timestamp":"2026-06-26T08:00:00Z",
                "type":"event_msg",
                "payload":{
                    "type":"token_count",
                    "info":{
                        "last_token_usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10},
                        "model_context_window":258400
                    }
                }
            }"#,
        )
        .unwrap();

        assert_eq!(row.source, "codex");
        assert_eq!(row.kind, "LlmCall");
        assert_eq!(event_token_usage(&row).unwrap().total, 10);
        assert_eq!(
            event_detail_value(&row, &["context_window"]).as_deref(),
            Some("258400")
        );
    }

    #[test]
    fn codex_token_usage_prefers_last_turn_over_lifetime_total() {
        let row = parse_observer_line(
            r#"{
                "timestamp":"2026-06-26T08:00:00Z",
                "type":"event_msg",
                "payload":{
                    "type":"token_count",
                    "info":{
                        "total_token_usage":{"input_tokens":1000000,"output_tokens":500000,"total_tokens":1500000},
                        "last_token_usage":{"input_tokens":70,"output_tokens":30,"total_tokens":100}
                    }
                }
            }"#,
        )
        .unwrap();

        assert_eq!(event_token_usage(&row).unwrap().total, 100);
        assert_eq!(
            event_detail_value(&row, &["lifetime_total_tokens"]).as_deref(),
            Some("1500000")
        );
    }

    #[test]
    fn parses_a3s_run_record_document() {
        let rows = parse_observer_json_document(
            r#"[
                {
                    "snapshot": {
                        "id": "run-1",
                        "session_id": "session-1",
                        "workspace": "/work/a3s"
                    },
                    "events": [
                        {
                            "sequence": 1,
                            "timestamp_ms": 1782470400000,
                            "event": {"type":"tool_start","name":"shell","command":"cargo test"}
                        },
                        {
                            "sequence": 2,
                            "timestamp_ms": 1782470400100,
                            "event": {"type":"turn_end","total_tokens":123}
                        }
                    ]
                }
            ]"#,
        );

        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0].source, "a3s-code");
        assert_eq!(rows[0].session.as_deref(), Some("session-1"));
        assert_eq!(rows[0].task.as_deref(), Some("run-1"));
        assert_eq!(rows[0].kind, "ToolExec");
        assert_eq!(event_workspace(&rows[0]).as_deref(), Some("/work/a3s"));
        assert_eq!(rows[1].kind, "LlmCall");
        assert_eq!(event_token_usage(&rows[1]).unwrap().total, 123);
    }

    fn event_row(
        source: &str,
        session: Option<&str>,
        task: Option<&str>,
        kind: &str,
        message: &str,
        risk: Risk,
    ) -> EventRow {
        EventRow {
            ts: "recent".into(),
            source: source.into(),
            session: session.map(Into::into),
            task: task.map(Into::into),
            pid: None,
            ppid: None,
            kind: kind.into(),
            message: message.into(),
            details: Vec::new(),
            risk,
        }
    }

    fn process_row(pid: u32, ppid: u32, command: &str) -> ProcessRow {
        let agent = detect_agent(command);
        ProcessRow {
            pid,
            ppid,
            cpu_pct: 0.0,
            mem_pct: 0.0,
            elapsed: "00:01".into(),
            cwd: None,
            command: command.into(),
            agent,
            risk: process_risk(command, agent),
        }
    }

    fn container_row(
        id: &str,
        name: &str,
        status: &str,
        cpu_pct: Option<f32>,
        mem_pct: Option<f32>,
    ) -> ContainerRow {
        ContainerRow {
            connector: ContainerConnector::Docker,
            id: id.into(),
            name: name.into(),
            image: "img".into(),
            status: status.into(),
            inspect: ContainerInspect::default(),
            cpu_pct,
            cpu_count: None,
            cpu_usage_total_ns: None,
            mem_pct,
            mem_usage: "1MiB / 10MiB".into(),
            net_io: "-".into(),
            block_io: "-".into(),
            pids: "1".into(),
            ports: "-".into(),
        }
    }

    fn numbered_lines(count: usize) -> String {
        (0..count)
            .map(|idx| format!("line-{idx}"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn write_file(path: &Path, content: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, content).unwrap();
    }

    fn restore_var(key: &str, value: Option<std::ffi::OsString>) {
        match value {
            Some(value) => std::env::set_var(key, value),
            None => std::env::remove_var(key),
        }
    }
}
