rig-tui 0.45.0

A rig-harness plugin: the terminal view, with panels and tool renderers other plugins add to.
Documentation
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
//! The frame: when one is due ([`needs_redraw`]), where each panel goes
//! ([`layout`]), how a transcript is laid out and lit
//! ([`TranscriptLayout`]), and the compositor that copies every panel's
//! canvas to the terminal under the view's overlays ([`render`]).

use std::collections::{BTreeMap, HashMap};
use std::ops::Range;
use std::time::{Duration, Instant};

use bevy_ecs::change_detection::Tick;
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::ScheduleLabel;
use bevy_ecs::system::{SystemChangeTick, SystemParam};
use crossterm::cursor::MoveTo;
use ratatui::Frame;
use ratatui::backend::Backend;
use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListState, Padding, Paragraph};

use super::complete::Completion;
use super::editor::{Caret, Editor};
use super::input::InputContext;
use super::keys::KeyFocus;
use super::pane::{PaneState, TranscriptPane};
use super::panel::{
    self, PanelCanvas, PanelSpawns, Placement, RedrawArea, RequestRedraw, TerminalSize, TuiPanel,
};
use super::pick::{Choices, InlineStrip, Listing, PickerInput, Row};
use super::renderers::{
    AsideRenderer, CallDecorator, CallState, ContentRenderer, EntryRenderer, Looks,
    MessageDecorator, MessageView, Renderers, Role, ToolCallView, ToolRenderer, UNNAMED_HOLD,
};
use super::status::{AppStatus, StatusItems, StatusLine};
use super::terminal::{Tui, TuiTestScreen};
use super::theme::Theme;
use super::transcript::{
    CallFacts, CallTree, ChildCalls, Inside, Look, Marks, Part, ShownAside, StreamingCalls,
    Transcript, live_call, notice_lines, on_band, plain_lines,
};
use super::view::{
    DrawnTranscript, Highlight, MarkIn, Notices, RowWrap, ShownNotice, TranscriptItem,
    TranscriptSlot, TranscriptView, TuiView,
};
use super::wrap::wrap_all;
use rig_core::conversation;
use rig_core::message::{CallId, ToolCall};
use rig_ecs::agent::{ActiveTurn, AgentId, Calls, Conversation, Partial};
use rig_ecs::inbox::{DeliveryMode, Incoming};
use rig_ecs::stage::{Effect, ForAgent, Holds, Outcome, Settled, Started, SupersededBy};
use rig_ecs::tools::ToolRequest;
use rig_ecs::turn::{ModelRequest, SentTo};
use rig_harness::main_agent::ShownAgent;
use rig_harness::pick::{PickPreview, Picker};
use rig_harness::prelude::{CallOutput, ToolProgress};

/// Most items the completion list shows at once.
const COMPLETION_ROWS: usize = 8;

/// The shortest time between two frames drawn only for a streaming
/// reply's new text: the loop's own limit of 60 frames a second would lay
/// the whole reply out again for every few tokens.
const STREAM_FRAME: Duration = Duration::from_millis(33);

/// When [`needs_redraw`] last drew, and whether streamed text waits to be
/// drawn.
#[derive(Default)]
pub(crate) struct Paced {
    drawn: Option<Instant>,
    waiting: bool,
}

/// Whether anything drawn changed since the last frame; the parameters
/// are the list. A streaming reply alone draws at most every
/// [`STREAM_FRAME`]; its waiting text is drawn by one of the frames the
/// kernel runs after the wake that brought it ([`Wake`](rig_ecs::tasks::Wake)).
pub(crate) fn needs_redraw(
    (view, notices, facts): (Res<TuiView>, Res<Notices>, Res<CallFacts>),
    (focus, shown, screen): (Res<KeyFocus>, Res<ShownAgent>, Res<TerminalSize>),
    changed: Query<
        (),
        Or<(
            Changed<Conversation>,
            Changed<ActiveTurn>,
            Changed<Calls>,
            Changed<StatusItems>,
            Changed<TuiPanel>,
            Changed<ToolRenderer>,
            Changed<EntryRenderer>,
            Changed<ContentRenderer>,
            Changed<CallDecorator>,
            Changed<AsideRenderer>,
            Changed<MessageDecorator>,
        )>,
    >,
    widgets: Query<
        (),
        Or<(
            Changed<TranscriptPane>,
            Changed<TranscriptView>,
            Changed<PaneState>,
            Changed<Editor>,
            Changed<StatusLine>,
            Changed<PickerInput>,
            Changed<PickPreview>,
            Changed<Picker>,
        )>,
    >,
    partials: Query<(), Or<(Changed<Partial>, Changed<StreamingCalls>)>>,
    messages: Query<(), (With<Incoming>, Or<(Added<Started>, Added<Outcome>)>)>,
    calls: Query<
        (),
        (
            With<ToolRequest>,
            Or<(
                Added<Started>,
                Added<Outcome>,
                Changed<Holds>,
                Changed<ToolProgress>,
            )>,
        ),
    >,
    (app_status, test): (Option<Res<AppStatus>>, Option<Res<TuiTestScreen>>),
    (mut requests, mut repaints): (MessageReader<RequestRedraw>, MessageReader<RedrawArea>),
    mut paced: Local<Paced>,
) -> bool {
    // Both readers are drained, so neither redraws again for the same change.
    let requested = requests.read().count() + repaints.read().count() > 0;
    let changed = requested
        || view.is_changed()
        || notices.is_changed()
        || facts.is_changed()
        || shown.is_changed()
        || !widgets.is_empty()
        || focus.is_changed()
        || screen.is_changed()
        || app_status.is_some_and(|status| status.is_changed())
        || test.is_some_and(|test| test.is_changed())
        || !changed.is_empty()
        || !messages.is_empty()
        || !calls.is_empty();
    paced.waiting |= !partials.is_empty();
    let now = Instant::now();
    let next = paced.drawn.map(|drawn| drawn + STREAM_FRAME);
    if changed || (paced.waiting && next.is_none_or(|next| now >= next)) {
        paced.drawn = Some(now);
        paced.waiting = false;
        return true;
    }
    false
}

/// Redraws once something drawn went away.
pub(crate) fn redraw_on_remove(
    _: On<
        Remove<(
            TuiPanel,
            StatusItems,
            Holds,
            ToolProgress,
            TranscriptPane,
            Editor,
            PickerInput,
        )>,
    >,
    mut redraw: MessageWriter<RequestRedraw>,
) {
    redraw.write(RequestRedraw);
}

/// Lays every pane's transcript out again once a look went away.
pub(crate) fn forget_looks(
    _: On<
        Remove<(
            ToolRenderer,
            EntryRenderer,
            ContentRenderer,
            AsideRenderer,
            CallDecorator,
            MessageDecorator,
        )>,
    >,
    mut panes: Query<&mut PaneState>,
    mut redraw: MessageWriter<RequestRedraw>,
) {
    for mut pane in &mut panes {
        pane.bypass_change_detection().transcript.clear();
    }
    redraw.write(RequestRedraw);
}

/// Whether [`layout`] laid out a frame that [`render`] did not draw yet.
#[derive(Resource, Default)]
pub(crate) struct FrameLayout {
    due: bool,
    /// The box of the picker shown with a preview, its list at the left
    /// and its preview at the right.
    picker: Option<Rect>,
}

/// Whether [`layout`] laid out a frame that is not drawn yet: the
/// condition of [`TuiSystems::Draw`](super::TuiSystems::Draw) and
/// [`render`].
pub(crate) fn frame_due(frame: Res<FrameLayout>) -> bool {
    frame.due
}

/// The view's layout of a frame: the panels ([`layout`]), then the panes
/// in what they leave, each scroll placed
/// ([`lay_out_panes`](crate::pane::lay_out_panes)). Run in
/// [`TuiSystems::Layout`](crate::TuiSystems::Layout) ([`lay_out_frame`]),
/// and again after [`TuiSystems::Fit`](crate::TuiSystems::Fit) when a
/// panel changed there ([`lay_out_fitted`]).
#[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct LayOut;

/// When [`lay_out_frame`] last ran [`LayOut`].
#[derive(Resource, Default)]
pub(crate) struct LaidOutAt(Option<Tick>);

/// Lays out the frame ([`LayOut`]), in
/// [`TuiSystems::Layout`](crate::TuiSystems::Layout).
pub(crate) fn lay_out_frame(world: &mut World) {
    if world.try_run_schedule(LayOut).is_ok() {
        let now = world.change_tick();
        world.insert_resource(LaidOutAt(Some(now)));
    }
}

/// Lays out the frame again when a panel was spawned or changed since
/// [`lay_out_frame`], in [`TuiSystems::Fit`](crate::TuiSystems::Fit), so
/// a panel fitted to a pane's scroll is drawn in the frame that placed it.
pub(crate) fn lay_out_fitted(world: &mut World, panels: &mut QueryState<Ref<TuiPanel>>) {
    let Some(at) = world.get_resource::<LaidOutAt>().and_then(|laid| laid.0) else {
        return;
    };
    let now = world.read_change_tick();
    let fitted = panels
        .iter(world)
        .any(|panel| panel.last_changed().is_newer_than(at, now));
    if fitted {
        world.try_run_schedule(LayOut).ok();
    }
}

/// Lays out a frame: every panel by its placement, in the order spawned
/// (the default screen's first), an editor as tall as its text and a strip
/// as tall as what it shows.
pub(crate) fn layout(
    screens: (Option<Res<Tui>>, Option<Res<TuiTestScreen>>),
    (view, previewed): (
        Res<TuiView>,
        Query<(Entity, &PickerInput), With<PickPreview>>,
    ),
    mut frame: ResMut<FrameLayout>,
    mut panels: Query<(
        Entity,
        &TuiPanel,
        &mut PanelCanvas,
        Option<&Editor>,
        Has<InlineStrip>,
    )>,
    (spawns, ticks): (PanelSpawns, SystemChangeTick),
) {
    let size = match screens {
        (Some(tui), _) => tui.terminal.size().ok(),
        (None, Some(test)) => Some(test.size()),
        (None, None) => None,
    };
    let Some(size) = size else {
        return;
    };
    let screen = Rect::new(0, 0, size.width, size.height);
    let inline = u16::from(!view.inline.is_empty());
    let mut panels: Vec<_> = panels.iter_mut().collect();
    panels.sort_by_key(|(entity, ..)| panel::spawn_order(ticks.this_run(), &spawns, *entity));
    let panels = panels
        .into_iter()
        .map(|(entity, panel, canvas, editor, strip)| {
            let fits = editor.map(|editor| editor.fit_rows(screen.width));
            (entity, panel, fits.or(strip.then_some(inline)), canvas)
        });
    let previewed = previewed.iter().find(|(_, state)| !state.in_input());
    let boxed = Constraint::Percentage(90);
    let picker = previewed.map(|(entity, _)| (entity, screen.centered(boxed, boxed)));
    panel::lay_out(panels, screen, picker.as_slice());
    *frame = FrameLayout {
        due: true,
        picker: picker.map(|(_, area)| area),
    };
}

/// What the transcript reads of where a turn's tool calls stand.
#[derive(SystemParam)]
pub(crate) struct CallStates<'w, 's> {
    calls: Query<
        'w,
        's,
        (
            &'static ToolRequest,
            Option<&'static Holds>,
            Has<Started>,
            Has<Outcome>,
            Option<&'static ToolProgress>,
        ),
    >,
    names: Query<'w, 's, &'static Name>,
    outputs: Query<
        'w,
        's,
        (&'static ToolRequest, &'static CallOutput),
        (With<Started>, Without<Outcome>),
    >,
}

impl CallStates<'_, '_> {
    /// Where each tool call of `calls` stands, by its id: held while it
    /// has holds, else done once answered, running (with its progress
    /// line) once started, else waiting.
    pub(crate) fn of(
        &self,
        calls: impl IntoIterator<Item = Entity>,
    ) -> BTreeMap<CallId, CallState> {
        let found = calls
            .into_iter()
            .filter_map(|call| self.calls.get(call).ok());
        found
            .map(|(request, holds, started, answered, progress)| {
                let state = match holds {
                    Some(holds) => {
                        CallState::Held(holds.iter().map(|hold| self.name(hold)).collect())
                    }
                    None if answered => CallState::Done,
                    None if started => {
                        CallState::Running(progress.map(|ToolProgress(line)| line.clone()))
                    }
                    None => CallState::Waiting,
                };
                (request.call.id.clone(), state)
            })
            .collect()
    }

    /// The output so far of each running call of `calls` whose tool
    /// reported some, by its id.
    pub(crate) fn outputs(
        &self,
        calls: impl IntoIterator<Item = Entity>,
    ) -> BTreeMap<CallId, String> {
        let found = calls
            .into_iter()
            .filter_map(|call| self.outputs.get(call).ok());
        let outputs = found.map(|(request, output)| (request.call.id.clone(), output.text()));
        outputs.filter(|(_, text)| !text.is_empty()).collect()
    }

    /// A hold's name, which says why it holds.
    fn name(&self, hold: Entity) -> String {
        let name = self.names.get(hold).map_or(UNNAMED_HOLD, Name::as_str);
        name.to_owned()
    }
}

/// An agent, as its transcript draws it.
pub(crate) struct ShownConversation<'a> {
    pub(crate) entity: Entity,
    /// Its id; `None` for a conversation no agent of this app holds,
    /// which shows no notices.
    pub(crate) id: Option<&'a AgentId>,
    pub(crate) conversation: &'a conversation::Conversation,
    /// Whether its conversation changed since the last frame.
    pub(crate) changed: bool,
    /// The messages it accepted and has not sent yet, in the order they
    /// came.
    pub(crate) inbox: Vec<&'a Incoming>,
    /// Where its running turn's calls stand.
    pub(crate) calls: BTreeMap<CallId, CallState>,
    /// The output so far of its running calls that report it.
    pub(crate) outputs: BTreeMap<CallId, String>,
    /// The reply streaming in, if any, and the call streaming it.
    pub(crate) partial: Option<(Entity, &'a Partial)>,
    /// The tool calls it streams.
    pub(crate) streaming: Vec<ToolCall>,
    /// The asides in its conversation, in the order put.
    pub(crate) asides: Vec<ShownAside<'a>>,
    /// The calls under each of its tool calls that started any.
    pub(crate) children: ChildCalls,
}

impl<'a> ShownConversation<'a> {
    /// `conversation`, which no agent of this app holds, drawn as
    /// `entity`'s: no live parts, no notices.
    pub(crate) fn value(conversation: &'a conversation::Conversation, entity: Entity) -> Self {
        Self {
            entity,
            id: None,
            conversation,
            changed: true,
            inbox: Vec::new(),
            calls: BTreeMap::new(),
            outputs: BTreeMap::new(),
            partial: None,
            streaming: Vec::new(),
            asides: asides(conversation),
            children: ChildCalls::default(),
        }
    }
}

/// The asides of `conversation`, in the order put.
fn asides(conversation: &conversation::Conversation) -> Vec<ShownAside<'_>> {
    let asides = conversation.asides();
    asides.map(|(id, aside)| ShownAside { id, aside }).collect()
}

/// An effect not settled yet, a tool or model call or any other, as
/// [`TranscriptLayout`] reads it for [`ToolCallView::children`].
type LiveEffect = (
    Entity,
    &'static Effect,
    (
        Option<&'static ToolRequest>,
        Option<&'static ModelRequest>,
        Option<&'static SentTo>,
    ),
    (Option<&'static ForAgent>, Option<&'static Outcome>),
    (Has<Started>, Has<Partial>),
);

/// What the transcript is drawn from besides the agent.
pub(crate) struct Sources<'a> {
    /// The pane's view as plugins steer it: what is marked.
    pub(crate) view: &'a TranscriptView,
    /// The notices shown.
    pub(crate) notices: &'a Notices,
    /// The looks plugins added.
    pub(crate) renderers: &'a Renderers<'a>,
    /// What settled calls published and how long they ran.
    pub(crate) facts: &'a CallFacts,
    /// The transcript's colors.
    pub(crate) theme: &'a Theme,
}

/// Lays out an agent's transcript as the terminal view draws it, without
/// drawing it: for any agent, shown or not, in a run with the terminal or
/// without, by the view's own layout (its order, its renderers, wrapping
/// and the matches it lights), so an export, a copy, a search or a screen
/// reader reads what the view shows. Each layout renders and wraps the
/// whole transcript, so lay one out when asked, not every frame. It is
/// there in any run with the [`TuiPlugin`](crate::TuiPlugin), and takes
/// the [`TranscriptView`] to lay out with, such as a pane's, so a system
/// that changes the view lays out with it too.
///
/// ```no_run
/// use rig_harness::prelude::*;
/// use rig_tui::{MainPane, TranscriptLayout, TranscriptView};
///
/// /// `/export`: the agent's transcript as the main pane draws it, a line
/// /// per row.
/// fn export(
///     In(args): In<CommandArgs>,
///     layout: TranscriptLayout,
///     view: Single<&TranscriptView, With<MainPane>>,
/// ) {
///     let width = view.width().unwrap_or(100);
///     let Some(laid_out) = layout.lay_out(&view, args.agent, width) else {
///         return;
///     };
///     let rows: Vec<&str> = laid_out.rows().iter().map(|row| row.text.as_str()).collect();
///     std::fs::write("transcript.txt", rows.join("\n")).ok();
/// }
/// ```
#[derive(SystemParam)]
pub struct TranscriptLayout<'w, 's> {
    agents: Query<'w, 's, (&'static AgentId, &'static Conversation)>,
    pub(crate) active: Query<'w, 's, &'static ActiveTurn>,
    turns: Query<'w, 's, &'static Calls>,
    /// The replies streaming in that nothing superseded, such as a race's
    /// running members.
    partials: Query<'w, 's, &'static Partial, Without<SupersededBy>>,
    accepted: Query<
        'w,
        's,
        (&'static Effect, &'static Incoming, &'static ForAgent),
        (With<Started>, Without<Outcome>),
    >,
    pub(crate) looks: Looks<'w, 's>,
    calls: CallStates<'w, 's>,
    live: Query<'w, 's, LiveEffect, Without<Settled>>,
    names: Query<'w, 's, &'static Name>,
    facts: Res<'w, CallFacts>,
    streaming: Query<'w, 's, &'static StreamingCalls>,
    notices: Res<'w, Notices>,
    theme: Res<'w, Theme>,
}

impl TranscriptLayout<'_, '_> {
    /// `agent`'s transcript laid out in rows `width` columns wide (as
    /// [`TranscriptView::width`] says the view draws it; `u16::MAX` keeps
    /// a line in one row), the matches of `view`'s highlights counted, as
    /// the view would draw it with `view`. It has no rows on screen. [`DrawnTranscript::item`] takes one item's rows out of
    /// it. `None` when `agent` is no agent.
    pub fn lay_out(
        &self,
        view: &TranscriptView,
        agent: Entity,
        width: u16,
    ) -> Option<DrawnTranscript> {
        let shown = self.agent(agent, true)?;
        let renderers = self.looks.by_kind(None);
        let sources = Sources {
            view,
            notices: self.notices(),
            renderers: &renderers,
            facts: &self.facts,
            theme: &self.theme,
        };
        let mut transcript = Transcript::default();
        let parts = lay_out_transcript(&mut transcript, &sources, Some(&shown), width);
        Some(transcript.laid_out(Some(agent), &parts, &view.highlights, 0..0))
    }

    /// The agent `entity` as its transcript draws it, its conversation
    /// `changed` since the last frame or not; `None` when it is no agent.
    pub(crate) fn agent(&self, entity: Entity, changed: bool) -> Option<ShownConversation<'_>> {
        let (id, conversation) = self.agents.get(entity).ok()?;
        let turn = self.active.get(entity).ok();
        let calls = turn.and_then(|turn| self.turns.get(turn.turn()).ok());
        // The newest of the turn's replies streaming in that nothing
        // superseded: in a race, the member still running.
        let partial = calls.and_then(|calls| {
            let streams = calls.iter();
            let mut streams =
                streams.filter_map(|call| Some((call, self.partials.get(call).ok()?)));
            streams.next_back()
        });
        let streaming = partial.and_then(|(call, _)| self.streaming.get(call).ok());
        let mut inbox: Vec<(&Effect, &Incoming)> = self
            .accepted
            .iter()
            .filter(|(.., to)| to.0 == entity)
            .map(|(effect, message, _)| (effect, message))
            .collect();
        inbox.sort_by_key(|(effect, _)| effect.id());
        Some(ShownConversation {
            entity,
            id: Some(id),
            conversation,
            changed,
            inbox: inbox.into_iter().map(|(_, message)| message).collect(),
            calls: self.calls.of(calls.into_iter().flat_map(Calls::iter)),
            outputs: self.calls.outputs(calls.into_iter().flat_map(Calls::iter)),
            partial,
            streaming: streaming
                .into_iter()
                .flat_map(StreamingCalls::calls)
                .collect(),
            asides: asides(conversation),
            children: self.facts.children(&self.live_calls(), &id.0),
        })
    }

    /// The effects not settled yet, by the effects that started them, the
    /// calls standing as they stand now.
    fn live_calls(&self) -> CallTree {
        let mut live = CallTree::default();
        for (entity, effect, requests, (agent, outcome), (started, streams)) in &self.live {
            let state = match requests.0 {
                Some(request) => self.calls.of([entity]).remove(&request.call.id),
                None if outcome.is_some() => Some(CallState::Done),
                None if streams => Some(CallState::Streaming),
                None if started => Some(CallState::Running(None)),
                None => None,
            };
            let agent = agent.map(|ForAgent(agent)| *agent);
            let name = agent.and_then(|agent| self.names.get(agent).ok());
            let state = state.unwrap_or(CallState::Waiting);
            let child = live_call(requests, (name, outcome), state);
            live.add(effect.parent, effect.id(), child, None);
            let id = agent.and_then(|agent| self.agents.get(agent).ok());
            if let (Some(request), Some((id, _))) = (requests.0, id) {
                live.tool_call(effect.id(), &id.0, request.call.id.clone());
            }
        }
        live
    }

    /// What settled calls published and how long they ran.
    pub(crate) fn facts(&self) -> &CallFacts {
        &self.facts
    }

    /// The notices shown.
    pub(crate) fn notices(&self) -> &Notices {
        &self.notices
    }

    /// The transcript's colors.
    pub(crate) fn theme(&self) -> &Theme {
        &self.theme
    }
}

/// Brings `transcript` up to date with the shown agent and returns its
/// parts at `width`: each message, with the notices written inside it,
/// after the notes and notices written before it; then those after the
/// last message, the reply streaming in with the tool calls it streams,
/// and the messages the agent accepted and has not sent yet. Without an
/// agent, the notices about every agent.
pub(crate) fn lay_out_transcript(
    transcript: &mut Transcript,
    sources: &Sources<'_>,
    shown: Option<&ShownConversation<'_>>,
    width: u16,
) -> Vec<Part> {
    let wrap_width = usize::from(width.max(1));
    let mut parts = Vec::new();
    let view = sources.view;
    let Some(shown) = shown else {
        let (mut extra, mut marks) = (Vec::new(), Marks::new());
        let notices = sources.notices.numbered();
        for notice in notices.filter(|(_, notice)| notice.is_for_every_agent()) {
            notice_lines(notice, &mut extra, &mut marks);
        }
        parts.push(Part::rows(&extra, &marks, wrap_width));
        return parts;
    };
    let (conversation, count) = (shown.conversation, shown.conversation.messages().len());
    // Notes and notices before the first message, after each message,
    // and notices inside one.
    let mut after: Vec<Vec<(usize, &ShownNotice)>> = vec![Vec::new(); count + 1];
    let mut asides_after: Vec<Vec<&ShownAside<'_>>> = vec![Vec::new(); count + 1];
    let mut look = Look {
        renderers: sources.renderers,
        calls: &shown.calls,
        outputs: &shown.outputs,
        inside: HashMap::new(),
        facts: sources.facts,
        theme: sources.theme,
        children: &shown.children,
        marks: &view.marks,
    };
    let notices = shown.id.map(|id| (id, sources.notices));
    let notices = notices
        .into_iter()
        .flat_map(|(id, notices)| notices.numbered().map(move |(at, notice)| (id, at, notice)));
    for (id, at, notice) in notices {
        let before = match notice.slot(id, conversation) {
            None => continue,
            Some(TranscriptSlot::Top) => 0,
            Some(TranscriptSlot::After(index)) => index + 1,
            Some(TranscriptSlot::Inside(index, item)) => {
                let inside = look.inside.entry(index).or_default();
                inside.push((item, Inside::Notice(at, notice)));
                continue;
            }
        };
        if let Some(after) = after.get_mut(before) {
            after.push((at, notice));
        }
    }
    let drawn = shown.asides.iter();
    for aside in drawn.filter(|shown| sources.renderers.draws(shown.aside)) {
        let before = match TranscriptSlot::of_aside(aside.aside, conversation) {
            TranscriptSlot::Top => 0,
            TranscriptSlot::After(index) => index + 1,
            TranscriptSlot::Inside(index, item) => {
                let inside = look.inside.entry(index).or_default();
                inside.push((item, Inside::Aside(aside)));
                continue;
            }
        };
        if let Some(at) = asides_after.get_mut(before) {
            at.push(aside);
        }
    }
    transcript.update(shown.entity, conversation, shown.changed, &look, width);
    let mut after = after.into_iter().zip(asides_after);
    for index in 0..=count {
        let (notices, asides) = after.next().unwrap_or_default();
        for aside in asides {
            let mut lines = vec![Line::default()];
            lines.extend(on_band(
                aside.lines(sources.renderers, sources.theme),
                sources.theme.tool,
            ));
            let marks = [(aside.item(), 0..lines.len())];
            parts.push(Part::rows(&lines, &marks, wrap_width));
        }
        let (mut extra, mut marks) = (Vec::new(), Marks::new());
        for notice in notices {
            notice_lines(notice, &mut extra, &mut marks);
        }
        if index == count {
            if let Some((call, partial)) = shown.partial {
                let view = |role, text, marks| MessageView {
                    role,
                    message: None,
                    block: 0,
                    text,
                    marks,
                    width: wrap_width,
                    theme: *sources.theme,
                };
                let reasoning = view(Role::Reasoning, &partial.reasoning, &[]);
                extra.extend(reasoning.default_lines());
                if !partial.text.is_empty() {
                    extra.push(Line::default());
                    let marks = look.marks_in(MarkIn::Streaming(call));
                    extra.extend(view(Role::Assistant, &partial.text, &marks).default_lines());
                }
            }
            for call in &shown.streaming {
                let mut view = ToolCallView::new(call, None, CallState::Streaming);
                view.theme = *sources.theme;
                extra.extend(on_band(sources.renderers.call(&view), sources.theme.tool));
            }
            inbox_lines(&shown.inbox, sources.theme, &mut extra);
            parts.push(Part::rows(&extra, &marks, wrap_width));
            break;
        }
        if !extra.is_empty() {
            parts.push(Part::rows(&extra, &marks, wrap_width));
        }
        parts.push(Part::Message(index));
    }
    parts
}

/// `rows`, the first being row `first` of the transcript, with the rows of
/// the `selected` item on `theme`'s selected color, over their bands.
pub(crate) fn decorate(
    rows: Vec<Line<'static>>,
    first: usize,
    selected: Option<Range<usize>>,
    theme: &Theme,
) -> Vec<Line<'static>> {
    let on = theme.selection();
    rows.into_iter()
        .enumerate()
        .map(|(index, row)| match &selected {
            Some(rows) if rows.contains(&(first + index)) => {
                let spans = row.spans.into_iter().map(|span| span.patch_style(on));
                Line::from(spans.collect::<Vec<_>>()).style(row.style.patch(on))
            }
            _ => row,
        })
        .collect()
}

/// The highlights to light, and where the spans of those with any are:
/// by the highlight's index and the first row of their line in the
/// transcript.
#[derive(Default)]
pub(crate) struct Lights<'a> {
    pub(crate) highlights: &'a [Highlight],
    pub(crate) placed: HashMap<(usize, usize), Vec<Range<usize>>>,
}

impl Lights<'_> {
    /// Whether any of them lights anything.
    pub(crate) fn any(&self) -> bool {
        let lights =
            |highlight: &Highlight| !highlight.text.is_empty() || !highlight.spans.is_empty();
        self.highlights.iter().any(lights)
    }
}

/// `rows`, the first being row `first` of the transcript, each continuing
/// the one before it as `continues` says, with each match of `lights` in
/// its style. A match is sought in each line the rows wrap, so one a wrap
/// splits lights on both rows.
pub(crate) fn light(
    rows: &[Line<'static>],
    continues: &[Option<RowWrap>],
    first: usize,
    lights: &Lights<'_>,
) -> Vec<Line<'static>> {
    let joints = continues.iter().map(Option::as_ref);
    let lit_rows = lit(rows.iter().zip(joints), first, lights);
    rows.iter()
        .zip(lit_rows)
        .map(|(line, row)| {
            if !row.cells.iter().any(|(.., lit)| *lit) {
                return line.clone();
            }
            let mut spans: Vec<Span<'static>> = Vec::new();
            for (character, style, _) in row.cells {
                match spans.last_mut() {
                    Some(span) if span.style == style => span.content.to_mut().push(character),
                    _ => spans.push(Span::styled(character.to_string(), style)),
                }
            }
            let mut lit = Line::from(spans).style(line.style);
            lit.alignment = line.alignment;
            lit
        })
        .collect()
}

/// A row once highlights are lit: its characters, each with its style and
/// whether a match lit it, and how many matches of each highlight start
/// in it.
pub(crate) struct LitRow {
    pub(crate) cells: Vec<(char, Style, bool)>,
    pub(crate) matches: Vec<usize>,
}

/// A character of a line being searched: in which row it is drawn, at
/// which character of it (`None` for a blank the wrap dropped), and
/// whether a match lit it.
struct Slot {
    row: usize,
    at: Option<usize>,
    character: char,
    lit: bool,
}

/// `rows`, the first being row `first` of the transcript, each with how it
/// continues the row before it, with the matches of `lights` lit. A match
/// is sought in the line the rows wrap, so it may span rows; it counts in
/// the row it starts in. A match over a character an earlier match lit is
/// no match.
pub(crate) fn lit<'a, 'line: 'a>(
    rows: impl IntoIterator<Item = (&'a Line<'line>, Option<&'a RowWrap>)>,
    first: usize,
    lights: &Lights<'_>,
) -> Vec<LitRow> {
    let mut lit = Vec::new();
    // The line being gathered, and its first row in the transcript.
    let mut line = Vec::new();
    let mut start = first;
    for (row, joint) in rows {
        if joint.is_none() {
            light_line(&mut lit, &mut line, start, lights);
            line.clear();
            start = first + lit.len();
        }
        let at = lit.len();
        let cells: Vec<(char, Style, bool)> = row
            .spans
            .iter()
            .flat_map(|span| span.content.chars().map(move |c| (c, span.style, false)))
            .collect();
        let indent = joint.map_or(0, |joint| joint.indent);
        let gap = joint.map(|joint| joint.gap.chars()).into_iter().flatten();
        line.extend(gap.map(|character| Slot {
            row: at,
            at: None,
            character,
            lit: false,
        }));
        let drawn = cells.iter().enumerate().skip(indent);
        line.extend(drawn.map(|(index, (character, ..))| Slot {
            row: at,
            at: Some(index),
            character: *character,
            lit: false,
        }));
        lit.push(LitRow {
            cells,
            matches: vec![0; lights.highlights.len()],
        });
    }
    light_line(&mut lit, &mut line, start, lights);
    lit
}

/// Lights the matches of `lights` in `line`, which starts at row `start`
/// of the transcript, in the `rows` it is drawn in.
fn light_line(rows: &mut [LitRow], line: &mut [Slot], start: usize, lights: &Lights<'_>) {
    for (index, highlight) in lights.highlights.iter().enumerate() {
        if !highlight.spans.is_empty() {
            let spans = lights.placed.get(&(index, start));
            for bytes in spans.into_iter().flatten() {
                if let Some(window) = slots_of(line, bytes) {
                    light_match(rows, line, window, (index, highlight.style));
                }
            }
            continue;
        }
        let needle: Vec<char> = highlight.text.chars().collect();
        let same = |a: char, b: char| {
            if highlight.match_case {
                a == b
            } else {
                a.to_lowercase().eq(b.to_lowercase())
            }
        };
        let mut at = 0;
        while !needle.is_empty() && at + needle.len() <= line.len() {
            let window = at..at + needle.len();
            let text = line.get(window.clone()).unwrap_or_default();
            let found = text
                .iter()
                .zip(&needle)
                .all(|(slot, n)| same(slot.character, *n));
            if found && light_match(rows, line, window, (index, highlight.style)) {
                at += needle.len();
            } else {
                at += 1;
            }
        }
    }
}

/// The characters of `line` that `bytes` of its text are; `None` for none,
/// or for bytes that cut a character or reach past the line.
fn slots_of(line: &[Slot], bytes: &Range<usize>) -> Option<Range<usize>> {
    let mut at = 0;
    let (mut from, mut to) = (None, None);
    for (index, slot) in line.iter().enumerate() {
        if at == bytes.start {
            from = Some(index);
        }
        if at == bytes.end {
            to = Some(index);
        }
        at += slot.character.len_utf8();
    }
    if at == bytes.end {
        to = Some(line.len());
    }
    Some(from?..to?).filter(|window| !window.is_empty())
}

/// Lights the characters `window` of `line` as a match of highlight
/// `index` in `style`, counted in the row it starts in, unless an earlier
/// match lit one of them; says whether it did.
fn light_match(
    rows: &mut [LitRow],
    line: &mut [Slot],
    window: Range<usize>,
    (index, style): (usize, Style),
) -> bool {
    let window = line.get_mut(window).unwrap_or_default();
    if window.iter().any(|slot| slot.lit) {
        return false;
    }
    let start = window.first().map(|slot| slot.row);
    let count = start.and_then(|row| rows.get_mut(row)?.matches.get_mut(index));
    if let Some(count) = count {
        *count += 1;
    }
    for slot in window {
        slot.lit = true;
        let row = rows.get_mut(slot.row);
        let cell = slot.at.and_then(|at| row?.cells.get_mut(at));
        if let Some((_, style_there, lit)) = cell {
            *style_there = style_there.patch(style);
            *lit = true;
        }
    }
    true
}

/// Draws the frame [`layout`] laid out: a compositor. It copies every
/// panel's canvas in z-order (the sides and the rest, then the boxes over
/// the screen), then draws the view's own overlays over everything: the
/// text of a question answered in the input above the editor with the
/// keys, the picker or question shown, the completion list, and the caret
/// of the editor with the keys.
pub(crate) fn render(
    (mut tui, mut test): (Option<ResMut<Tui>>, Option<ResMut<TuiTestScreen>>),
    mut drew: ResMut<TuiFrame>,
    view: Res<TuiView>,
    (state, focus, choices): (Res<InputContext>, Res<KeyFocus>, Choices),
    mut frame_layout: ResMut<FrameLayout>,
    (panels, carets): (Query<(Entity, &TuiPanel, &PanelCanvas)>, Query<&Caret>),
    (spawns, ticks): (PanelSpawns, SystemChangeTick),
    mut showing: Query<(Entity, &mut PickerInput, Option<&PickPreview>)>,
    mut repaints: MessageReader<RedrawArea>,
    theme: Res<Theme>,
) -> Result {
    if !std::mem::take(&mut frame_layout.due) {
        return Ok(());
    }
    let repaint: Vec<Rect> = repaints.read().map(|repaint| repaint.0).collect();
    let picker = showing.iter().find(|(_, choosing, _)| !choosing.in_input());
    let picker = picker.map(|(entity, ..)| entity);
    // The panels at the sides, then the boxes over the screen, then those
    // within another, those within the picker over it.
    let layer = |panel: &TuiPanel| match panel.placement {
        Placement::Over { .. } => 1,
        Placement::Within { panel, .. } if Some(panel) == picker => 3,
        Placement::Within { .. } => 2,
        _ => 0,
    };
    let keyed = state.editor().and_then(|editor| {
        let found = panels.get(editor).ok();
        found.map(|(.., canvas)| canvas.area())
    });
    let mut panels: Vec<_> = panels.iter().collect();
    let order = |entity| panel::spawn_order(ticks.this_run(), &spawns, entity);
    panels.sort_by_key(|(entity, panel, _)| (layer(panel), order(*entity)));
    let (under, over_picker): (Vec<_>, Vec<_>) = panels
        .into_iter()
        .partition(|(_, panel, _)| layer(panel) < 3);
    let edit = showing.iter().find(|(_, choosing, _)| choosing.in_input());
    let edit = edit.and_then(|(entity, ..)| choices.shown(entity));
    let cursor = carets.iter().find_map(|caret| caret.0);
    let cursor = cursor.map(|at| MoveTo(at.x, at.y));
    let mut areas = TuiFrame::default();
    let draw_frame = |frame: &mut Frame| {
        let screen = frame.area();
        let mut overlays = Vec::new();
        let mut drawn = Vec::new();
        let mut copy =
            |frame: &mut Frame, (entity, panel, canvas): &(Entity, &TuiPanel, &PanelCanvas)| {
                canvas.copy_to(frame.buffer_mut());
                drawn.push((*entity, canvas.area()));
                if layer(panel) > 0 {
                    overlays.push(canvas.area());
                }
            };
        for panel in &under {
            copy(frame, panel);
        }
        let body = edit.as_ref().zip(keyed);
        let body = body.and_then(|(edit, at)| draw_body(frame, &theme, edit.body, at));
        // Drawing the picker keeps its scroll within its body: drawing's
        // own bookkeeping, not a change to redraw for.
        let shown = picker.and_then(|picker| {
            let (_, choosing, preview) = showing.get_mut(picker).ok()?;
            Some((choices.shown(picker)?, choosing, preview))
        });
        let mut over = body.into_iter().collect::<Vec<_>>();
        match (shown, &view.completion, keyed) {
            (Some((shown, mut choosing, preview)), ..) => {
                let preview = preview.zip(frame_layout.picker);
                let choosing = choosing.bypass_change_detection();
                over.push(draw_choice(frame, &theme, choosing, &shown, preview));
                for panel in &over_picker {
                    copy(frame, panel);
                }
            }
            (None, Some(completion), Some(at)) if focus.0.is_none() => {
                over.extend(draw_completion(frame, &theme, completion, at));
            }
            _ => {}
        }
        overlays.extend(over);
        if theme.plain {
            strip_colors(frame.buffer_mut());
        }
        areas = TuiFrame {
            screen,
            areas: drawn,
            overlays,
        };
    };
    let drawn = match (tui.as_deref_mut(), test.as_mut()) {
        (Some(tui), _) => {
            tui.begin_frame()?;
            let drawn = tui.terminal.draw(draw_frame);
            let drawn = drawn.map(|completed| cells_in(completed.buffer, &repaint));
            let drawn = drawn.and_then(|cells| {
                let cells = cells.iter().map(|(x, y, cell)| (*x, *y, cell));
                tui.terminal.backend_mut().draw(cells)
            });
            tui.end_frame(cursor.filter(|_| drawn.is_ok()))?;
            drawn
        }
        (None, Some(test)) => {
            // Drawing is not a change to redraw for; a resize is.
            test.bypass_change_detection().draw(draw_frame, &repaint);
            Ok(())
        }
        (None, None) => Ok(()),
    };
    drew.set_if_neq(areas);
    drawn?;
    Ok(())
}

/// The cells of `buffer` in `areas`, clipped to it, to write again.
fn cells_in(buffer: &Buffer, areas: &[Rect]) -> Vec<(u16, u16, Cell)> {
    let areas = areas.iter().map(|area| area.intersection(buffer.area));
    let positions = areas.flat_map(|area| area.positions());
    positions
        .filter_map(|at| Some((at.x, at.y, buffer.cell(at)?.clone())))
        .collect()
}

/// Whether a selection moves through `item`: all but a decorator's rows,
/// which belong to their call.
pub(crate) fn selectable(item: &TranscriptItem) -> bool {
    !matches!(
        item,
        TranscriptItem::Decoration(..) | TranscriptItem::MessageDecoration(..)
    )
}

/// Where the last frame drew each part of the screen: for a plugin that
/// writes to the terminal itself after the frame ([`TerminalWrite`]),
/// such as an image over the rows its content renderer left blank,
/// which finds those rows in a pane's [`DrawnTranscript`] (see
/// [`TuiFrame::screen_row`]) and leaves out what an overlay covers. The
/// view writes it after each frame it draws, in
/// [`TuiSystems::Render`](crate::TuiSystems::Render); read it after that.
/// All empty in a run without the terminal.
///
/// [`TerminalWrite`]: crate::TerminalWrite
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)]
pub struct TuiFrame {
    /// The whole screen.
    pub screen: Rect,
    /// Each panel drawn, the default screen's transcript, input and status
    /// line among them, with its area, in the order drawn.
    pub areas: Vec<(Entity, Rect)>,
    /// What was drawn over the rest: the panels placed over the screen,
    /// the picker or question, the completion list, a question's text
    /// above the input.
    pub overlays: Vec<Rect>,
}

impl TuiFrame {
    /// The screen row the `row` of `drawn` (a pane's transcript this frame
    /// drew) is on, when it is on screen.
    pub fn screen_row(&self, drawn: &DrawnTranscript, row: usize) -> Option<u16> {
        let on_screen = drawn.on_screen();
        if !on_screen.contains(&row) {
            return None;
        }
        let below_top = u16::try_from(row - on_screen.start).ok()?;
        Some(drawn.area().y.saturating_add(below_top))
    }

    /// Where the panel `entity` was drawn, if it was.
    pub fn area(&self, entity: Entity) -> Option<Rect> {
        let drawn = self.areas.iter().find(|(drawn, _)| *drawn == entity);
        drawn.map(|(_, area)| *area)
    }

    /// Whether an overlay covers any of `area`.
    pub fn covered(&self, area: Rect) -> bool {
        self.overlays.iter().any(|over| over.intersects(area))
    }
}

/// What a question answered in the input is about, on a raised surface
/// just above the input; where, if it is drawn.
fn draw_body(frame: &mut Frame, theme: &Theme, body: &str, input: Rect) -> Option<Rect> {
    if body.trim().is_empty() {
        return None;
    }
    let lines = wrap_all(
        &plain_lines(body, Style::new()),
        usize::from(input.width.saturating_sub(2).max(1)),
    );
    let rows = u16::try_from(lines.len()).unwrap_or(u16::MAX);
    let height = rows.saturating_add(2).min(input.y);
    if height < 3 {
        return None;
    }
    let area = Rect::new(input.x, input.y - height, input.width, height);
    frame.render_widget(Clear, area);
    frame.render_widget(Paragraph::new(lines).block(theme.raised("")), area);
    Some(area)
}

/// The completion list on a raised surface just above the input, its keys
/// on its last row; where, if it fit.
fn draw_completion(
    frame: &mut Frame,
    theme: &Theme,
    completion: &Completion,
    input: Rect,
) -> Option<Rect> {
    let rows = completion.items.len().min(COMPLETION_ROWS);
    let height = u16::try_from(rows + 2).unwrap_or(3).min(input.y);
    if height < 3 {
        return None;
    }
    let area = Rect::new(input.x, input.y - height, input.width, height);
    frame.render_widget(Clear, area);
    let marker = completion
        .kind
        .marker()
        .map(String::from)
        .unwrap_or_default();
    let items: Vec<Line<'static>> = completion
        .items
        .iter()
        .map(|item| {
            let mut spans = vec![Span::from(format!("{marker}{}", item.text))];
            if !item.detail.is_empty() {
                spans.push(Span::from(format!("  {}", item.detail)).dim());
            }
            Line::from(spans)
        })
        .collect();
    let mut state = ListState::default().with_selected(Some(completion.selected));
    frame.render_stateful_widget(
        List::new(items)
            .block(with_keys(
                theme.raised(""),
                "Tab or Enter completes · Esc closes",
            ))
            .highlight_style(theme.selection()),
        area,
        &mut state,
    );
    Some(area)
}

/// The rows a picker's list keeps, at least, when a long body takes the
/// rest of its box.
const LEAST_LIST: usize = 3;

/// The picker or question on a raised surface centred over the
/// transcript, titled, its keys on its last row: its body,
/// scrolled to `choosing.scroll` (kept within it), what is typed, then the
/// rows it leaves, each with its detail at the right and, in a choice of
/// several, its tick box; for free text, the typed answer.
fn draw_choice(
    frame: &mut Frame,
    theme: &Theme,
    choosing: &mut PickerInput,
    shown: &Listing<'_>,
    preview: Option<(&PickPreview, Rect)>,
) -> Rect {
    let area = frame.area();
    // With a preview, the list takes the left of its box, the preview the
    // right: text drawn here, a conversation by its pane.
    let boxed = preview.map(|(preview, at)| {
        let [list, beside] =
            Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(at);
        (at, list, preview, beside)
    });
    let width = boxed.map_or(area.width.saturating_mul(4) / 5, |(_, list, ..)| list.width);
    let more = shown.more(choosing);
    let body = wrap_all(
        &plain_lines(more.unwrap_or(shown.body), Style::new()),
        usize::from(width.saturating_sub(2).max(1)),
    );
    let rows = shown.rows(&choosing.filter);
    // Room for the body, the typed line and the rows, up to 3/4 of the
    // screen.
    let wanted = body.len() + usize::from(!body.is_empty()) + 1 + rows.len() + 2;
    let height = u16::try_from(wanted)
        .unwrap_or(u16::MAX)
        .min(area.height.saturating_mul(3) / 4)
        .max(5);
    let popup = match boxed {
        Some((_, list, ..)) => list,
        None => area.centered(Constraint::Length(width), Constraint::Length(height)),
    };
    frame.render_widget(Clear, popup);
    if let Some((_, _, PickPreview::Text(text), beside)) = boxed {
        let wide = usize::from(beside.width.saturating_sub(2).max(1));
        let lines = wrap_all(&plain_lines(text, Style::new()), wide);
        frame.render_widget(Clear, beside);
        let block = theme.raised("preview");
        frame.render_widget(Paragraph::new(lines).block(block), beside);
    }
    let inner = theme.raised("").inner(popup);
    // The body takes what the typed line, a gap and the list's least
    // leave, and scrolls when that is not all of it.
    let list_least = rows.len().clamp(1, LEAST_LIST);
    let room = usize::from(inner.height).saturating_sub(2 + list_least);
    let body_height = body.len().min(room);
    choosing.scroll = choosing.scroll.min(body.len() - body_height);
    let mut keys = vec![if shown.free_text {
        "type to filter or answer"
    } else {
        "type to filter"
    }];
    if body_height < body.len() {
        keys.push("Shift+↑↓ scrolls");
    }
    if shown.ticks {
        keys.push("Tab ticks");
    }
    if more.is_some() {
        keys.push("← hides more");
    } else if shown.items.iter().any(|item| item.more.is_some()) {
        keys.push("→ shows more");
    }
    let keys = format!("{}, Enter picks, Esc closes", keys.join(", "));
    frame.render_widget(with_keys(theme.raised(shown.title), &keys), popup);
    let [body_area, _, typed, list] = Layout::vertical([
        Constraint::Length(u16::try_from(body_height).unwrap_or(u16::MAX)),
        Constraint::Length(u16::from(body_height > 0)),
        Constraint::Length(1),
        Constraint::Min(1),
    ])
    .areas(inner);
    let scroll = u16::try_from(choosing.scroll).unwrap_or(u16::MAX);
    frame.render_widget(Paragraph::new(body).scroll((scroll, 0)), body_area);
    frame.render_widget(Line::from(format!("› {}▏", choosing.filter)), typed);
    let width = usize::from(list.width);
    let items: Vec<Line<'static>> = rows
        .iter()
        .map(|row| match row {
            Row::Item(index) => {
                let Some(item) = shown.items.get(*index) else {
                    return Line::default();
                };
                if !shown.ticks {
                    return item.line(width);
                }
                let tick = match (shown.multiple, choosing.ticked.contains(index)) {
                    (true, true) => "[x] ",
                    (true, false) => "[ ] ",
                    (false, true) => "(x) ",
                    (false, false) => "( ) ",
                };
                let mut line = item.line(width.saturating_sub(tick.len()));
                line.spans.insert(0, Span::from(tick));
                line
            }
            Row::Typed if choosing.filter.trim().is_empty() => {
                Line::from("✎ or type an answer").dim()
            }
            Row::Typed if choosing.ticked.is_empty() => {
                Line::from(format!("✎ {}", choosing.filter.trim()))
            }
            Row::Typed => Line::from(format!("✎ {} (with the ticked)", choosing.filter.trim())),
        })
        .collect();
    let mut state = ListState::default().with_selected(Some(choosing.selected));
    frame.render_stateful_widget(
        List::new(items).highlight_style(theme.selection()),
        list,
        &mut state,
    );
    boxed.map_or(popup, |(whole, ..)| whole)
}

/// Every cell of `buffer` in the terminal's own colors, keeping its bold,
/// underline and the like, for a [`plain`](Theme::plain) theme.
fn strip_colors(buffer: &mut Buffer) {
    for cell in &mut buffer.content {
        cell.fg = Color::Reset;
        cell.bg = Color::Reset;
        cell.underline_color = Color::Reset;
    }
}

/// `surface` with `keys` dim at the right of its last row, where its
/// padding was.
fn with_keys<'a>(surface: Block<'a>, keys: &str) -> Block<'a> {
    surface
        .padding(Padding::horizontal(1))
        .title_bottom(Line::from(format!("{keys} ")).dim().right_aligned())
}

/// The messages the agent accepted and has not sent yet, under the
/// transcript, in the order they came.
fn inbox_lines(inbox: &[&Incoming], theme: &Theme, lines: &mut Vec<Line<'static>>) {
    if inbox.is_empty() {
        return;
    }
    lines.push(Line::default());
    for message in inbox {
        let when = match message.mode {
            DeliveryMode::Steer => "steering",
            DeliveryMode::Queue => "after this turn",
            DeliveryMode::Inform => "noted",
        };
        let text = message.text();
        let mut rows = text.lines();
        let first = Line::from(vec![
            Span::from(format!("  ⏵ {when}: ")).dim(),
            Span::from(rows.next().unwrap_or_default().to_owned()).dim(),
        ]);
        let rest = rows.map(|row| Line::from(format!("    {row}")).dim());
        lines.extend(on_band(std::iter::once(first).chain(rest), theme.user));
    }
}

#[cfg(test)]
mod tests;