cctop 0.13.0

An htop-like terminal monitor for AI coding agent sessions on Linux (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, Windsurf)
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
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
//! Which sessions the table shows, and the search that widens or narrows it.
//!
//! Four filters stack — live-only, age, cost floor and the query — and
//! [`App::refilter`] is the single place they are applied, because a row that
//! two of them disagree about would otherwise flicker depending on which ran
//! last. The content search sits here too rather than with the worker: the scan
//! itself is the worker's, but deciding when a query is worth reading every
//! transcript for, and what to do with hits that arrive after the user has
//! typed more, is a property of the filter.

use super::*;

/// How long typing has to pause before the query is scanned for.
///
/// A scan reads every transcript on disk, so it waits for a word rather than
/// chasing each character of one. Short enough that finishing a word and
/// looking up finds the results already there.
const SCAN_DEBOUNCE: Duration = Duration::from_millis(300);

/// Shortest query worth reading every transcript for.
const MIN_SCAN_CHARS: usize = 3;

/// `haystack.to_ascii_lowercase().contains(needle)` without the allocation.
///
/// Comparing bytes is safe on UTF-8 here: ASCII case folding never touches a
/// continuation byte, so a match can only start at a character boundary.
fn contains_ascii_ci(haystack: &str, lowercase_needle: &str) -> bool {
    let (h, n) = (haystack.as_bytes(), lowercase_needle.as_bytes());
    if n.is_empty() {
        return true;
    }
    h.len() >= n.len()
        && h.windows(n.len())
            .any(|w| w.iter().zip(n).all(|(a, b)| a.to_ascii_lowercase() == *b))
}

impl App {
    /// Apply filters and sorting, then rebuild the visible index list.
    ///
    /// Selection is tracked by session key rather than row number, so a refresh
    /// that reorders the table doesn't move the cursor off whatever the user was
    /// looking at.
    pub fn refilter(&mut self) {
        let anchor = self.selected_row().map(|r| self.row_key(r));
        let now = chrono::Utc::now();
        let now_ms = now.timestamp_millis();
        let query = self.search.to_ascii_lowercase();

        let mut visible: Vec<usize> = (0..self.sessions.len())
            .filter(|&i| {
                let s = &self.sessions[i];
                if self.live_only && !s.is_running() {
                    return false;
                }
                if let Some(age) = self.age_filter {
                    let ts = if s.last_active.is_empty() {
                        &s.started_at
                    } else {
                        &s.last_active
                    };
                    let within = crate::util::parse_ts(ts)
                        .map(|d| now_ms - d.timestamp_millis() <= age.max_age_ms())
                        .unwrap_or(false);
                    if !within {
                        return false;
                    }
                }
                if !self.matches_query(s, &query) {
                    return false;
                }
                if self.cost_floor > 0.0 {
                    // Sessions with unknown cost are kept: the floor can't say
                    // they're below it. "Included" cost (None) counts as zero.
                    let cost = if s.cost_available {
                        s.total_cost.unwrap_or(0.0)
                    } else {
                        // Unknown — can't disqualify.
                        return true;
                    };
                    if cost < self.cost_floor {
                        return false;
                    }
                }
                true
            })
            .collect();

        let col = self.sort_col;
        let asc = self.sort_asc;
        visible.sort_by(|&a, &b| {
            let ord = columns::compare(col, &self.sessions[a], &self.sessions[b], &now);
            if asc { ord } else { ord.reverse() }
        });

        // Sessions are sorted first, then each expanded one has its children
        // spliced in beneath it: subagents belong to their parent's position in
        // the table, not to the ordering the sort column would give them.
        self.visible = visible
            .into_iter()
            .flat_map(|i| {
                let session = &self.sessions[i];
                let children = if self.expanded.contains(&session.key()) {
                    session.subagents.len()
                } else {
                    0
                };
                std::iter::once(Row::Session(i))
                    .chain((0..children).map(move |index| Row::Subagent { parent: i, index }))
            })
            .collect();
        self.selected = anchor
            .and_then(|key| self.visible.iter().position(|&r| self.row_key(r) == key))
            .unwrap_or(self.selected)
            .min(self.visible.len().saturating_sub(1));
        self.ensure_available_tab();
        self.needs_redraw = true;
    }

    /// Fold this refresh's figures into the overview history buffers.
    pub(super) fn push_history(&mut self) {
        // This is a rate, not a refresh delta, so its meaning is stable when
        // the user changes --delay or a filesystem scan takes longer.
        self.global_spend.push(self.stats.spend_per_min);
        self.global_cpu.push(self.stats.total_cpu as f64);

        for s in &self.sessions {
            let Some(p) = &s.process else { continue };
            let key = s.key();
            self.cpu_history
                .entry(key.clone())
                .or_default()
                .push(p.cpu as f64);
            self.mem_history
                .entry(key)
                .or_default()
                .push(p.memory as f64 / (1024.0 * 1024.0));
        }
    }

    /// Whether the session matches the active text search.
    ///
    /// `refilter` calls [`matches_query`] directly with a query it lowercases
    /// once; this is the same predicate for callers that only have one session
    /// in hand, so the live filter and the `n`/`N` jump cannot drift apart.
    pub(super) fn matches_search(&self, s: &Session) -> bool {
        self.matches_query(s, &self.search.to_ascii_lowercase())
    }

    /// Whether a session matches `query`, which must already be lowercase.
    ///
    /// Content search widens the filter rather than replacing it: a query that
    /// names a project still finds that project's sessions, and the transcripts
    /// add whatever else mentions it. Hits only count while they belong to the
    /// query being typed — until the scan for a longer query lands, its rows are
    /// the metadata matches alone, which is a filter narrowing as you type
    /// rather than showing results for a query you have moved on from.
    pub(super) fn matches_query(&self, s: &Session, query: &str) -> bool {
        if query.is_empty() {
            return true;
        }
        // Field by field rather than one joined string. This runs per session
        // per refresh, and lowercasing them all was the whole per-refresh
        // allocation; it also stops a query matching across the seam between
        // two unrelated fields.
        let fields: [&str; 8] = [
            s.display_label(),
            &s.model,
            &s.harness,
            s.provider.as_str(),
            &s.session_id,
            &s.label_source,
            // Empty for this user's own rows, which no query can match, so
            // searching a name finds that person's sessions and nothing else.
            s.owner.as_deref().unwrap_or_default(),
            // Likewise empty for every harness but Claude Code, so `work` finds
            // that login's sessions rather than everything that mentions work.
            s.profile.as_deref().unwrap_or_default(),
        ];
        if fields.iter().any(|f| contains_ascii_ci(f, query)) {
            return true;
        }
        // The branch is derived rather than stored, so it is the one field that
        // cannot be borrowed straight off the session.
        if columns::branch_of(s).is_some_and(|b| contains_ascii_ci(&b, query)) {
            return true;
        }
        self.search_content && self.scan_query == query && self.scan_hits.contains_key(&s.key())
    }

    /// The transcript text around the selected session's content match.
    pub fn selected_snippet(&self) -> Option<&str> {
        let s = self.selected_session()?;
        (self.search_content && self.scan_query == self.search.to_ascii_lowercase())
            .then(|| self.scan_hits.get(&s.key()))
            .flatten()
            .map(String::as_str)
    }

    /// Note that the query changed, so the scan can be rescheduled.
    ///
    /// Every edit lands here, including the ones that only shorten the query:
    /// hits for a longer query are not hits for a shorter one, and leaving them
    /// applied would leave rows on screen that no longer match anything.
    pub(super) fn search_edited(&mut self) {
        self.history_cursor = None;
        self.scan_typed_at = Some(Instant::now());
        self.refilter();
    }

    /// Turn transcript searching on or off.
    pub(super) fn toggle_content_search(&mut self) {
        self.search_content = !self.search_content;
        if !self.search_content {
            // Results for a search nobody is running any more; keeping them
            // would make the next toggle show stale rows for an instant.
            self.scan_hits.clear();
            self.scan_query.clear();
        }
        self.scan_typed_at = Some(Instant::now());
        self.refilter();
    }

    /// Send the current query off to be scanned, once the typing has settled.
    ///
    /// Called every loop iteration rather than on each keystroke: a scan reads
    /// every transcript on disk, and firing one per character would spend the
    /// whole budget on prefixes of the word being typed.
    pub(super) fn tick_scan(&mut self) {
        if !self.search_content || self.scanning {
            return;
        }
        let query = self.search.to_ascii_lowercase();
        if query == self.scan_query {
            self.scan_typed_at = None;
            return;
        }
        // A one- or two-character query matches nearly every transcript, so it
        // is the most expensive scan to run and the least useful to read.
        // Deleting back to that length drops the results with it, rather than
        // leaving a count on screen for a query no longer being asked.
        if query.chars().count() < MIN_SCAN_CHARS {
            if !self.scan_query.is_empty() {
                self.scan_query.clear();
                self.scan_hits.clear();
                self.refilter();
                self.needs_redraw = true;
            }
            return;
        }
        match self.scan_typed_at {
            Some(at) if at.elapsed() < SCAN_DEBOUNCE => return,
            _ => {}
        }
        self.scan_typed_at = None;
        let targets: Vec<crate::session::search::Target> = self
            .sessions
            .iter()
            .map(crate::session::search::Target::of)
            .collect();
        if self.tx.send(Request::Scan { query, targets }).is_ok() {
            self.scanning = true;
            self.needs_redraw = true;
        }
    }

    /// Fold in a finished scan.
    pub(super) fn scanned(&mut self, query: String, hits: HashMap<String, String>) {
        self.scanning = false;
        self.scan_query = query;
        self.scan_hits = hits;
        self.refilter();
        self.needs_redraw = true;
    }

    /// Record the query that was just run, so ↑ can bring it back.
    pub(super) fn remember_query(&mut self) {
        let query = self.search.trim().to_string();
        if query.is_empty() {
            return;
        }
        // Re-running a query moves it to the front rather than adding a second
        // copy, which is what makes a short history worth walking.
        self.search_history.retain(|q| q != &query);
        self.search_history.insert(0, query);
        self.search_history
            .truncate(crate::cache::MAX_SEARCH_HISTORY);
        self.save_prefs();
    }

    /// Walk the query history: `1` towards older entries, `-1` back towards
    /// what was being typed when the walk started.
    pub(super) fn history_step(&mut self, delta: isize) {
        if self.search_history.is_empty() {
            return;
        }
        let (at, typed) = match self.history_cursor.take() {
            Some((at, typed)) => (at as isize + delta, typed),
            // Nothing walked yet: ↓ has nowhere older to come back from.
            None if delta < 0 => return,
            None => (0, self.search.clone()),
        };
        // Stepping back past the newest entry restores the partial query, which
        // is the one thing the history itself cannot hold.
        if at < 0 {
            self.search = typed;
        } else {
            let at = (at as usize).min(self.search_history.len() - 1);
            self.search = self.search_history[at].clone();
            self.history_cursor = Some((at, typed));
        }
        self.scan_typed_at = Some(Instant::now());
        self.refilter();
        self.needs_redraw = true;
    }

    /// Peel off one filter layer, narrowest first, and say which one went.
    ///
    /// One press per layer rather than all at once: filters are combined
    /// deliberately, and clearing four of them on a stray Esc would lose work
    /// that took four deliberate keystrokes to set up. Every layer that paints
    /// a badge in the footer is reachable from here, so nothing can stay on
    /// with no way to turn it off.
    /// Whether any filter layer is on, and so whether `Esc` would do anything.
    ///
    /// The same layers [`clear_one_filter`](Self::clear_one_filter) peels, in
    /// one place: a footer that offered `Esc Clear filter` with nothing to
    /// clear would be teaching a key that does nothing.
    pub(super) fn has_filter(&self) -> bool {
        !self.search.is_empty()
            || self.cost_floor > 0.0
            || self.live_only
            || self.age_filter.is_some()
            || self.tool_tab != 0
            || self.tool_live_only
    }

    pub(super) fn clear_one_filter(&mut self) {
        let cleared = if !self.search.is_empty() {
            self.search.clear();
            "Search cleared"
        } else if self.cost_floor > 0.0 {
            self.cost_floor = 0.0;
            "Cost floor cleared"
        } else if self.live_only {
            self.live_only = false;
            "Showing stopped sessions too"
        } else if self.age_filter.is_some() {
            self.age_filter = None;
            "Age filter cleared"
        } else if self.tool_tab != 0 || self.tool_live_only {
            // The Tool Activity sidebar filters a panel rather than the table,
            // so it comes last: it is the layer the user is least likely to
            // have forgotten about.
            self.tool_tab = 0;
            self.tool_live_only = false;
            self.tool_follow = true;
            "Tool Activity filter cleared"
        } else {
            return;
        };
        self.refilter();
        self.save_prefs();
        self.set_status(cleared);
    }

    /// Jump to the next/previous session matching the active search, wrapping
    /// around both ends. With no search active every visible session matches.
    pub(super) fn cycle_matches(&mut self, delta: isize) {
        if self.visible.is_empty() {
            return;
        }
        // Positions rather than rows: a child row matches on its parent's text,
        // so several rows can share one session and `position` would keep
        // sending the cursor back to the first of them.
        let matches: Vec<usize> = self
            .visible
            .iter()
            .enumerate()
            .filter(|(_, row)| self.matches_search(&self.sessions[row.session()]))
            .map(|(at, _)| at)
            .collect();
        if matches.is_empty() {
            return;
        }
        let pos = matches.iter().position(|&at| at == self.selected);
        let n = matches.len() as isize;
        let next = ((pos.unwrap_or(0) as isize + delta).rem_euclid(n)) as usize;
        self.selected = matches[next];
        self.ensure_available_tab();
        self.needs_redraw = true;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ui::tests::{key, session, test_app};
    use ratatui::crossterm::event::KeyCode;
    #[test]
    fn live_filter_hides_stopped_sessions() {
        let mut app = test_app();
        app.sessions = vec![session("a", true, "x"), session("b", false, "y")];
        app.live_only = true;
        app.refilter();
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.sessions[app.visible[0].session()].session_id, "a");
    }

    #[test]
    fn live_filter_includes_transcript_inferred_cursor_session() {
        let mut app = test_app();
        let mut cursor = Session::new(Provider::Cursor, "cursor".into());
        cursor.started_at = chrono::Utc::now().to_rfc3339();
        cursor.last_active = cursor.started_at.clone();
        cursor.inferred_running = true;
        app.sessions = vec![cursor];
        app.live_only = true;
        app.refilter();
        assert_eq!(app.visible.len(), 1);
        assert!(app.sessions[app.visible[0].session()].is_running());
        assert!(app.sessions[app.visible[0].session()].process.is_none());
    }

    #[test]
    fn search_matches_label_and_id_case_insensitively() {
        let mut app = test_app();
        app.sessions = vec![
            session("aaa", false, "/home/x/alpha"),
            session("bbb", false, "/home/x/beta"),
        ];
        app.search = "alpha".into();
        app.refilter();
        assert_eq!(app.visible.len(), 1);

        app.search = "BBB".into();
        app.refilter();
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.sessions[app.visible[0].session()].session_id, "bbb");
    }

    /// The table abbreviates the working directory to fit its column, so the
    /// filter has to match the full path — otherwise the directory someone
    /// types is one the row is not admitting to.
    #[test]
    fn search_matches_the_full_working_directory() {
        let mut app = test_app();
        let mut deep = session("aaa", false, "/home/x/work/api/services/billing");
        // What the table actually shows for that row.
        deep.abbrev_label = "…/billing".into();
        app.sessions = vec![deep, session("bbb", false, "/home/x/other")];

        app.search = "work/api".into();
        app.refilter();
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.sessions[app.visible[0].session()].session_id, "aaa");
    }

    /// Content hits widen the filter, and only for the query they were found
    /// for: a scan that lands after the user has typed another character must
    /// not put its rows back on screen.
    #[test]
    fn transcript_hits_widen_the_filter_for_their_own_query_only() {
        let mut app = test_app();
        app.sessions = vec![
            session("aaa", false, "/home/x/alpha"),
            session("bbb", false, "/home/x/beta"),
        ];
        let hit = |key: &str| HashMap::from([(key.to_string(), "…flywheel…".to_string())]);

        // No content search: a word only the transcript knows finds nothing.
        app.search = "flywheel".into();
        app.refilter();
        assert!(app.visible.is_empty());

        // With one, the session whose transcript matched joins the metadata
        // matches rather than replacing them.
        app.search_content = true;
        app.scanned("flywheel".into(), hit("claude:bbb"));
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.sessions[app.visible[0].session()].session_id, "bbb");

        app.search = "alpha".into();
        app.scanned("alpha".into(), hit("claude:bbb"));
        assert_eq!(
            app.visible.len(),
            2,
            "metadata and content matches, not one"
        );

        // Hits belonging to a query that has since been extended are ignored.
        app.search = "alphabet".into();
        app.refilter();
        assert!(app.visible.is_empty());
        assert!(app.selected_snippet().is_none());
    }

    /// Turning content search off has to take its results with it, or the rows
    /// it found stay on screen with nothing matching them.
    #[test]
    fn leaving_content_search_drops_its_rows() {
        let mut app = test_app();
        app.sessions = vec![session("aaa", false, "/home/x/alpha")];
        app.search = "flywheel".into();
        app.search_content = true;
        app.scanned(
            "flywheel".into(),
            HashMap::from([("claude:aaa".into(), "…flywheel…".into())]),
        );
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.selected_snippet(), Some("…flywheel…"));

        app.toggle_content_search();
        assert!(app.visible.is_empty());
        assert!(app.selected_snippet().is_none());
    }

    /// A scan is worth its cost only once there is a word to look for, and only
    /// for a query that isn't already answered.
    #[test]
    fn a_scan_waits_for_a_word_and_for_the_typing_to_settle() {
        let mut app = test_app();
        app.sessions = vec![session("aaa", false, "/home/x/alpha")];
        app.search_content = true;

        // Too short to be worth reading every transcript for.
        app.search = "fl".into();
        app.search_edited();
        app.scan_typed_at = None;
        app.tick_scan();
        assert!(!app.scanning);

        // Long enough, but the user is still typing.
        app.search = "flywheel".into();
        app.search_edited();
        app.tick_scan();
        assert!(!app.scanning, "fired before the debounce elapsed");

        // Settled.
        app.scan_typed_at = Some(Instant::now() - SCAN_DEBOUNCE);
        app.tick_scan();
        assert!(app.scanning);

        // And the answer to a query already scanned for is not scanned again.
        app.scanned("flywheel".into(), HashMap::new());
        app.tick_scan();
        assert!(!app.scanning);
    }

    /// ↑ walks back through past queries and ↓ returns, ending on whatever was
    /// half-typed when the walk began.
    #[test]
    fn the_query_history_walks_both_ways() {
        let mut app = test_app();
        app.search_history = vec!["newest".into(), "older".into()];

        app.search = "half-typ".into();
        app.history_step(1);
        assert_eq!(app.search, "newest");
        app.history_step(1);
        assert_eq!(app.search, "older");
        // The end of the history is a floor, not a wrap.
        app.history_step(1);
        assert_eq!(app.search, "older");

        app.history_step(-1);
        assert_eq!(app.search, "newest");
        app.history_step(-1);
        assert_eq!(app.search, "half-typ");
        // Nothing older to come back from any more.
        app.history_step(-1);
        assert_eq!(app.search, "half-typ");
    }

    /// Re-running a query moves it to the front rather than filling the history
    /// with copies of the search someone runs most.
    #[test]
    fn a_repeated_query_is_remembered_once() {
        let mut app = test_app();
        app.search = "alpha".into();
        app.remember_query();
        app.search = "beta".into();
        app.remember_query();
        app.search = "alpha".into();
        app.remember_query();
        assert_eq!(app.search_history, vec!["alpha", "beta"]);

        // An abandoned modal leaves nothing behind.
        app.search = "   ".into();
        app.remember_query();
        assert_eq!(app.search_history, vec!["alpha", "beta"]);
    }

    #[test]
    fn age_filter_excludes_old_sessions() {
        let mut app = test_app();
        let mut old = session("old", false, "/x");
        old.last_active = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
        app.sessions = vec![session("new", false, "/x"), old];
        app.age_filter = Some(AgeFilter::Day);
        app.refilter();
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.sessions[app.visible[0].session()].session_id, "new");
    }

    #[test]
    fn cost_floor_filters_by_total_cost() {
        let mut app = test_app();
        let mut cheap = session("cheap", false, "/x");
        cheap.total_cost = Some(0.50);
        let mut pricey = session("pricey", false, "/x");
        pricey.total_cost = Some(5.00);
        app.sessions = vec![cheap, pricey];
        app.cost_floor = 1.0;
        app.refilter();
        assert_eq!(app.visible.len(), 1);
        assert_eq!(app.sessions[app.visible[0].session()].session_id, "pricey");
    }

    #[test]
    fn cost_floor_zero_shows_everything() {
        let mut app = test_app();
        app.sessions = vec![session("a", false, "/x"), session("b", false, "/x")];
        app.cost_floor = 0.0;
        app.refilter();
        assert_eq!(app.visible.len(), 2);
    }

    #[test]
    fn cycle_matches_wraps_around_search_matches() {
        let mut app = test_app();
        let now = chrono::Utc::now().to_rfc3339();
        let mk = |id: &str, label: &str| {
            let mut s = session(id, false, label);
            s.last_active = now.clone();
            s.started_at = now.clone();
            s
        };
        app.sessions = vec![mk("a", "/x/match"), mk("b", "/y"), mk("c", "/z/match")];
        app.search = "match".into();
        app.refilter();
        assert_eq!(app.visible.len(), 2);
        app.selected = 0;
        app.cycle_matches(1);
        assert_eq!(app.selected_session().unwrap().session_id, "c");
        app.cycle_matches(1);
        assert_eq!(app.selected_session().unwrap().session_id, "a");
        app.cycle_matches(-1);
        assert_eq!(app.selected_session().unwrap().session_id, "c");
    }

    /// Every filter that paints a badge must be reachable from Esc, one press
    /// at a time.
    #[test]
    fn esc_clears_one_filter_layer_per_press() {
        let mut app = test_app();
        app.sessions = vec![session("a", true, "/x")];
        app.search = "x".into();
        app.cost_floor = 1.0;
        app.live_only = true;
        app.age_filter = Some(AgeFilter::Day);
        app.tool_tab = 2;
        app.refilter();

        for expected in 1..=5 {
            app.on_key(key(KeyCode::Esc));
            let left = [
                !app.search.is_empty(),
                app.cost_floor > 0.0,
                app.live_only,
                app.age_filter.is_some(),
                app.tool_tab != 0,
            ]
            .iter()
            .filter(|on| **on)
            .count();
            assert_eq!(
                left,
                5 - expected,
                "press {expected} cleared the wrong count"
            );
        }
        // A sixth press is harmless.
        app.on_key(key(KeyCode::Esc));
        assert_eq!(app.mode, Mode::List);
    }

    /// The live filter and the n/N jump must agree, because they are now the
    /// same predicate.
    #[test]
    fn refilter_and_matches_search_agree() {
        let mut app = test_app();
        app.sessions = vec![
            session("aaa", false, "/home/x/Alpha"),
            session("bbb", false, "/home/x/beta"),
        ];
        for query in ["alpha", "ALPHA", "x/", "", "nomatch"] {
            app.search = query.into();
            app.refilter();
            let by_predicate: Vec<usize> = (0..app.sessions.len())
                .filter(|&i| app.matches_search(&app.sessions[i]))
                .collect();
            assert_eq!(app.visible.len(), by_predicate.len(), "query {query:?}");
        }
    }

    #[test]
    fn case_insensitive_contains_matches_std() {
        for (h, n) in [
            ("Alpha/Beta", "beta"),
            ("Alpha", "alpha"),
            ("Alpha", ""),
            ("a", "aa"),
            ("héllo-World", "world"),
            ("nope", "zz"),
        ] {
            assert_eq!(
                contains_ascii_ci(h, n),
                h.to_ascii_lowercase().contains(n),
                "{h:?} / {n:?}"
            );
        }
    }
}