cctop 0.23.0

An htop-like terminal monitor for AI coding agent sessions on Linux (Claude Code, Codex, Cursor, Devin, 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
//! ↑ ↑ ↓ ↓ ← → ← → b a, anywhere. The same again to go home.
//!
//! Or switch a Claude Code session cctop is watching to ultracode, with
//! `/effort`: the party starts itself, and when every session that started
//! it has switched back to another effort, it goes home by itself too. Codex
//! has no effort switch, so there it is a prompt that says `ultracode`, and
//! only the code ends that party. All of it is read from the transcript, so
//! it needs no hook. Only a switch newer than the last one heard, and never
//! one from before cctop started, starts it, so a session left in ultracode
//! last week does not throw a party every time the dashboard opens.
//!
//! Heard in every mode and on every tab, ahead of everything else that reads
//! the keyboard: the arrows are what move you between panels and tabs, so a
//! code only listened for in one place is carried out of it by its own keys.
//!
//! An easter egg, and so built to cost nothing when it is not running: one
//! counter advanced per key, and a paint pass that returns before touching the
//! buffer. While it runs, the screen is repainted after it has been drawn —
//! colours only, never a symbol, so every number and every agent's output
//! still reads and every click still lands — plus an equaliser where the
//! footer was.
//!
//! Like the rest of [`super::effects`], nothing is kept between frames: every
//! frame is a pure function of how long the party has been going, so the same
//! instant always draws the same floor.
//!
//! The beat is a soft thump rather than a strobe. At 128 BPM it lands a little
//! over twice a second, under the three-a-second line past which flashing is a
//! photosensitivity hazard, and its peak lifts the ground to at most a third of
//! full brightness rather than flashing it white.

mod sound;

use super::effects::nearest_indexed;
use super::*;
use crossterm::event::KeyCode;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;
use ratatui::style::Color;

/// The code, as the keys it is typed with.
pub(super) const CODE: [KeyCode; 10] = [
    KeyCode::Up,
    KeyCode::Up,
    KeyCode::Down,
    KeyCode::Down,
    KeyCode::Left,
    KeyCode::Right,
    KeyCode::Left,
    KeyCode::Right,
    KeyCode::Char('b'),
    KeyCode::Char('a'),
];

/// House tempo.
const BPM: f32 = 128.0;

/// How far along the code the keyboard is, and when the party started.
#[derive(Debug, Default)]
pub struct Rave {
    heard: usize,
    since: Option<Instant>,
    /// The music, while there is a party and something to play it with.
    sound: Option<sound::Sound>,
    /// The newest `ultracode` prompt already answered, so one prompt starts
    /// one party: going home is not undone by the next refresh reading the
    /// same transcript again.
    ultracode_heard: Option<chrono::DateTime<chrono::Utc>>,
    /// Whether ultracode started this party, and so may end it. A party
    /// started with the code is the code's to end.
    by_ultracode: bool,
    /// The agents in ultracode now, by the pid their row is running as: the
    /// tabs showing them are where an ultracode party is shown.
    ultracode_pids: Vec<u32>,
}

/// What a key was to the code.
#[derive(Debug, PartialEq, Eq)]
pub enum Heard {
    /// Nothing to do with it: the key does what it always does.
    Pass,
    /// Part of the code and not harmless alone — `b` jumps to a bell, and in a
    /// pane both letters would be typed at the agent — so kept from whatever
    /// else would have taken it. The arrows pass through: they only move.
    Swallow,
    /// The code is complete: the party starts, or ends.
    Toggle,
}

impl Rave {
    /// Take one key, returning what it is to the code.
    pub fn hear(&mut self, code: KeyCode) -> Heard {
        if code != CODE[self.heard] {
            // The only overlap the code has with itself is its opening run of
            // ups: a third ↑ after two is still two ups in, not one.
            self.heard = match code {
                KeyCode::Up if self.heard == 2 => 2,
                KeyCode::Up => 1,
                _ => 0,
            };
            return Heard::Pass;
        }
        self.heard += 1;
        match self.heard {
            n if n == CODE.len() => {
                self.heard = 0;
                self.since = match self.since {
                    Some(_) => None,
                    None => Some(Instant::now()),
                };
                Heard::Toggle
            }
            n if n > 8 => Heard::Swallow,
            _ => Heard::Pass,
        }
    }

    /// How long the party has been going, while it is.
    pub fn elapsed(&self) -> Option<Duration> {
        self.since.map(|since| since.elapsed())
    }
}

impl App {
    /// Feed a key to the code, `true` when it was the code's to keep.
    pub(super) fn hear_rave(&mut self, key: crossterm::event::KeyEvent) -> bool {
        let code = match key.modifiers.is_empty() {
            true => key.code,
            false => KeyCode::Null,
        };
        match self.rave.hear(code) {
            Heard::Pass => false,
            Heard::Swallow => true,
            Heard::Toggle => {
                self.rave.by_ultracode = false;
                match self.rave.since {
                    Some(_) => self.start_rave("↑↑↓↓←→←→ba"),
                    None => {
                        // Dropping it is what stops the player.
                        self.rave.sound = None;
                        self.set_status("Lights up. Back to work")
                    }
                }
                true
            }
        }
    }

    /// Start the music and say so, the lights having just come on for `why`.
    fn start_rave(&mut self, why: &str) {
        if theme::no_color() {
            self.rave.since = None;
            self.set_status("No colour, no party");
            return;
        }
        self.rave.since.get_or_insert_with(Instant::now);
        self.rave.sound = sound::Sound::start();
        self.set_status(format!(
            "♫ {why}: rave mode, with {} if you are online. ↑↑↓↓←→←→ba to go home",
            sound::STATION
        ));
    }

    /// Start the party when a session has gone into ultracode since the last
    /// time one did, and end it when ultracode started it and no session is
    /// in ultracode any more. See the module docs.
    ///
    /// Called whenever the rows change. A party already going is left alone
    /// when another session switches: it asks for the lights, and they are on.
    pub(super) fn hear_ultracode(&mut self) {
        let parse = |ts: &str| {
            chrono::DateTime::parse_from_rfc3339(ts)
                .ok()
                .map(|t| t.with_timezone(&chrono::Utc))
        };
        let start = parse(&self.started_at);
        // Only switches since cctop started: one from before it is neither a
        // reason to start nor to keep going.
        let live: Vec<(chrono::DateTime<chrono::Utc>, Option<u32>)> = self
            .sessions
            .iter()
            .filter(|s| matches!(s.provider, Provider::Claude | Provider::Codex))
            .filter_map(|s| Some((s.in_ultracode().and_then(parse)?, s.root_pid())))
            .filter(|(on, _)| start.is_none_or(|start| *on > start))
            .collect();
        self.rave.ultracode_pids = live.iter().filter_map(|(_, pid)| *pid).collect();
        let newest = live.iter().map(|(on, _)| *on).max();
        match newest {
            Some(on) if self.rave.ultracode_heard.is_none_or(|heard| on > heard) => {
                self.rave.ultracode_heard = Some(on);
                if self.rave.since.is_none() {
                    self.start_rave("ultracode");
                    self.rave.by_ultracode = self.rave.since.is_some();
                }
            }
            None if self.rave.by_ultracode && self.rave.since.is_some() => {
                self.rave.by_ultracode = false;
                self.rave.since = None;
                // Dropping it is what stops the player.
                self.rave.sound = None;
                self.set_status("Ultracode off. Lights up");
            }
            _ => {}
        }
    }

    /// Whether the screen is dancing, and wants a frame every
    /// [`effects::FRAME`] to do it.
    ///
    /// A party the code started is everywhere. One ultracode started is the
    /// session's own, so it is shown only on the tab running that session,
    /// and the rest of cctop carries on as it was. An agent in ultracode that
    /// no tab here is running, in a terminal of its own, has no tab to be
    /// shown on, so its party is everywhere instead: otherwise the switch
    /// would do nothing at all that anyone could see.
    pub fn raving(&self) -> bool {
        if self.rave.since.is_none() {
            return false;
        }
        if !self.rave.by_ultracode {
            return true;
        }
        let tabs: Vec<usize> = self
            .rave
            .ultracode_pids
            .iter()
            .filter_map(|&pid| self.tab_running(pid))
            .map(|(at, _)| at + 1)
            .collect();
        tabs.is_empty() || tabs.contains(&self.tab)
    }
}

/// Beats to a phrase: eight bars of four, the unit a DJ builds and drops on.
const PHRASE: f32 = 32.0;

/// The last this many beats of every phrase are the build.
const BUILD: f32 = 8.0;

/// How far into the build toward the next drop the party is at `beats`, `0.0`
/// outside one and rising to `1.0` on the beat before the drop.
///
/// The first phrase has no build: the party starts on a drop, which is what
/// typing the code is.
fn build(beats: f32) -> f32 {
    let into = beats.rem_euclid(PHRASE) - (PHRASE - BUILD);
    (into / BUILD).clamp(0.0, 1.0)
}

/// Whether the drop is still landing: the first two beats of every phrase but
/// the first, which never had a build to drop from.
fn dropping(beats: f32) -> bool {
    beats >= PHRASE && beats.rem_euclid(PHRASE) < 2.0
}

/// Repaint `buf` as the party is `t` in, with the equaliser across `footer`.
///
/// In layers, back to front: a ground that thumps on every beat and rises
/// through each build, a ring spreading out from the middle on the beat, two
/// pairs of lasers turning against each other, glints of a mirror ball on
/// empty ground, and over all of it the rainbow the text is written in. Every
/// layer moves the colours of a cell; only the glints write a symbol, and only
/// where there was nothing to read.
pub fn paint(buf: &mut Buffer, footer: Rect, t: Duration, truecolor: bool) {
    let secs = t.as_secs_f32();
    let beats = secs * BPM / 60.0;
    let beat = beats.floor();
    let phase = beats - beat;
    // A sharp attack at each beat, decaying well before the next.
    let thump = (-phase * 5.0).exp();
    let build = build(beats);
    // The ground takes a new colour every beat, a fifth of the wheel on so
    // that consecutive beats never sit beside each other; through a build it
    // climbs, so the drop is felt as the release of something.
    let ground_hue = (beat * 72.0) % 360.0;
    let ground_value = 0.08 + 0.2 * thump + 0.12 * build;
    // The rainbow rolls faster as the build climbs. Its extra travel is a
    // whole number of turns by the drop, so where it resets the colours are
    // exactly where they would have been.
    let roll = secs * 180.0 + 720.0 * build * build * build;

    let area = buf.area;
    let cx = f32::from(area.x) + f32::from(area.width) / 2.0;
    let cy = f32::from(area.y) + f32::from(area.height) / 2.0;
    // A cell is about twice as tall as it is wide, so distances are measured
    // in rows with columns halved — or the ring would be an ellipse, and a
    // laser at 45° would lie flatter than it should.
    let reach = (f32::from(area.width) / 4.0).hypot(f32::from(area.height) / 2.0);
    let ring_at = phase * reach * 1.2;
    let ring_fade = 1.0 - phase;
    let beams = [
        (secs * 0.5, 120.0),
        (secs * 0.5 + std::f32::consts::FRAC_PI_2, 120.0),
        (-secs * 0.35 + 0.4, 300.0),
        (-secs * 0.35 + 0.4 + std::f32::consts::FRAC_PI_2, 300.0),
    ];

    let glints = glints(buf, beats);
    // Snapping to the 256-colour palette is a search, and a screen is ten
    // thousand cells asking it twice each: in a debug build that alone was
    // twice the frame. A frame has far fewer distinct colours than cells, so
    // each is searched for once.
    let mut palette: std::collections::HashMap<(u8, u8, u8), Color> =
        std::collections::HashMap::new();
    // In truecolor there is no search to save, and the lookup would cost more
    // than the conversion it stands in for.
    let mut snap = |rgb| match truecolor {
        true => snap(rgb, true),
        false => *palette.entry(rgb).or_insert_with(|| snap(rgb, false)),
    };
    for y in area.top()..area.bottom() {
        for x in area.left()..area.right() {
            let Some(cell) = buf.cell_mut((x, y)) else {
                continue;
            };
            // Behind an image or a link's label, which is drawn from another
            // cell and would not show a colour set here anyway.
            if cell.diff_option == CellDiffOption::Skip {
                continue;
            }
            let dx = (f32::from(x) - cx) / 2.0;
            let dy = f32::from(y) - cy;

            let ring = (-((dx.hypot(dy) - ring_at) / 1.5).powi(2)).exp() * ring_fade;
            let (mut hue, mut value) = match ring > 0.05 {
                // The complement of the ground, so the ring reads as a wave
                // passing over it rather than the ground getting brighter.
                true => ((ground_hue + 180.0) % 360.0, ground_value + 0.25 * ring),
                false => (ground_hue, ground_value),
            };
            for (angle, beam_hue) in beams {
                // Distance from the line through the middle at `angle`.
                let off = (dx * angle.sin() - dy * angle.cos()).abs();
                if off < 0.6 {
                    hue = beam_hue;
                    value = value.max(0.45 * (1.0 - off / 0.6) + 0.1);
                }
            }
            cell.bg = snap(hsv(hue, 0.9, value.min(0.55)));

            let hue = (f32::from(x) * 6.0 + f32::from(y) * 12.0 - roll).rem_euclid(360.0);
            cell.fg = snap(hsv(hue, 0.85, 1.0));
        }
    }
    for (x, y, glint) in glints {
        if let Some(cell) = buf.cell_mut((x, y)) {
            cell.set_symbol(glint);
            cell.fg = snap(hsv(0.0, 0.0, 1.0));
        }
    }
    equaliser(
        buf,
        footer.intersection(area),
        beats,
        thump,
        build,
        truecolor,
    );
}

/// Where the mirror ball throws its light this half-beat, and with what.
///
/// Only on open ground: a blank cell with blanks either side, so a glint never
/// lands between two words and reads as punctuation. Chosen before anything is
/// written, because a glint is not blank and would close the ground beside it.
fn glints(buf: &Buffer, beats: f32) -> Vec<(u16, u16, &'static str)> {
    const GLINTS: [&str; 4] = ["✦", "✧", "·", "*"];
    let tick = (beats * 2.0).floor() as u32;
    let area = buf.area;
    let blank = |x: u16, y: u16| {
        buf.cell((x, y))
            .is_some_and(|c| c.symbol() == " " && c.diff_option == CellDiffOption::None)
    };
    let mut out = Vec::new();
    for y in area.top()..area.bottom() {
        for x in area.left() + 1..area.right().saturating_sub(1) {
            let roll = scatter(u32::from(x), u32::from(y), tick);
            if roll % 1000 < 6 && blank(x - 1, y) && blank(x, y) && blank(x + 1, y) {
                out.push((x, y, GLINTS[(roll / 1000) as usize % GLINTS.len()]));
            }
        }
    }
    out
}

/// A well-mixed number from a cell and a moment, so the glints land somewhere
/// new every half-beat without anything being remembered between frames.
fn scatter(x: u32, y: u32, tick: u32) -> u32 {
    let mut h =
        x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ tick.wrapping_mul(0xC2B2_AE3D);
    h ^= h >> 15;
    h = h.wrapping_mul(0x2C1B_3C6D);
    h ^= h >> 12;
    h
}

/// A bar per column, bouncing to the beat, with the way out across the middle
/// and a crowd either side of it.
fn equaliser(buf: &mut Buffer, footer: Rect, beats: f32, thump: f32, build: f32, truecolor: bool) {
    const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
    const MOVES: [&str; 4] = ["\\o/", "\\o_", "_o/", "|o|"];
    let secs = beats * 60.0 / BPM;
    let y = footer.y;
    for x in footer.left()..footer.right() {
        let fx = f32::from(x);
        // Three sines at unrelated rates, so the bars never fall into a
        // pattern the eye can follow, all lifted together by the kick — and
        // pushed toward the top as a build climbs.
        let level = 0.5
            + 0.2 * (fx * 0.9 + secs * 7.0).sin()
            + 0.15 * (fx * 0.37 - secs * 3.1).sin()
            + 0.15 * (fx * 1.7 + secs * 11.3).sin();
        let level = (level * (0.55 + 0.45 * thump) + 0.5 * build).clamp(0.0, 0.999);
        let hue = (fx * 8.0 + secs * 90.0).rem_euclid(360.0);
        if let Some(cell) = buf.cell_mut((x, y)) {
            // A link in the footer was one wide cell standing for its whole
            // label; a bar is one cell standing for itself.
            cell.set_diff_option(CellDiffOption::None);
            cell.set_symbol(BARS[(level * BARS.len() as f32) as usize]);
            cell.fg = snap(hsv(hue, 1.0, 1.0), truecolor);
            cell.bg = Color::Indexed(16);
        }
    }

    let label = match (dropping(beats), build > 0.0) {
        (true, _) => " ♫  D R O P  ♫ ",
        (false, true) => " ♫  here it comes…  ♫ ",
        (false, false) => " ♫  ↑↑↓↓←→←→ba to go home  ♫ ",
    };
    let width = label.chars().count() as u16;
    if footer.width <= width {
        return;
    }
    let x = footer.x + (footer.width - width) / 2;
    let style = ratatui::style::Style::default()
        .fg(Color::Indexed(16))
        .bg(snap(
            hsv((secs * 90.0).rem_euclid(360.0), 0.7, 1.0),
            truecolor,
        ))
        .add_modifier(ratatui::style::Modifier::BOLD);
    buf.set_string(x, y, label, style);

    // Two dancers a side, each a beat out of step with the next, where the
    // footer has room for them past the label.
    const DANCER: u16 = 4;
    if footer.width < width + 4 * DANCER + 2 {
        return;
    }
    let dancer = ratatui::style::Style::default()
        .fg(snap(hsv(0.0, 0.0, 1.0), truecolor))
        .bg(Color::Indexed(16))
        .add_modifier(ratatui::style::Modifier::BOLD);
    let beat = beats.floor() as usize;
    let spots = [x - 2 * DANCER, x - DANCER, x + width, x + width + DANCER];
    for (i, at) in spots.into_iter().enumerate() {
        let step = MOVES[(beat + i) % MOVES.len()];
        buf.set_string(at, y, format!(" {step}"), dancer);
    }
}

/// `rgb` as this terminal can show it.
pub(super) fn snap(rgb: (u8, u8, u8), truecolor: bool) -> Color {
    match truecolor {
        true => Color::Rgb(rgb.0, rgb.1, rgb.2),
        false => Color::Indexed(nearest_indexed(rgb)),
    }
}

/// Hue in degrees, saturation and value in `0..=1`, to RGB.
pub(super) fn hsv(hue: f32, saturation: f32, value: f32) -> (u8, u8, u8) {
    let c = value * saturation;
    let h = hue.rem_euclid(360.0) / 60.0;
    let x = c * (1.0 - (h % 2.0 - 1.0).abs());
    let (r, g, b) = match h as u8 {
        0 => (c, x, 0.0),
        1 => (x, c, 0.0),
        2 => (0.0, c, x),
        3 => (0.0, x, c),
        4 => (x, 0.0, c),
        _ => (c, 0.0, x),
    };
    let m = value - c;
    let byte = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
    (byte(r), byte(g), byte(b))
}

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

    fn type_code(rave: &mut Rave) -> Vec<Heard> {
        CODE.iter().map(|&key| rave.hear(key)).collect()
    }

    /// The code toggles the party both ways, and only its `b` and `a` are kept
    /// from the table: the arrows still move the cursor.
    #[test]
    fn the_code_starts_the_party_and_ends_it() {
        let mut rave = Rave::default();
        let heard = type_code(&mut rave);
        assert!(heard[..8].iter().all(|h| *h == Heard::Pass), "{heard:?}");
        assert_eq!(heard[8], Heard::Swallow);
        assert_eq!(heard[9], Heard::Toggle);
        assert!(rave.elapsed().is_some());
        type_code(&mut rave);
        assert!(rave.elapsed().is_none());
    }

    /// A slip starts the code over, and extra ups at the start are forgiven.
    #[test]
    fn a_wrong_key_starts_over_but_a_third_up_does_not() {
        let mut rave = Rave::default();
        rave.hear(KeyCode::Up);
        rave.hear(KeyCode::Up);
        rave.hear(KeyCode::Down);
        assert_eq!(rave.hear(KeyCode::Char('x')), Heard::Pass);
        assert_eq!(rave.heard, 0);
        // A plain `b` or `a` outside the code is the table's.
        assert_eq!(rave.hear(KeyCode::Char('b')), Heard::Pass);
        for _ in 0..5 {
            rave.hear(KeyCode::Up);
        }
        assert_eq!(rave.heard, 2);
        let rest: Vec<_> = CODE[2..].iter().map(|&key| rave.hear(key)).collect();
        assert_eq!(rest.last(), Some(&Heard::Toggle));
    }

    /// Started over a modal the code is still heard, even though its own ←
    /// closes help on the way: a key that moves you elsewhere halfway through
    /// does not lose the rest of the code.
    #[test]
    fn the_code_is_heard_away_from_the_table() {
        let mut app = crate::ui::tests::test_app();
        app.mode = Mode::Help;
        for code in CODE {
            app.on_key(crate::ui::tests::key(code));
        }
        assert!(app.raving());
    }

    /// The party opens on a drop, builds through the last eight beats of
    /// every phrase, and drops on the first beat of the next.
    #[test]
    fn a_phrase_builds_for_eight_beats_and_drops() {
        assert_eq!(build(0.0), 0.0);
        assert!(!dropping(0.5), "the first phrase has no build to drop from");
        assert_eq!(build(23.9), 0.0);
        assert!((build(28.0) - 0.5).abs() < 1e-4);
        assert!(build(31.99) > 0.99);
        assert_eq!(build(32.0), 0.0);
        assert!(dropping(32.0) && dropping(33.9) && !dropping(34.0));
    }

    /// A glint lands only on open ground, never in the space between words,
    /// and never over a letter.
    #[test]
    fn the_mirror_ball_keeps_off_the_text() {
        let area = Rect::new(0, 0, 60, 20);
        let mut buf = Buffer::empty(area);
        for y in 0..20 {
            buf.set_string(0, y, "a b c d e f g", ratatui::style::Style::default());
        }
        // Over enough half-beats that the ball has thrown light everywhere.
        let mut seen = 0;
        for tick in 0..64 {
            for (x, y, _) in glints(&buf, tick as f32 / 2.0) {
                assert!(x > 13, "a glint at ({x}, {y}) is inside the text");
                seen += 1;
            }
        }
        assert!(seen > 0, "the ball never threw any light");
    }

    #[test]
    fn hsv_lands_on_the_primaries() {
        assert_eq!(hsv(0.0, 1.0, 1.0), (255, 0, 0));
        assert_eq!(hsv(120.0, 1.0, 1.0), (0, 255, 0));
        assert_eq!(hsv(240.0, 1.0, 1.0), (0, 0, 255));
        assert_eq!(hsv(360.0, 1.0, 1.0), (255, 0, 0));
        assert_eq!(hsv(42.0, 0.0, 0.0), (0, 0, 0));
    }

    /// Colours move, symbols do not — except the footer, which is the
    /// equaliser — and a 256-colour terminal is never sent RGB.
    #[test]
    fn the_party_repaints_colours_and_keeps_the_text() {
        let area = Rect::new(0, 0, 40, 3);
        let mut buf = Buffer::empty(area);
        buf.set_string(0, 0, "$12.34 spent", ratatui::style::Style::default());
        let footer = Rect::new(0, 2, 40, 1);
        paint(&mut buf, footer, Duration::from_millis(1234), false);
        let text: String = (0..12).map(|x| buf[(x, 0)].symbol().to_string()).collect();
        assert_eq!(text, "$12.34 spent");
        for cell in buf.content() {
            assert!(
                !matches!(cell.fg, Color::Rgb(..)) && !matches!(cell.bg, Color::Rgb(..)),
                "{cell:?}"
            );
        }
        assert!(buf.content()[80..].iter().any(|c| c.symbol() != " "));
    }

    /// A session switched into ultracode after cctop started starts the
    /// party once; one from before it started never does, and going home
    /// with the code is not undone by the same switch being read again.
    #[test]
    fn ultracode_starts_the_party_once() {
        let mut app = crate::ui::tests::test_app();
        let mut before = crate::session::Session::new(Provider::Claude, "old".into());
        before.ultracode_at = Some("2020-01-01T00:00:00.000Z".into());
        app.sessions.push(before);
        app.hear_ultracode();
        assert!(!app.raving(), "a switch from before cctop started");

        let mut now = crate::session::Session::new(Provider::Codex, "new".into());
        now.ultracode_at = Some(chrono::Utc::now().to_rfc3339());
        app.sessions.push(now);
        app.hear_ultracode();
        assert!(app.raving() || theme::no_color());

        for code in CODE {
            app.on_key(crate::ui::tests::key(code));
        }
        assert!(!app.raving());
        app.hear_ultracode();
        assert!(!app.raving(), "the same switch, read again");
    }

    /// Switching away from ultracode ends the party ultracode started, once
    /// every session that is in it has left, and never one the code started.
    #[test]
    fn leaving_ultracode_ends_its_party_and_only_its_own() {
        if theme::no_color() {
            return;
        }
        let at = |secs: i64| (chrono::Utc::now() + chrono::Duration::seconds(secs)).to_rfc3339();
        let mut app = crate::ui::tests::test_app();
        for id in ["a", "b"] {
            let mut s = crate::session::Session::new(Provider::Claude, id.into());
            s.ultracode_at = Some(at(1));
            app.sessions.push(s);
        }
        app.hear_ultracode();
        assert!(app.raving());

        app.sessions[0].ultracode_off_at = Some(at(2));
        app.hear_ultracode();
        assert!(app.raving(), "one session is still in ultracode");
        app.sessions[1].ultracode_off_at = Some(at(2));
        app.hear_ultracode();
        assert!(!app.raving(), "every session has left it");

        // Back in: the party comes back. Then the code takes it over, and
        // leaving ultracode no longer ends it.
        app.sessions[0].ultracode_at = Some(at(3));
        app.hear_ultracode();
        assert!(app.raving());
        for code in CODE.into_iter().chain(CODE) {
            app.on_key(crate::ui::tests::key(code));
        }
        assert!(app.raving());
        app.sessions[0].ultracode_off_at = Some(at(4));
        app.hear_ultracode();
        assert!(app.raving(), "the code's party is the code's to end");
    }

    /// A party ultracode started with no tab here running its agent is
    /// shown everywhere, since there is nowhere else for it; the code's own
    /// party is shown everywhere regardless.
    #[test]
    fn an_ultracode_party_with_no_tab_of_its_own_is_everywhere() {
        if theme::no_color() {
            return;
        }
        let mut app = crate::ui::tests::test_app();
        let mut s = crate::session::Session::new(Provider::Claude, "a".into());
        s.ultracode_at = Some((chrono::Utc::now() + chrono::Duration::seconds(1)).to_rfc3339());
        app.sessions.push(s);
        app.hear_ultracode();
        assert!(app.rave.by_ultracode);
        assert!(app.raving(), "no tab runs it, so the dashboard shows it");
        app.rave.ultracode_pids = vec![4242];
        assert!(app.raving(), "a pid no tab is running changes nothing");
    }
}