cctop 0.10.0

An htop-like terminal monitor for AI coding agent sessions (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
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
//! Key and mouse handling: translate input events into state changes.

use super::columns::COLUMNS;
use super::{AGE_OPTIONS, App, BatchKind, LaunchInto, Mode, PAGE, Request, render};
/// Longest path the launcher's directory field accepts.
///
/// Comfortably past any real working directory — Linux caps a path at 4096
/// bytes and this is about the width of four terminals — while still bounding
/// what a runaway paste can put in one line.
pub(super) const MAX_PATH_INPUT: usize = 512;

/// Longest name a tab will take.
///
/// The bar truncates well before this, so a longer name is one nobody can read
/// anyway; the cap is here so a paste cannot fill the rmux option with a
/// transcript.
pub(super) const TAB_NAME_MAX: usize = 64;

/// How long after a right-click a paste still counts as that click's echo.
///
/// One frame's worth of slack: the terminal writes the click and the clipboard
/// back to back, so anything this close arrived with the button, while a person
/// reaching for Ctrl+Shift+V cannot be here yet.
pub(super) const RIGHT_CLICK_PASTE: Duration = Duration::from_millis(250);

use ratatui::crossterm::event::{
    self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use std::time::{Duration, Instant};

/// The pasted text as a single line, within `room` more bytes — the budget being
/// in bytes because the caps it enforces are the ones `on_key_send` and
/// `on_key_cost` already apply to a `String`'s length.
///
/// Every input on the dashboard is one line drawn in one strip, and none of them
/// has a notion of a cursor on a second row — a newline dropped straight in would
/// be a character the box can neither show nor let you delete past. Each run of
/// line breaks and tabs becomes the one space it stands for, so pasting a wrapped
/// sentence into the search box searches for the sentence rather than for a
/// string no session's text contains. Every other control character is dropped:
/// none of them is anything a query or a message meant to contain, and an escape
/// among them would repaint the strip it landed in.
fn flatten(text: &str, room: usize) -> String {
    let mut out = String::new();
    let mut last_was_break = false;
    for c in text.chars() {
        if out.len() + c.len_utf8() > room {
            break;
        }
        match c {
            // A CRLF is one break, not two spaces, and neither is a line that
            // was indented after it.
            '\n' | '\r' | '\t' => {
                if !last_was_break {
                    out.push(' ');
                }
                last_was_break = true;
            }
            c if c.is_control() => {}
            c => {
                out.push(c);
                last_was_break = false;
            }
        }
    }
    out
}

impl App {
    pub(super) fn on_key(&mut self, key: KeyEvent) {
        if key.kind != KeyEventKind::Press {
            return;
        }
        self.needs_redraw = true;

        // Moving between tabs and panes has to work from inside a pane, where
        // every other key belongs to the agent. Alt is the modifier left over:
        // Ctrl- is the agent's (Ctrl-C interrupts it), and the function keys are
        // too few to also carry the splits.
        if key.modifiers.contains(KeyModifiers::ALT) && self.on_key_workspace(key) {
            return;
        }

        // Inside a pane the keyboard belongs to the agent — every key but the
        // function keys, which are cctop's wherever you are. The footer offers
        // them from inside a pane, so one that reached the agent instead would
        // be a promise the pane quietly broke.
        if self.tab > 0 && self.mode == Mode::List {
            if matches!(key.code, KeyCode::F(_)) {
                self.on_key_function(key);
                return;
            }
            // Ctrl+V with a picture on the clipboard, which is the gesture
            // anyone reaches for before they reach for F9. Taken only when
            // there is an image to take it for: with text on the clipboard the
            // key goes to the agent untouched, as it always did.
            //
            // Not every terminal sends it: Windows Terminal binds Ctrl+V to
            // its own paste and the application is never told, so there F9 is
            // the only way in. Unbinding it in the terminal's settings gives
            // this back.
            if key.code == KeyCode::Char('v')
                && key.modifiers.contains(KeyModifiers::CONTROL)
                && self.image_gesture_into_pane()
            {
                return;
            }
            if let Some(pane) = self.focused_pane() {
                // The agent this key is going to, taken before the borrow ends:
                // answering its question is the one thing no hook reports.
                let agent = pane.agent();
                let alive = pane.view.send_key(key);
                self.mark_answered(agent);
                if !alive {
                    self.close_pane();
                    self.set_status("The agent's terminal closed");
                }
            }
            return;
        }

        // Ctrl-C quits from any mode.
        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
            self.request_quit();
            return;
        }

        match self.mode {
            Mode::Search => self.on_key_search(key),
            Mode::SortBy => self.on_key_sortby(key),
            Mode::AgeFilter => self.on_key_age(key),
            Mode::DeleteConfirm => self.on_key_delete(key),
            Mode::KillConfirm => self.on_key_kill(key),
            Mode::ResumeConfirm => self.on_key_resume(key),
            Mode::TmuxInstall => {
                self.rmux_install_answer(key.code == KeyCode::Char('y'));
            }
            Mode::Serve => self.on_key_serve(key),
            Mode::QuitConfirm => self.on_key_quit(key),
            Mode::BatchConfirm | Mode::BatchDeleteBlocked | Mode::BatchKillBlocked => {
                self.on_key_batch(key)
            }
            Mode::CostFilter => self.on_key_cost(key),
            Mode::SendKeys => self.on_key_send(key),
            Mode::RenameTab => self.on_key_rename(key),
            Mode::Launch => self.on_key_launch(key),
            Mode::RowMenu => self.on_key_menu(key),
            Mode::LaunchCwd => self.on_key_launch_cwd(key),
            Mode::Hooks => self.on_key_hooks(key),
            Mode::Help => self.on_key_help(key),
            Mode::DeleteBlocked | Mode::KillBlocked => self.mode = Mode::List,
            Mode::List => self.on_key_list(key),
        }
    }

    /// A paste, which the terminal hands over whole rather than as the keys it
    /// spells.
    ///
    /// Inside a pane it belongs to the agent and goes down the pty in one write;
    /// see [`Attach::send_paste`](crate::attach::Attach::send_paste) for what
    /// happens to it on the way. Everywhere else the only thing on screen that
    /// can hold text is whichever one-line input is open, so a paste is typing
    /// into that and nothing at all when none is open. It is deliberately not a
    /// shortcut for anything: pasting into the dashboard is somebody aiming at a
    /// box, and answering it with an action would be a command nobody typed.
    pub(super) fn on_paste(&mut self, text: &str) {
        self.needs_redraw = true;

        // An image that arrived as text, which is the only way one reaches a
        // cctop running over ssh: the clipboard is on the machine the ssh was
        // typed on, and no helper on this side can see it. What is pasted is a
        // file here, and what the agent is given is its path — the same as F9,
        // by a different road.
        if let Some(png) = crate::clipboard::png_from_paste(text) {
            match crate::clipboard::write_png(&png) {
                Ok(path) => {
                    let shown = path
                        .file_name()
                        .map(|n| n.to_string_lossy().into_owned())
                        .unwrap_or_default();
                    self.set_status(format!("Pasted {shown}"));
                    self.paste_text(&format!("{} ", path.display()));
                }
                // The paste is not put through as text on a failure: 100KB of
                // base64 in front of an agent is worse than nothing having
                // happened, and the status line says what did.
                Err(e) => self.set_status(format!("Could not save the pasted image: {e}")),
            }
            return;
        }
        self.paste_text(text);
    }

    /// A paste, once it is known to be text.
    fn paste_text(&mut self, text: &str) {
        if self.tab > 0 && self.mode == Mode::List {
            if let Some(pane) = self.focused_pane() {
                // The same bookkeeping a keystroke does: text put in front of an
                // agent is an answer to whatever it asked, and no hook reports
                // that.
                let agent = pane.agent();
                let alive = pane.view.send_paste(text);
                self.mark_answered(agent);
                if !alive {
                    self.close_pane();
                    self.set_status("The agent's terminal closed");
                }
            }
            return;
        }

        match self.mode {
            Mode::Search => {
                self.search.push_str(&flatten(text, usize::MAX));
                self.search_edited();
            }
            Mode::SendKeys => {
                let room = 500usize.saturating_sub(self.send_input.len());
                self.send_input.push_str(&flatten(text, room));
            }
            Mode::RenameTab => {
                // The clipboard a right-click brought along with it, not a
                // paste anyone asked for. See `rename_opened_by_click`.
                if let Some(at) = self.rename_opened_by_click.take()
                    && at.elapsed() < RIGHT_CLICK_PASTE
                {
                    return;
                }
                let room = TAB_NAME_MAX.saturating_sub(self.rename_input.chars().count());
                self.rename_input.push_str(&flatten(text, room));
            }
            // Pasting a path in is the point of this field: a directory deep
            // enough to be worth typing is one you copied from somewhere.
            Mode::LaunchCwd => {
                let room = MAX_PATH_INPUT.saturating_sub(self.launch_cwd_input.chars().count());
                self.launch_cwd_input.push_str(&flatten(text, room));
                self.launch_cwd_bad = false;
                self.launch_cwd_suggest();
            }
            // The cost floor is a number, so a paste is filtered the way typing
            // one is rather than flattened: anything that is not a digit or a
            // point could not have been typed here either.
            Mode::CostFilter => {
                let room = 12usize.saturating_sub(self.cost_input.len());
                let digits: String = text
                    .chars()
                    .filter(|c| c.is_ascii_digit() || *c == '.')
                    .take(room)
                    .collect();
                self.cost_input.push_str(&digits);
            }
            _ => {}
        }
    }

    /// The clipboard's image written to a file, and its path — the form an
    /// agent can actually read one in. Says why when there is nothing to paste.
    ///
    /// Every failure is reported in the status line rather than swallowed: a
    /// key that silently does nothing is indistinguishable from one that is not
    /// bound, and the two answers this can give — an empty clipboard, and a
    /// machine with no helper installed — are things the user can act on.
    fn image_paste(&mut self) -> Option<String> {
        self.needs_redraw = true;
        match crate::clipboard::image_to_file() {
            Ok(path) => {
                let shown = path
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_default();
                self.set_status(format!("Pasted {shown}"));
                Some(path.display().to_string())
            }
            Err(why) => {
                self.set_status(why.message());
                None
            }
        }
    }

    /// F9 on the dashboard, where there is no composer to type into.
    ///
    /// The image is read first and the destination decided after, which is the
    /// whole of what was wrong here to begin with: asking `send_prompt` first
    /// meant a row with no local process refused the key with "no local process
    /// to type into" — a true sentence about a question nobody asked, and no
    /// clue that it was the image key that had just been pressed.
    ///
    /// With a session that can be typed into, the path goes into the box that
    /// types for it, the field left open for the sentence that follows. With
    /// none — a finished session, a subagent, a row on another machine — the
    /// file has still been written and the status line names it in full, which
    /// is the most that can be done with an image nobody is waiting for.
    ///
    /// Not copied to the clipboard, tempting as it is: the clipboard is where
    /// the image just came from, and putting a path there would delete the
    /// screenshot the next F9 was going to read. A key that destroys its own
    /// input on the way past is worse than one that only reports.
    fn paste_image_from_the_list(&mut self) {
        let Some(path) = self.image_paste() else {
            return;
        };
        self.send_prompt();
        if self.mode == Mode::SendKeys {
            self.send_input = format!("{path} ");
            return;
        }
        // `send_prompt` will have said why it could not open, which is no
        // longer the news.
        self.set_status(format!("Saved {path}"));
    }

    /// The image paste as a gesture rather than as a key: pastes and returns
    /// true when the clipboard holds an image, and says nothing at all when it
    /// does not.
    ///
    /// Silence is the point. `F9` is pressed to paste an image and so reports
    /// when there is none; `Ctrl+V` is pressed to paste *whatever* is there,
    /// and a status line saying "no image on the clipboard" every time someone
    /// pastes a line of text would be cctop talking over the thing they were
    /// doing.
    ///
    /// ponytail: the right button is not one of these, and was for a day. In
    /// Windows Terminal it *copies* when there is a selection and pastes only
    /// when there is not — so a user selecting output and right-clicking to
    /// copy it got an image pasted into their agent instead. A gesture whose
    /// meaning depends on a selection cctop cannot see is not one it can take.
    fn image_gesture_into_pane(&mut self) -> bool {
        let Ok(path) = crate::clipboard::image_to_file() else {
            return false;
        };
        self.needs_redraw = true;
        let shown = path
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        self.send_to_focused_pane(&format!("{} ", path.display()));
        self.set_status(format!("Pasted {shown}"));
        true
    }

    /// Text typed at the focused pane's agent, with the bookkeeping a paste
    /// carries: what is put in front of an agent answers whatever it asked.
    fn send_to_focused_pane(&mut self, text: &str) {
        let Some(pane) = self.focused_pane() else {
            return;
        };
        let agent = pane.agent();
        let alive = pane.view.send_paste(text);
        self.mark_answered(agent);
        if !alive {
            self.close_pane();
            self.set_status("The agent's terminal closed");
        }
    }

    /// F9 inside a pane: the image, then a space, typed at the agent.
    ///
    /// A space after it because the path is the start of a sentence, not the
    /// whole of one — "what is wrong with this?" follows, and every harness
    /// needs the path separated from it.
    fn paste_image_into_pane(&mut self) {
        let Some(path) = self.image_paste() else {
            return;
        };
        self.send_to_focused_pane(&format!("{path} "));
    }

    /// The multiplexer keys, live everywhere including inside a pane. Returns
    /// false for an Alt- combination that means nothing here, so it still
    /// reaches the agent.
    fn on_key_workspace(&mut self, key: KeyEvent) -> bool {
        match key.code {
            // Shifted, the arrows carry the tab instead of moving between them
            // — the keyboard's half of dragging one along the bar.
            KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => self.move_workspace(-1),
            KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => self.move_workspace(1),
            KeyCode::Left => self.cycle_workspace(-1),
            KeyCode::Right => self.cycle_workspace(1),
            // The dashboard is tab 1, matching where it sits in the tab bar.
            KeyCode::Char(c @ '1'..='9') => self.show_tab(c as usize - '1' as usize),
            KeyCode::Char('n') => self.launch_prompt(LaunchInto::Tab),
            KeyCode::Char('v') => self.launch_prompt(LaunchInto::Split { stacked: false }),
            KeyCode::Char('s') => self.launch_prompt(LaunchInto::Split { stacked: true }),
            KeyCode::Char('o') => match self.active_tab() {
                Some(tab) => tab.cycle_focus(),
                None => return false,
            },
            KeyCode::Char('w') => self.close_pane(),
            // Shifted, because it is the irreversible one: `w` on a rmux-backed
            // pane only detaches, and the key that ends the agent should not be
            // the same key with a slip of a finger.
            KeyCode::Char('W') if key.modifiers.contains(KeyModifiers::SHIFT) => self.kill_pane(),
            _ => return false,
        }
        true
    }

    fn on_key_launch(&mut self, key: KeyEvent) {
        let n = self.launch_choices().len().max(1);
        match key.code {
            // Backing out of the launcher abandons the handoff with it: a brief
            // left pending would be typed at whatever agent is started next,
            // which by then is an unrelated one.
            KeyCode::Esc => {
                self.mode = Mode::List;
                self.pending_brief = None;
                self.pending_fork = None;
            }
            KeyCode::Up | KeyCode::Char('k') => {
                self.launch_cursor = (self.launch_cursor + n - 1) % n
            }
            KeyCode::Down | KeyCode::Char('j') => self.launch_cursor = (self.launch_cursor + 1) % n,
            // Only where there is more than one account to be in, so the key is
            // absent rather than inert on the machines that have never had two.
            KeyCode::Char('p') => self.cycle_launch_profile(),
            // `c` for the directory it will start in. Not offered while
            // reattaching: that agent is already somewhere, and the footer says
            // so — a path typed there would be quietly ignored.
            KeyCode::Char('c') if !self.launch_is_reattach() => self.edit_launch_cwd(),
            KeyCode::Enter => {
                self.mode = Mode::List;
                self.launch_selected();
            }
            _ => {}
        }
    }

    /// Typing the launcher's working directory.
    ///
    /// Accepting is where the path is checked, not where it is typed: a
    /// directory half-spelled is not yet wrong, and colouring it red on the way
    /// through would be noise on every keystroke.
    fn on_key_launch_cwd(&mut self, key: KeyEvent) {
        match key.code {
            // Back to the list with the old directory intact. Cancelling has to
            // leave the launch exactly as it was found, or Esc becomes a way to
            // lose the setting you were trying to change.
            KeyCode::Esc => self.mode = Mode::Launch,
            KeyCode::Enter => self.take_launch_cwd(),
            // Tab fills in what the suggestions agree on. The list below the
            // field is what makes the key discoverable; without it a path still
            // has to be spelled to the last character.
            KeyCode::Tab => self.complete_launch_cwd(),
            // Into the suggestions and back out again. Nothing else in this
            // field wanted the arrows, and the launcher's own list is not being
            // moved while its directory is being typed.
            KeyCode::Down => self.step_launch_cwd(true),
            KeyCode::Up => self.step_launch_cwd(false),
            KeyCode::Backspace => {
                self.launch_cwd_input.pop();
                self.launch_cwd_bad = false;
                self.launch_cwd_suggest();
            }
            // Bounded like every other one-line input here: a path longer than
            // this is not one anybody typed on purpose.
            KeyCode::Char(c) if self.launch_cwd_input.chars().count() < MAX_PATH_INPUT => {
                self.launch_cwd_input.push(c);
                self.launch_cwd_bad = false;
                self.launch_cwd_suggest();
            }
            _ => {}
        }
        self.needs_redraw = true;
    }

    /// The integration panel. Every action rewrites somebody's settings file,
    /// so each is a distinct letter — there is no cursor to land on the wrong
    /// row and no Enter that does whatever was last highlighted.
    fn on_key_hooks(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
                self.mode = Mode::List;
                self.hooks = None;
            }
            KeyCode::Char('i') => self.set_hooks(crate::hook::Scope::User, true),
            KeyCode::Char('x') => self.set_hooks(crate::hook::Scope::User, false),
            KeyCode::Char('p') | KeyCode::Char('P') => match self.hook_project() {
                Some(dir) => self.set_hooks(
                    crate::hook::Scope::Project(dir),
                    key.code == KeyCode::Char('p'),
                ),
                None => self.set_status("The selected session has no project directory here"),
            },
            _ => {}
        }
    }

    /// The browser panel's keys.
    ///
    /// `l` and `t` start rather than toggle, and both are offered while a
    /// server is up: switching between them is a stop and a start, which is
    /// what changing whether the page is on the internet actually is.
    fn on_key_serve(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => self.mode = Mode::List,
            KeyCode::Char('l') => self.start_serving(false),
            KeyCode::Char('t') => self.start_serving(true),
            KeyCode::Char('x') => self.stop_serving(),
            KeyCode::Char('o') => match self.serving.as_ref().map(|s| s.best().to_string()) {
                Some(link) => match crate::serve::open_in_browser(&link) {
                    true => self.set_status("Opening the page in your browser"),
                    false => self.set_status("No browser to open it with — y copies the link"),
                },
                None => self.set_status("Nothing is being served yet"),
            },
            KeyCode::Char('y') => match self.serving.as_ref().map(|s| s.best().to_string()) {
                Some(link) => {
                    crate::ui::render::copy_to_clipboard(&link);
                    // The token is in the link, so this is a credential leaving
                    // the process. Said plainly rather than a silent "copied".
                    self.set_status("Link copied — it carries the token that opens it");
                }
                None => self.set_status("Nothing is being served yet"),
            },
            _ => {}
        }
    }

    fn on_key_search(&mut self, key: KeyEvent) {
        match key.code {
            // Enter is "I meant that one"; Esc is backing out. Only the former
            // is worth remembering, or the history fills with abandoned
            // prefixes typed on the way to somewhere else.
            KeyCode::Enter => {
                self.remember_query();
                self.mode = Mode::List;
            }
            KeyCode::Esc => self.mode = Mode::List,
            KeyCode::Backspace => {
                self.search.pop();
                self.search_edited();
            }
            // Tab rather than a letter: every printable character belongs to the
            // query being typed.
            KeyCode::Tab => self.toggle_content_search(),
            KeyCode::Up => self.history_step(1),
            KeyCode::Down => self.history_step(-1),
            KeyCode::Char(c) => {
                self.search.push(c);
                self.search_edited();
            }
            _ => {}
        }
    }

    /// The resume confirmation, shown only when the session is already running.
    fn on_key_resume(&mut self, key: KeyEvent) {
        self.mode = Mode::List;
        if key.code == KeyCode::Char('y') {
            self.resume_now();
        }
    }

    /// The help text is longer than most terminals are tall, so the navigation
    /// keys scroll it and everything else still dismisses it.
    fn on_key_help(&mut self, key: KeyEvent) {
        let step = |app: &mut App, delta: i32| {
            app.help_scroll =
                (app.help_scroll as i32 + delta).clamp(0, app.help_max_scroll as i32) as u16;
        };
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => step(self, -1),
            KeyCode::Down | KeyCode::Char('j') => step(self, 1),
            KeyCode::PageUp => step(self, -(PAGE as i32)),
            KeyCode::PageDown | KeyCode::Char(' ') => step(self, PAGE as i32),
            KeyCode::Home | KeyCode::Char('g') => self.help_scroll = 0,
            KeyCode::End | KeyCode::Char('G') => self.help_scroll = self.help_max_scroll,
            _ => {
                self.mode = Mode::List;
                self.help_scroll = 0;
            }
        }
    }

    fn on_key_sortby(&mut self, key: KeyEvent) {
        let n = COLUMNS.len();
        match key.code {
            KeyCode::Esc | KeyCode::F(6) => self.mode = Mode::List,
            KeyCode::Up | KeyCode::Char('k') => {
                self.sortby_cursor = (self.sortby_cursor + n - 1) % n
            }
            KeyCode::Down | KeyCode::Char('j') => self.sortby_cursor = (self.sortby_cursor + 1) % n,
            KeyCode::Enter => {
                self.set_sort(COLUMNS[self.sortby_cursor].id);
                self.mode = Mode::List;
            }
            KeyCode::Char('q') => self.request_quit(),
            _ => {}
        }
    }

    fn on_key_age(&mut self, key: KeyEvent) {
        let n = AGE_OPTIONS.len();
        match key.code {
            KeyCode::Esc | KeyCode::F(7) => self.mode = Mode::List,
            KeyCode::Up | KeyCode::Char('k') => self.age_cursor = (self.age_cursor + n - 1) % n,
            KeyCode::Down | KeyCode::Char('j') => self.age_cursor = (self.age_cursor + 1) % n,
            KeyCode::Enter => {
                self.age_filter = AGE_OPTIONS[self.age_cursor];
                self.refilter();
                self.save_prefs();
                self.mode = Mode::List;
            }
            _ => {}
        }
    }

    fn on_key_delete(&mut self, key: KeyEvent) {
        if key.code == KeyCode::Char('y')
            && let Some(s) = self.selected_session().cloned()
        {
            if self.tx.send(Request::Delete(Box::new(s.clone()))).is_ok() {
                self.deleting.insert(s.key());
                self.set_status(format!("Deleting session {}", s.session_id));
            } else {
                self.set_status("Could not start session deletion");
            }
        }
        self.mode = Mode::List;
    }

    fn on_key_kill(&mut self, key: KeyEvent) {
        if key.code == KeyCode::Char('y')
            && let Some(pid) = self.selected_session().and_then(|s| s.root_pid())
            && let Some(session) = self.selected_session()
        {
            let _ = self.tx.send(Request::Terminate {
                session_key: session.key(),
                pid,
            });
            self.set_status(format!("Stopping session {}", session.session_id));
        }
        self.mode = Mode::List;
    }

    /// Quit, or ask first when it would take the hosted agent down.
    ///
    /// `q` is muscle memory in an htop-like list, and here it would end a live
    /// coding session: the agent runs on a pty this process owns, so there is
    /// nothing left of it once cctop is gone.
    /// A function key pressed inside a pane.
    ///
    /// None of them is passed on. Agents do not read them — nothing in
    /// `claude`, `codex`, or a shell binds one — and cctop's own map is written
    /// in them, which is why they were the keys it kept.
    ///
    /// Most act on the dashboard: a search box, a sort order, or the help sheet
    /// drawn over a pane would be a modal on a screen the agent is repainting
    /// underneath, and the thing being filtered is not on screen at all. So the
    /// dashboard comes forward first and the key then does exactly what it does
    /// there. The three that need no dashboard stay where they are pressed.
    fn on_key_function(&mut self, key: KeyEvent) {
        match key.code {
            // Back to the dashboard, which is the one function key that only
            // means anything inside a pane.
            KeyCode::F(12) => self.show_tab(0),
            // Quitting is the pane's own key, and refreshing acts on the walk
            // rather than on anything drawn — pulling the dashboard forward for
            // it would take you off the agent you are watching in order to
            // reload a table you were not looking at.
            KeyCode::F(10) | KeyCode::F(5) => self.on_key_list(key),
            // The clipboard's image, as a path the agent can open. Stays in
            // the pane: the image is for the agent being typed at, and pulling
            // the dashboard forward would take the composer off screen at the
            // moment something is being put into it.
            KeyCode::F(9) => self.paste_image_into_pane(),
            // The keys the dashboard binds, on the dashboard.
            KeyCode::F(1) | KeyCode::F(3) | KeyCode::F(6) | KeyCode::F(7) | KeyCode::F(8) => {
                self.show_tab(0);
                self.on_key_list(key);
            }
            // Everything else: swallowed. An unbound function key does nothing
            // here rather than arriving at the agent as an escape sequence it
            // will print or misread.
            _ => {}
        }
    }

    fn request_quit(&mut self) {
        match self.hosted.is_some() {
            true => self.mode = Mode::QuitConfirm,
            false => self.should_quit = true,
        }
    }

    fn on_key_quit(&mut self, key: KeyEvent) {
        self.mode = Mode::List;
        match key.code {
            KeyCode::Char('y') => self.should_quit = true,
            KeyCode::Char('A') => self.attach_hosted(),
            _ => {}
        }
    }

    fn on_key_batch(&mut self, key: KeyEvent) {
        if key.code == KeyCode::Char('y') && self.mode == Mode::BatchConfirm {
            self.batch_execute();
        }
        self.mode = Mode::List;
    }

    fn on_key_cost(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc => self.mode = Mode::List,
            KeyCode::Enter => {
                if let Ok(v) = self.cost_input.parse::<f64>() {
                    self.cost_floor = v.max(0.0);
                    self.refilter();
                    self.save_prefs();
                    self.set_status(if v > 0.0 {
                        format!("Cost floor: ${v:.2}")
                    } else {
                        "Cost floor cleared".into()
                    });
                }
                self.mode = Mode::List;
            }
            KeyCode::Backspace => {
                self.cost_input.pop();
            }
            KeyCode::Char(c) if (c.is_ascii_digit() || c == '.') && self.cost_input.len() < 12 => {
                self.cost_input.push(c);
            }
            _ => {}
        }
    }

    fn on_key_send(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc => self.mode = Mode::List,
            KeyCode::Enter => {
                let text = self.send_input.clone();
                if !text.is_empty()
                    && let Some(pid) = self.selected_session().and_then(|s| s.root_pid())
                {
                    let _ = self.tx.send(Request::SendKeys { pid, text });
                    self.set_status("Sending…");
                }
                self.mode = Mode::List;
            }
            KeyCode::Backspace => {
                self.send_input.pop();
            }
            KeyCode::F(9) => {
                if let Some(path) = self.image_paste() {
                    let room = 500usize.saturating_sub(self.send_input.len());
                    if path.len() < room {
                        self.send_input.push_str(&path);
                        self.send_input.push(' ');
                    }
                }
            }
            KeyCode::Char(c) if self.send_input.len() < 500 => self.send_input.push(c),
            _ => {}
        }
    }

    /// The tab-rename field. Empty is not a name, so Enter with nothing typed
    /// backs out the same way Esc does rather than blanking the tab bar.
    fn on_key_rename(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc => self.mode = Mode::List,
            KeyCode::Enter => {
                let name = self.rename_input.trim().to_string();
                self.mode = Mode::List;
                if name.is_empty() {
                    return;
                }
                // The bar may have moved while the field was open — see
                // [`App::rename_was`]. A tab that is no longer the one the
                // right-click landed on keeps its name.
                let Some(tab) = self
                    .tabs
                    .get_mut(self.rename_tab.saturating_sub(1))
                    .filter(|tab| tab.title() == self.rename_was)
                else {
                    self.set_status("That tab is gone; nothing was renamed");
                    return;
                };
                tab.rename(name.clone());
                self.set_status(format!("Tab renamed to {name}"));
            }
            KeyCode::Backspace => {
                self.rename_input.pop();
            }
            KeyCode::Char(c) if self.rename_input.chars().count() < TAB_NAME_MAX => {
                self.rename_input.push(c)
            }
            _ => {}
        }
    }

    /// Ask for a new name for a tab, addressed the way the bar numbers them:
    /// tab 0 is the dashboard, which is not a tab anything renames.
    fn rename_prompt(&mut self, tab: usize) {
        let Some(target) = self.tabs.get(tab.saturating_sub(1)) else {
            return;
        };
        self.rename_tab = tab;
        self.rename_was = target.title();
        self.rename_input.clear();
        self.rename_opened_by_click = None;
        self.mode = Mode::RenameTab;
        self.needs_redraw = true;
    }

    /// Open the row menu on the first entry that can actually run.
    pub(super) fn open_row_menu(&mut self) {
        let items = super::menu::items(self);
        if items.is_empty() {
            return;
        }
        self.menu_cursor = super::menu::first_enabled(&items);
        self.mode = Mode::RowMenu;
        self.needs_redraw = true;
    }

    /// Keys inside the row menu.
    ///
    /// The shortcut letters stay live in here too, so `Enter d` and a plain `d`
    /// are the same two keystrokes and neither has to be unlearned — the menu
    /// shows the letters precisely so they get used.
    fn on_key_menu(&mut self, key: KeyEvent) {
        let items = super::menu::items(self);
        if items.is_empty() {
            self.mode = Mode::List;
            return;
        }
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::List,
            KeyCode::Up | KeyCode::Char('k') => {
                self.menu_cursor = super::menu::step(&items, self.menu_cursor, -1);
            }
            KeyCode::Down | KeyCode::Char('j') => {
                self.menu_cursor = super::menu::step(&items, self.menu_cursor, 1);
            }
            KeyCode::Enter => {
                if let Some(item) = items.get(self.menu_cursor)
                    && item.enabled()
                {
                    let action = item.action;
                    self.mode = Mode::List;
                    self.run_menu_action(action);
                }
            }
            // A blocked entry's letter says why rather than doing nothing,
            // which is the same answer the table gives for the same key.
            KeyCode::Char(c) => {
                let hit = items
                    .iter()
                    .find(|i| i.key.len() == 1 && i.key.starts_with(c));
                if let Some(item) = hit {
                    let action = item.action;
                    let blocked = item.blocked.clone();
                    self.mode = Mode::List;
                    match blocked {
                        Some(why) => self.set_status(why),
                        None => self.run_menu_action(action),
                    }
                }
            }
            _ => {}
        }
        self.needs_redraw = true;
    }

    /// Run one menu entry, through the same method its key calls.
    fn run_menu_action(&mut self, action: super::menu::Action) {
        use super::menu::Action;
        match action {
            Action::Resume => self.resume_selected(),
            Action::Attach => self.attach_selected(),
            Action::Send => self.send_prompt(),
            Action::Handoff => self.handoff_selected(),
            Action::Expand => self.toggle_expanded(),
            Action::Mark => self.toggle_mark(),
            Action::Terminate => self.confirm_terminate(),
            Action::Delete => self.delete_selected(),
        }
    }

    /// Start deleting the selected session's transcript, or say why not.
    ///
    /// Split out from the `d` arm so the row menu runs the identical path. Two
    /// routes to one action must not have two ideas of when it is allowed.
    pub(super) fn delete_selected(&mut self) {
        if self.on_subagent() {
            self.set_status("A subagent cannot be deleted on its own");
            return;
        }
        match self.selected_session() {
            Some(s) if self.deleting.contains(&s.key()) => {
                self.set_status("Session deletion is already in progress")
            }
            Some(s) if s.is_running() => self.mode = Mode::DeleteBlocked,
            Some(_) => self.mode = Mode::DeleteConfirm,
            None => {}
        }
    }

    /// Open the send box on the selected session, or say why not.
    ///
    /// Prefilled with the answer a stalled session usually wants, so s-Enter is
    /// the whole interaction.
    pub(super) fn send_prompt(&mut self) {
        if self.on_subagent() {
            self.set_status("A subagent cannot be typed into on its own");
            return;
        }
        match self.selected_session() {
            Some(s) if s.root_pid().is_some() => {
                self.send_input = "continue".into();
                self.mode = Mode::SendKeys;
            }
            Some(_) => self.set_status("Selected session has no local process to type into"),
            None => {}
        }
    }

    fn on_key_list(&mut self, key: KeyEvent) {
        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);

        // Shift+Up/Down scrolls inside the active bottom panel, since the plain
        // arrows are taken by list navigation and panel switching. Home and End
        // join it under the same modifier for the same reason — unshifted they
        // jump the session list — and the ends are asked for as a distance no
        // panel can be longer than, since only the renderer knows the real one.
        if shift {
            match key.code {
                KeyCode::Up => return self.scroll_active_panel(-1),
                KeyCode::Down => return self.scroll_active_panel(1),
                KeyCode::Home => return self.scroll_active_panel(i32::MIN),
                KeyCode::End => return self.scroll_active_panel(i32::MAX),
                _ => {}
            }
        }

        match key.code {
            KeyCode::Char('q') | KeyCode::F(10) => self.request_quit(),
            // Terminate is deliberately behind a modifier: `k` is vim's "up",
            // and every modal in this file already binds it that way, so a
            // plain `k` aimed at the cursor must never reach a live agent.
            // Plain `K` is taken by the batch kill, hence Ctrl.
            KeyCode::Char('k') if ctrl => self.confirm_terminate(),
            KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
            KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
            KeyCode::PageUp => self.move_selection(-PAGE),
            KeyCode::PageDown => self.move_selection(PAGE),
            // `b` was a second name for PageUp; answering a bell is worth more
            // than a third way to scroll up, and PageUp and Ctrl+U both remain.
            KeyCode::Char('b') => self.jump_to_bell(),
            KeyCode::Char('g') => {
                self.selected = 0;
                self.ensure_available_tab();
                self.needs_redraw = true;
            }
            KeyCode::Char('G') => {
                self.selected = self.visible.len().saturating_sub(1);
                self.ensure_available_tab();
                self.needs_redraw = true;
            }
            KeyCode::Char('u') if ctrl => {
                self.move_selection(-(self.half_page() as isize));
            }
            KeyCode::Char('d') if ctrl => {
                self.move_selection(self.half_page() as isize);
            }
            KeyCode::Char('f') => {
                self.follow = !self.follow;
                self.set_status(if self.follow {
                    "Follow mode on"
                } else {
                    "Follow mode off"
                });
            }
            KeyCode::Char(' ') => self.toggle_mark(),
            KeyCode::Char('e') => self.toggle_expanded(),
            KeyCode::Char('E') => self.toggle_expanded_all(),
            KeyCode::Char('U') => self.unmark_all(),
            KeyCode::Char('D') => self.batch(BatchKind::Delete),
            KeyCode::Char('K') => self.batch(BatchKind::Kill),
            KeyCode::Char('n') => self.cycle_matches(1),
            KeyCode::Char('N') => self.cycle_matches(-1),
            // `w` for the bell, not `n`: n/N is next/previous match everywhere
            // a search exists, and there were free letters to spend instead.
            KeyCode::Char('w') => self.toggle_notifications(),
            // `W` next to it, since both are about an agent reaching you rather
            // than you reaching it.
            KeyCode::Char('W') => self.share_selected(),
            // `B` for browser, beside the two keys that are also about reaching
            // this machine from somewhere else. `W` puts one agent's terminal
            // in a browser; this puts the whole table in one.
            KeyCode::Char('B') => self.mode = Mode::Serve,
            KeyCode::Char('#') => {
                self.cost_input = if self.cost_floor > 0.0 {
                    format!("{:.2}", self.cost_floor)
                } else {
                    String::new()
                };
                self.mode = Mode::CostFilter;
            }
            KeyCode::Tab => self.cycle_tab(1),
            KeyCode::BackTab => self.cycle_tab(-1),
            // Bounded by the tab list rather than a literal range, so a panel
            // added to `panels::TABS` gets its number key for free.
            KeyCode::Char(c @ '1'..='9') => {
                let tab = c as usize - '1' as usize;
                if tab < super::panels::TABS.len() && self.tab_available(tab) {
                    self.bottom_tab = tab;
                    self.save_prefs();
                }
            }
            KeyCode::Char('`') => {
                self.live_only = !self.live_only;
                self.refilter();
                self.save_prefs();
            }

            // `h` rather than `H`, which already sorts by harness.
            KeyCode::Char('h') | KeyCode::F(8) => self.open_hooks(),
            KeyCode::Char('/') | KeyCode::F(3) => self.mode = Mode::Search,
            KeyCode::Char('?') | KeyCode::F(1) => self.mode = Mode::Help,
            // One way in, rather than the six single-letter sort keys this
            // replaced. `P`/`M`/`T` were htop's, and `H`/`X`/`S` were three
            // more that only cctop has columns for: six keys spent on an
            // ordering you set once, none of them guessable without the help.
            // The panel names every column, says which is current, and `S` is
            // the letter anyone tries first.
            KeyCode::Char('S') | KeyCode::Char('>') | KeyCode::Char('<') | KeyCode::F(6) => {
                self.sortby_cursor = COLUMNS
                    .iter()
                    .position(|c| c.id == self.sort_col)
                    .unwrap_or(0);
                self.mode = Mode::SortBy;
            }
            KeyCode::F(7) => {
                self.age_cursor = AGE_OPTIONS
                    .iter()
                    .position(|o| *o == self.age_filter)
                    .unwrap_or(AGE_OPTIONS.len() - 1);
                self.mode = Mode::AgeFilter;
            }
            KeyCode::Char('r') | KeyCode::F(5) => {
                let _ = self.tx.send(Request::Refresh);
                self.set_status("Refreshing…");
            }
            // Everything below reaches into *this* machine — a signal to a
            // process, a transcript on disk, a pty. A row read over ssh has
            // none of those here, and the same path on this filesystem is a
            // different file. Refused with the host named, rather than left to
            // fail further in with a message about a missing process.
            KeyCode::Char('d' | 's' | 'a' | 'R' | 'O') if self.selected_is_remote() => {
                let why = self.selected_session().and_then(App::remote_refusal);
                if let Some(why) = why {
                    self.set_status(why);
                }
            }
            // Enter is the one key in this map that does not do a thing so
            // much as show what the others do. It was free, it is what "open
            // this row" means everywhere else, and unlike a bare modifier every
            // terminal actually delivers it.
            KeyCode::Enter if self.selected_session().is_some() => self.open_row_menu(),
            KeyCode::Char('d') => self.delete_selected(),
            KeyCode::Char('s') => self.send_prompt(),
            KeyCode::F(9) => self.paste_image_from_the_list(),
            KeyCode::Char('a') if self.on_subagent() => {
                self.set_status("A subagent has no terminal of its own to attach to")
            }
            KeyCode::Char('a') => self.attach_selected(),
            KeyCode::Char('A') => self.attach_hosted(),
            // `R`, because `r` refreshes. Capital also matches how the other
            // keys that start something irreversible are spelled.
            KeyCode::Char('R') => self.resume_selected(),
            // `O` for hand-off, capitalised alongside `R`: both take a session
            // somewhere else and both start an agent, so neither belongs on a
            // lowercase key. `H` was already sort-by-harness.
            KeyCode::Char('O') if self.on_subagent() => {
                self.set_status("Hand off the session, not one of its subagents")
            }
            KeyCode::Char('O') => self.handoff_selected(),
            // Alt+n does this too and works from inside a pane; here on the
            // dashboard, where nothing is competing for the keyboard, a plain
            // letter is what anyone will try first.
            KeyCode::Char('t') => self.launch_prompt(LaunchInto::Tab),
            KeyCode::Char('y') => self.copy_selection(),
            KeyCode::Char('L') => {
                self.tool_live_only = !self.tool_live_only;
                self.save_prefs();
            }
            KeyCode::Char('v') => {
                self.tool_show_diff = !self.tool_show_diff;
                self.save_prefs();
            }
            // Move through the Tool Activity filter sidebar.
            KeyCode::Char('[') => self.cycle_tool_filter(-1),
            KeyCode::Char(']') => self.cycle_tool_filter(1),

            // Arrows move between bottom panels; Shift+arrows scroll within one.
            KeyCode::Left => self.cycle_tab(-1),
            KeyCode::Right => self.cycle_tab(1),
            KeyCode::Esc => self.clear_one_filter(),
            _ => {}
        }
    }

    /// The workspace bar's mouse: picking a tab, the new-tab button, and
    /// dragging a tab to a different place in the bar. Returns whether the
    /// event belonged to the bar, in which case nothing downstream sees it.
    ///
    /// Consuming the whole drag, not just the part over the bar, is what keeps a
    /// rearrangement out of the agents: a press on a tab followed by a pointer
    /// that wanders down into a pane would otherwise arrive there as a click and
    /// a release the agent never saw the press for.
    fn on_mouse_workspace(&mut self, ev: event::MouseEvent, layout: &render::Layout) -> bool {
        match ev.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(tab) = layout.workspace_at(ev.column, ev.row) {
                    self.show_tab(tab);
                    // The dashboard is tab zero wherever the bar is drawn and
                    // stays there, so only a real tab is picked up.
                    self.drag_tab = (tab > 0).then_some(tab);
                    return true;
                }
                if layout.workspace_new_at(ev.column, ev.row) {
                    self.launch_prompt(LaunchInto::Tab);
                    return true;
                }
                false
            }
            // Reordering as the pointer moves rather than on the release: the
            // bar is the only feedback there is for where the tab will land, and
            // one that only redraws at the end is a drag you have to guess at.
            MouseEventKind::Drag(MouseButton::Left) => {
                let Some(from) = self.drag_tab else {
                    return false;
                };
                if let Some(to) = layout.workspace_at(ev.column, ev.row).filter(|to| *to > 0) {
                    self.move_tab(from, to);
                    self.drag_tab = Some(to);
                    self.needs_redraw = true;
                }
                true
            }
            // The end of a drag, and the moment its arrangement is worth
            // writing down: `move_tab` has been called on every pointer
            // movement between the press and here.
            MouseEventKind::Up(MouseButton::Left) => match self.drag_tab.take() {
                Some(_) => {
                    self.save_tab_order();
                    true
                }
                None => false,
            },
            // Right-click renames. The bar is the only place cctop answers the
            // right button at all — inside a pane it is deliberately dropped
            // (see [`App::on_mouse`]) — so there is nothing here to compete
            // with, and a tab called `3:claude-4` is precisely the thing you
            // want to rename by pointing at it.
            MouseEventKind::Down(MouseButton::Right) => {
                match layout
                    .workspace_at(ev.column, ev.row)
                    .filter(|tab| *tab > 0)
                {
                    Some(tab) => {
                        self.rename_prompt(tab);
                        self.rename_opened_by_click = Some(Instant::now());
                        true
                    }
                    None => false,
                }
            }
            _ => false,
        }
    }

    /// A click on the footer's corner: open the page, or open a tunnel.
    ///
    /// The link opens the browser, as `o` does in the serve panel. The button
    /// takes two clicks, and `armed` says which this is — the first only lights
    /// the corner amber and changes what it says, because publishing every
    /// session on this machine to the internet is not something a slipped
    /// pointer should be able to do. The second is the one that registers with
    /// Cloudflare's edge, which is a second of network cctop spends in front of
    /// the person who asked for it.
    pub(super) fn on_share_corner(&mut self, armed: bool) {
        // Already dialling. The corner is spinning and says so; a click at it
        // is impatience, not a second instruction.
        if self.share_opening.is_some() {
            return;
        }
        if let Some(link) = self.serving.as_ref().and_then(|s| s.public.clone()) {
            match crate::serve::open_in_browser(&link) {
                true => self.set_status("Opening the shared page in your browser"),
                false => self.set_status("No browser to open it with — B then y copies the link"),
            }
            return;
        }
        if !armed {
            self.share_arm = true;
            self.set_status("Click again to put this table on the internet");
            return;
        }
        self.start_serving(true);
    }

    /// A click on a key written on screen — a footer hint, or the `[y]` in a
    /// confirmation — answered by pressing that key.
    ///
    /// Going through `on_key` rather than calling the action is what keeps the
    /// two in step: the hint says `R Resume`, and clicking it does whatever `R`
    /// does in the state the app is actually in, including opening the
    /// confirmation that `R` opens.
    ///
    /// `q` is the exception, and takes two clicks. It is the one key on the
    /// footer whose action cannot be undone — there is no confirmation behind
    /// it unless cctop is hosting an agent — so a slipped pointer must not end
    /// the session, in the same way one must not put a tunnel on the internet.
    fn on_hint_click(&mut self, key: KeyEvent, quit_armed: bool) {
        if key.code == KeyCode::Char('q') && key.modifiers.is_empty() && !quit_armed {
            self.quit_arm = true;
            // Said in the footer's badges rather than with `set_status`, which
            // draws over the hints — including the `q Quit` the second click has
            // to land on. Arming that hid its own button was a button that could
            // only ever be clicked once.
            self.needs_redraw = true;
            return;
        }
        self.on_key(key);
    }

    pub(super) fn on_mouse(&mut self, ev: event::MouseEvent, layout: &render::Layout) {
        // Anything the mouse actually does changes the screen, and unlike
        // `on_key` there is nothing downstream to rely on for the frame:
        // `launch_prompt` opening the launcher and `set_sort` reordering the
        // table both leave the previous picture up until some unrelated event
        // repaints it, which reads as a dead click. Movement is left out —
        // capture reports it continuously and it changes nothing.
        if matches!(
            ev.kind,
            MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
        ) {
            self.needs_redraw = true;
        }

        // A modal owns the mouse while it is up. Without this the dashboard
        // underneath still answers, so a click on a launcher row lands on the
        // panel tab or session row the modal is drawn over.
        //
        // Only a modal that recorded its rectangle, though, since that rectangle
        // is the whole means of telling a click meant for the modal from one
        // meant for what it covers. The search box records none: it is a strip
        // over a table that is still being scrolled and clicked while the query
        // is typed, and swallowing the wheel there strands the filter it exists
        // to drive.
        if self.mode != Mode::List && layout.modal_rect.is_some() {
            if ev.kind != MouseEventKind::Down(MouseButton::Left) {
                return;
            }
            // A `[y]` in a confirmation, answered by pressing what it says.
            // Single-click, unlike the launcher's two: the dialog is itself the
            // second step — a click got here by asking for something that
            // stopped to ask, and asking twice about the same pointer teaches
            // nothing.
            if let Some(key) = layout.key_at(ev.column, ev.row) {
                self.on_key(key);
                return;
            }
            // The row menu answers a single click: unlike the launcher, every
            // entry is an action the keyboard reaches in one keystroke too, and
            // the destructive two both stop at a confirmation of their own.
            if self.mode == Mode::RowMenu {
                if let Some(i) = layout.menu_row_at(ev.column, ev.row) {
                    let items = super::menu::items(self);
                    if let Some(item) = items.get(i)
                        && item.enabled()
                    {
                        let action = item.action;
                        self.mode = Mode::List;
                        self.run_menu_action(action);
                    }
                } else if !layout.in_modal(ev.column, ev.row) {
                    self.mode = Mode::List;
                }
                return;
            }
            // A click on a suggestion is answered before the choices behind
            // it: the two lists are drawn in one modal, and while the field is
            // open the lower rows are the directories, not the agents.
            if self.mode == Mode::LaunchCwd
                && let Some(i) = layout.launch_cwd_row_at(ev.column, ev.row)
            {
                // One click picks, a second takes it — the same two-step the
                // choices above use, for the same reason: a stray click must
                // not silently move where the agent will start.
                match self.launch_cwd_pick == Some(i) {
                    true => self.take_launch_cwd(),
                    false => self.launch_cwd_pick = Some(i),
                }
                self.needs_redraw = true;
                return;
            }
            // One click picks; Enter, or a second click on the row already
            // picked, starts it — so no single stray click starts an agent.
            if let Some(i) = layout.launch_row_at(ev.column, ev.row) {
                match i == self.launch_cursor {
                    true => {
                        self.mode = Mode::List;
                        self.launch_selected();
                    }
                    false => {
                        self.launch_cursor = i;
                        self.needs_redraw = true;
                    }
                }
            } else if layout.modal_rect.is_some() && !layout.in_modal(ev.column, ev.row) {
                // Clicking off a modal is how everyone dismisses one. A modal
                // that did not record its rectangle swallows the click instead
                // of guessing that it was aimed elsewhere.
                self.mode = Mode::List;
            }
            return;
        }

        // The workspace bar owns its own row wherever it is drawn — over the
        // dashboard as much as over a set of panes — so it is asked before
        // either. Clicks and drags only: mouse capture also reports movement,
        // and switching tabs on a hover means the pointer resting anywhere near
        // the bar drags you out of the agent you are typing into.
        if self.on_mouse_workspace(ev, layout) {
            return;
        }

        // The footer's corner: the tunnel's link while there is one, and the
        // button that opens one while there is not. cctop holds the terminal's
        // mouse capture, so in most terminals a plain click on an OSC 8 link
        // never reaches the terminal that would follow it — answering it here is
        // what makes the link clickable without a modifier held down. Drawn over
        // a tab as much as over the dashboard, so answered before either.
        if ev.kind == MouseEventKind::Down(MouseButton::Left) {
            let on_corner = layout.share_corner_at(ev.column, ev.row);
            // Arming is about the click being made now. A click anywhere else
            // takes it back, which is what stops a forgotten first click from
            // turning an unrelated one into a tunnel.
            let armed = std::mem::replace(&mut self.share_arm, false);
            // Same rule as the corner's: arming is about the click being made
            // now, so any click that is not the second one on `q Quit` takes it
            // back. A first click that quits on the next unrelated one would be
            // worse than no button at all.
            let quit_armed = std::mem::replace(&mut self.quit_arm, false);
            if let Some(key) = layout.key_at(ev.column, ev.row) {
                self.on_hint_click(key, quit_armed);
                return;
            }
            if on_corner {
                self.on_share_corner(armed);
                return;
            }
        }

        // Inside a tab the rest of the mouse is the agents'.
        if self.tab > 0 {
            // Inside a pane the mouse is the agent's. Claude Code, opencode and
            // pi all ask for mouse reporting and act on it — placing the cursor
            // in the composer, picking a file, choosing from the agents list —
            // and cctop holds the terminal's capture, so without forwarding the
            // click simply went nowhere.
            //
            // The right button is the exception, and rmux is the reason. With
            // `mouse` on — which cctop turns on so the wheel scrolls — rmux
            // binds `MouseDown3Pane` to its own pane menu, so a right-click
            // inside an agent opened a rmux popup whose entries split the pane
            // in two. The binding lives in the server's `root` key table, not in
            // the session, so cctop cannot unbind it without changing the user's
            // own rmux sessions too; not sending the button is the fix that
            // stays inside cctop. No agent cctop hosts asks for right-click, so
            // nothing is lost by keeping it.
            let button = |b| match b {
                MouseButton::Left => Some(crate::attach::MouseButton::Left),
                MouseButton::Middle => Some(crate::attach::MouseButton::Middle),
                MouseButton::Right => None,
            };
            let action = match ev.kind {
                MouseEventKind::Down(b) => button(b).map(|b| (crate::attach::MouseKind::Press, b)),
                MouseEventKind::Up(b) => button(b).map(|b| (crate::attach::MouseKind::Release, b)),
                MouseEventKind::Drag(b) => button(b).map(|b| (crate::attach::MouseKind::Drag, b)),
                _ => None,
            };
            if let Some((kind, b)) = action {
                if let Some((i, col, row)) = layout.pane_at(ev.column, ev.row) {
                    // A press also moves the keyboard there, which is what
                    // clicking a pane means everywhere else. Only a press: a
                    // release ending a drag that wandered out of the pane it
                    // started in must not hand focus to whatever it landed on.
                    if kind == crate::attach::MouseKind::Press
                        && let Some(tab) = self.active_tab()
                    {
                        tab.focus = i;
                    }
                    if let Some(pane) = self.active_tab().and_then(|t| t.panes.get_mut(i)) {
                        // A failed send means that agent has gone, which the
                        // reaper already watches for.
                        let _ = pane.view.mouse(kind, b, col, row);
                    }
                }
                return;
            }
            // The wheel is the agent's, wherever it is pointed — cctop keeps no
            // scrollback of its own, so a pane's history lives in the agent (or
            // in the rmux around it) and only the agent can scroll it. The pane
            // under the pointer, not the focused one, because the wheel says
            // where it is aimed and stealing focus to answer it would move the
            // keyboard out from under someone mid-sentence.
            let up = match ev.kind {
                MouseEventKind::ScrollUp => true,
                MouseEventKind::ScrollDown => false,
                _ => return,
            };
            if let Some((i, col, row)) = layout.pane_at(ev.column, ev.row)
                && let Some(pane) = self.active_tab().and_then(|t| t.panes.get_mut(i))
            {
                // A failed send means that agent is gone, which the reaper is
                // already watching for. Unlike a keystroke there is nothing to
                // report: a scroll that landed on a dead pane asked for nothing.
                let _ = pane.view.wheel(up, col, row);
            }
            return;
        }
        match ev.kind {
            MouseEventKind::ScrollDown => {
                if layout.in_bottom_panel(ev.row) {
                    self.scroll_active_panel(1);
                } else {
                    self.move_selection(1);
                }
            }
            MouseEventKind::ScrollUp => {
                if layout.in_bottom_panel(ev.row) {
                    self.scroll_active_panel(-1);
                } else {
                    self.move_selection(-1);
                }
            }
            // Right-click a row for its menu, as the tab bar's right button
            // renames a tab: the actions are already a click away once the menu
            // is up, and reaching for Enter to open it was the one step in that
            // path a mouse could not take.
            MouseEventKind::Down(MouseButton::Right) => {
                if let Some(row) = layout.row_at(ev.row) {
                    let idx = self.scroll + row;
                    if idx < self.visible.len() {
                        self.selected = idx;
                        self.ensure_available_tab();
                        self.open_row_menu();
                    }
                }
            }
            MouseEventKind::Down(MouseButton::Left) => {
                if self.bottom_tab == 3
                    && let Some(offset) = layout.tool_log_row_at(ev.column, ev.row)
                {
                    self.toggle_tool_expansion(offset);
                } else if let Some(idx) = layout.tool_sidebar_at(ev.column, ev.row) {
                    self.tool_tab = idx;
                    self.tool_follow = true;
                    self.needs_redraw = true;
                } else if let Some(tab) = layout.tab_at(ev.column, ev.row) {
                    self.bottom_tab = tab;
                    self.save_prefs();
                    self.needs_redraw = true;
                } else if let Some(col) = layout.header_column_at(ev.column, ev.row) {
                    self.set_sort(col);
                } else if let Some(row) = layout.row_at(ev.row) {
                    let idx = self.scroll + row;
                    if idx < self.visible.len() {
                        self.selected = idx;
                        self.ensure_available_tab();
                        self.needs_redraw = true;
                    }
                }
            }
            _ => {}
        }
    }
}