agentop 0.7.0

A TUI process inspector for Claude Code and OpenAI Codex CLI — like top for AI coding agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
use std::collections::HashMap;

use super::filter::{is_target_process, process_kind, ActivityState, ProcessKind};
use super::info::ProcessInfo;

/// Aggregated resource usage for a process and all of its descendants.
///
/// Computed by [`compute_subtree_stats`] after the forest is built, so every
/// root node carries the rolled-up totals for its entire subtree.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SubtreeStats {
    /// Sum of CPU usage (%) for this process and all descendants.
    pub total_cpu: f32,
    /// Sum of resident memory (bytes) for this process and all descendants.
    pub total_memory: u64,
    /// Total number of processes in the subtree, including self.
    pub process_count: usize,
}

/// A node in the process tree, holding one process and its direct children.
#[derive(Debug, Clone, PartialEq)]
pub struct ProcessNode {
    /// Snapshot data for this process.
    pub info: ProcessInfo,
    /// Direct child processes (recursively nested).
    pub children: Vec<ProcessNode>,
    /// Distance from the tree root (root nodes have depth 0).
    pub depth: usize,
    /// Whether child processes are currently shown in the flattened view.
    pub expanded: bool,
    /// `true` for nodes that are top-level targets (not merely child subtrees).
    pub is_root: bool,
    /// Aggregated CPU, memory, and count for this node and all descendants.
    pub subtree_stats: SubtreeStats,
}

/// A single row in the flat, scrollable list derived from [`ProcessNode`] trees.
#[derive(Debug, Clone, PartialEq)]
pub struct FlatEntry {
    /// Owned snapshot for this row.
    pub info: ProcessInfo,
    /// Indentation depth.
    pub depth: usize,
    /// `true` when this entry has no parent in the visible forest.
    pub is_root: bool,
    /// Mirror of [`ProcessNode::expanded`].
    pub expanded: bool,
    /// `true` when this node has at least one child process.
    pub has_children: bool,
    /// `true` when this is the last sibling within its parent's child list.
    pub is_last_sibling: bool,
    /// Detected kind (Claude / Codex), or `None` for plain child processes.
    pub kind: Option<ProcessKind>,
    /// Aggregated CPU, memory, and count copied from the corresponding [`ProcessNode`].
    pub subtree_stats: SubtreeStats,
    /// Activity state for root agent processes; `None` for non-root entries.
    /// Injected by [`crate::app::App::rebuild_flat_list`] after flattening.
    pub activity: Option<ActivityState>,
}

// ---------------------------------------------------------------------------
// Forest construction
// ---------------------------------------------------------------------------

/// Build a forest of [`ProcessNode`] trees from a flat process snapshot list.
///
/// Only target processes (Claude / Codex, identified by [`is_target_process`])
/// become root nodes. All processes are then considered as potential children
/// when their `parent_pid` matches any node already in the forest.
///
/// Subtree statistics are computed for every root after the tree is assembled,
/// so each node's [`ProcessNode::subtree_stats`] is fully populated.
///
/// # Arguments
///
/// * `processes` - Full slice of process snapshots from the current refresh cycle.
///
/// # Returns
///
/// A `Vec` of root-level [`ProcessNode`] values, each potentially containing a
/// recursive `children` subtree.
pub fn build_forest(processes: &[ProcessInfo]) -> Vec<ProcessNode> {
    // Map parent_pid -> list of child ProcessInfo refs for O(1) child lookup.
    let mut children_map: HashMap<u32, Vec<&ProcessInfo>> = HashMap::new();
    for p in processes {
        if let Some(ppid) = p.parent_pid {
            children_map.entry(ppid).or_default().push(p);
        }
    }

    // Only target processes become tree roots.
    let mut roots: Vec<ProcessNode> = processes
        .iter()
        .filter(|p| is_target_process(p))
        .map(|p| build_node(p, &children_map, 0, true))
        .collect();

    // Compute aggregated stats bottom-up for every root subtree.
    for root in roots.iter_mut() {
        compute_subtree_stats(root);
    }

    roots
}

/// Recursively build a [`ProcessNode`], attaching child subtrees.
///
/// Recursion depth is bounded by the OS process tree depth, which is
/// typically shallow (< 20 levels). No cycle guard is needed because
/// the kernel guarantees acyclic parent-child relationships.
///
/// Note: [`SubtreeStats`] is left at `Default` here; [`compute_subtree_stats`]
/// fills it in a second pass after the entire tree is assembled.
fn build_node<'a>(
    info: &'a ProcessInfo,
    children_map: &HashMap<u32, Vec<&'a ProcessInfo>>,
    depth: usize,
    is_root: bool,
) -> ProcessNode {
    let children = children_map
        .get(&info.pid)
        .map(|kids| {
            kids.iter()
                .map(|child| build_node(child, children_map, depth + 1, false))
                .collect()
        })
        .unwrap_or_default();

    ProcessNode {
        info: info.clone(),
        children,
        depth,
        // Default to expanded so the tree is fully visible on first render.
        expanded: true,
        is_root,
        subtree_stats: SubtreeStats::default(),
    }
}

/// Recursively compute [`SubtreeStats`] for `node` and all of its descendants.
///
/// This is a post-order traversal: children are processed first so that
/// their stats are available when computing the parent's aggregate.
///
/// # Arguments
///
/// * `node` - The node whose subtree stats should be populated (mutated in place).
pub fn compute_subtree_stats(node: &mut ProcessNode) {
    // Recurse into children first (post-order) so their stats are ready.
    for child in node.children.iter_mut() {
        compute_subtree_stats(child);
    }

    // Self contribution.
    let mut stats = SubtreeStats {
        total_cpu: node.info.cpu_usage,
        total_memory: node.info.memory_bytes,
        process_count: 1,
    };

    // Accumulate each child's already-computed subtree.
    for child in &node.children {
        stats.total_cpu += child.subtree_stats.total_cpu;
        stats.total_memory += child.subtree_stats.total_memory;
        stats.process_count += child.subtree_stats.process_count;
    }

    node.subtree_stats = stats;
}

// ---------------------------------------------------------------------------
// Flattening
// ---------------------------------------------------------------------------

/// Flatten the forest into an ordered list of visible rows.
///
/// Collapsed nodes' children are skipped entirely, matching typical tree-view
/// behaviour. The returned `Vec` is in display order (parent before children).
pub fn flatten_visible(forest: &[ProcessNode]) -> Vec<FlatEntry> {
    let mut out = Vec::new();
    let last_idx = forest.len().saturating_sub(1);
    for (i, node) in forest.iter().enumerate() {
        flatten_node(node, &mut out, i == last_idx);
    }
    out
}

/// Recursively push a node (and its visible descendants) onto `out`.
fn flatten_node(node: &ProcessNode, out: &mut Vec<FlatEntry>, is_last_sibling: bool) {
    out.push(FlatEntry {
        info: node.info.clone(),
        depth: node.depth,
        is_root: node.is_root,
        expanded: node.expanded,
        has_children: !node.children.is_empty(),
        is_last_sibling,
        kind: process_kind(&node.info),
        // Copy the pre-computed aggregate so the flat list can render rollups
        // and the detail view can display subtree totals without re-traversing.
        subtree_stats: node.subtree_stats,
        // Activity is injected by App::rebuild_flat_list after flattening.
        activity: None,
    });

    if node.expanded {
        let last_child = node.children.len().saturating_sub(1);
        for (i, child) in node.children.iter().enumerate() {
            flatten_node(child, out, i == last_child);
        }
    }
}

// ---------------------------------------------------------------------------
// Expansion state helpers
// ---------------------------------------------------------------------------

/// Toggle the `expanded` flag on the node whose pid matches `target_pid`.
///
/// Performs a depth-first search through the entire forest.
pub fn toggle_expand(forest: &mut [ProcessNode], target_pid: u32) {
    for node in forest.iter_mut() {
        if node.info.pid == target_pid {
            node.expanded = !node.expanded;
            return;
        }
        toggle_expand(&mut node.children, target_pid);
    }
}

/// Snapshot the current pid → expanded state for every node in the forest.
///
/// Use this before rebuilding the forest so that [`preserve_expansion`] can
/// restore the UI state after a data refresh.
pub fn collect_expansion(forest: &[ProcessNode]) -> HashMap<u32, bool> {
    let mut map = HashMap::new();
    for node in forest {
        map.insert(node.info.pid, node.expanded);
        // Recurse into children, merging their entries directly into `map`.
        map.extend(collect_expansion(&node.children));
    }
    map
}

/// Restore expansion states from a previously collected pid → bool map.
///
/// Nodes whose pid is absent from `old_states` are left at their current value
/// (defaulting to expanded for newly appearing processes).
pub fn preserve_expansion(forest: &mut [ProcessNode], old_states: &HashMap<u32, bool>) {
    for node in forest.iter_mut() {
        if let Some(&was_expanded) = old_states.get(&node.info.pid) {
            node.expanded = was_expanded;
        }
        preserve_expansion(&mut node.children, old_states);
    }
}

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

    fn proc(pid: u32, parent: Option<u32>, name: &str) -> ProcessInfo {
        ProcessInfo {
            pid,
            parent_pid: parent,
            name: name.to_string(),
            cmd: vec![name.to_string()],
            exe_path: None,
            cwd: None,
            cpu_usage: 0.0,
            memory_bytes: 0,
            status: "Run".to_string(),
            environ_count: 0,
            start_time: 0,
            run_time: 0,
        }
    }

    /// Build a `ProcessInfo` with explicit CPU and memory values for stats tests.
    fn proc_with_resources(
        pid: u32,
        parent: Option<u32>,
        name: &str,
        cpu: f32,
        mem: u64,
    ) -> ProcessInfo {
        ProcessInfo {
            pid,
            parent_pid: parent,
            name: name.to_string(),
            cmd: vec![name.to_string()],
            exe_path: None,
            cwd: None,
            cpu_usage: cpu,
            memory_bytes: mem,
            status: "Run".to_string(),
            environ_count: 0,
            start_time: 0,
            run_time: 0,
        }
    }

    #[test]
    fn build_forest_finds_roots() {
        let procs = vec![
            proc(1, None, "claude"),
            proc(2, Some(1), "node"),
            proc(3, None, "bash"),
        ];
        let forest = build_forest(&procs);
        // Only the claude root should appear; bash is not a target process.
        assert_eq!(forest.len(), 1);
        assert_eq!(forest[0].info.pid, 1);
        assert_eq!(forest[0].children.len(), 1);
        assert_eq!(forest[0].children[0].info.pid, 2);
    }

    #[test]
    fn flatten_respects_expansion() {
        let procs = vec![proc(1, None, "claude"), proc(2, Some(1), "node")];
        let mut forest = build_forest(&procs);
        let flat = flatten_visible(&forest);
        // Both nodes visible when expanded (the default).
        assert_eq!(flat.len(), 2);

        toggle_expand(&mut forest, 1);
        let flat = flatten_visible(&forest);
        // Only the root visible when collapsed.
        assert_eq!(flat.len(), 1);
    }

    #[test]
    fn collect_and_preserve_expansion() {
        let procs = vec![proc(1, None, "claude"), proc(2, Some(1), "node")];
        let mut forest = build_forest(&procs);
        toggle_expand(&mut forest, 1);

        let states = collect_expansion(&forest);
        assert_eq!(states.get(&1), Some(&false));

        let mut new_forest = build_forest(&procs);
        preserve_expansion(&mut new_forest, &states);
        assert!(!new_forest[0].expanded);
    }

    #[test]
    fn empty_process_list() {
        let forest = build_forest(&[]);
        assert!(forest.is_empty());
        let flat = flatten_visible(&forest);
        assert!(flat.is_empty());
    }

    // -------------------------------------------------------------------------
    // SubtreeStats tests
    // -------------------------------------------------------------------------

    #[test]
    fn test_subtree_stats_leaf_node() {
        // A single root with no children: stats equal the node itself.
        let procs = vec![proc_with_resources(1, None, "claude", 2.5, 1024)];
        let forest = build_forest(&procs);
        let stats = forest[0].subtree_stats;
        assert_eq!(stats.process_count, 1);
        assert!((stats.total_cpu - 2.5).abs() < 1e-4, "cpu mismatch");
        assert_eq!(stats.total_memory, 1024);
    }

    #[test]
    fn test_subtree_stats_with_children() {
        // Root (cpu=1.0, mem=100) + two children (cpu=2.0/3.0, mem=200/300).
        let procs = vec![
            proc_with_resources(1, None, "claude", 1.0, 100),
            proc_with_resources(2, Some(1), "node", 2.0, 200),
            proc_with_resources(3, Some(1), "node", 3.0, 300),
        ];
        let forest = build_forest(&procs);
        let stats = forest[0].subtree_stats;
        assert_eq!(stats.process_count, 3);
        assert!((stats.total_cpu - 6.0).abs() < 1e-4, "cpu mismatch");
        assert_eq!(stats.total_memory, 600);
    }

    #[test]
    fn test_subtree_stats_deep_tree() {
        // Three levels: root -> child -> grandchild.
        let procs = vec![
            proc_with_resources(1, None, "claude", 1.0, 10),
            proc_with_resources(2, Some(1), "node", 1.0, 20),
            proc_with_resources(3, Some(2), "node", 1.0, 30),
        ];
        let forest = build_forest(&procs);
        // Root should aggregate all three levels.
        let root_stats = forest[0].subtree_stats;
        assert_eq!(root_stats.process_count, 3);
        assert!((root_stats.total_cpu - 3.0).abs() < 1e-4, "root cpu");
        assert_eq!(root_stats.total_memory, 60);

        // The middle node's subtree covers itself + grandchild only.
        let child_stats = forest[0].children[0].subtree_stats;
        assert_eq!(child_stats.process_count, 2);
        assert!((child_stats.total_cpu - 2.0).abs() < 1e-4, "child cpu");
        assert_eq!(child_stats.total_memory, 50);
    }

    #[test]
    fn test_subtree_stats_in_flat_entry() {
        // Verify that flatten_visible copies subtree_stats from the node.
        let procs = vec![
            proc_with_resources(1, None, "claude", 4.0, 400),
            proc_with_resources(2, Some(1), "node", 1.0, 100),
        ];
        let forest = build_forest(&procs);
        let flat = flatten_visible(&forest);

        // The root FlatEntry should carry the aggregated stats.
        let root_flat = flat
            .iter()
            .find(|e| e.info.pid == 1)
            .expect("root not found");
        assert_eq!(root_flat.subtree_stats.process_count, 2);
        assert!((root_flat.subtree_stats.total_cpu - 5.0).abs() < 1e-4);
        assert_eq!(root_flat.subtree_stats.total_memory, 500);

        // The child FlatEntry's subtree_stats should equal itself (leaf).
        let child_flat = flat
            .iter()
            .find(|e| e.info.pid == 2)
            .expect("child not found");
        assert_eq!(child_flat.subtree_stats.process_count, 1);
        assert!((child_flat.subtree_stats.total_cpu - 1.0).abs() < 1e-4);
        assert_eq!(child_flat.subtree_stats.total_memory, 100);
    }
}