Skip to main content

rig_tui/
render.rs

1//! The frame: when one is due ([`needs_redraw`]), where each panel goes
2//! ([`layout`]), how a transcript is laid out and lit
3//! ([`TranscriptLayout`]), and the compositor that copies every panel's
4//! canvas to the terminal under the view's overlays ([`render`]).
5
6use std::collections::{BTreeMap, HashMap};
7use std::ops::Range;
8use std::time::{Duration, Instant};
9
10use bevy_ecs::change_detection::Tick;
11use bevy_ecs::prelude::*;
12use bevy_ecs::schedule::ScheduleLabel;
13use bevy_ecs::system::{SystemChangeTick, SystemParam};
14use crossterm::cursor::MoveTo;
15use ratatui::Frame;
16use ratatui::backend::Backend;
17use ratatui::buffer::{Buffer, Cell};
18use ratatui::layout::{Constraint, Layout, Rect};
19use ratatui::style::{Color, Style, Stylize};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Clear, List, ListState, Padding, Paragraph};
22
23use super::complete::Completion;
24use super::editor::{Caret, Editor};
25use super::input::InputContext;
26use super::keys::KeyFocus;
27use super::pane::{PaneState, TranscriptPane};
28use super::panel::{
29    self, PanelCanvas, PanelSpawns, Placement, RedrawArea, RequestRedraw, TerminalSize, TuiPanel,
30};
31use super::pick::{Choices, InlineStrip, Listing, PickerInput, Row};
32use super::renderers::{
33    AsideRenderer, CallDecorator, CallState, ContentRenderer, EntryRenderer, Looks,
34    MessageDecorator, MessageView, Renderers, Role, ToolCallView, ToolRenderer, UNNAMED_HOLD,
35};
36use super::status::{AppStatus, StatusItems, StatusLine};
37use super::terminal::{Tui, TuiTestScreen};
38use super::theme::Theme;
39use super::transcript::{
40    CallFacts, CallTree, ChildCalls, Inside, Look, Marks, Part, ShownAside, StreamingCalls,
41    Transcript, live_call, notice_lines, on_band, plain_lines,
42};
43use super::view::{
44    DrawnTranscript, Highlight, MarkIn, Notices, RowWrap, ShownNotice, TranscriptItem,
45    TranscriptSlot, TranscriptView, TuiView,
46};
47use super::wrap::wrap_all;
48use rig_core::conversation;
49use rig_core::message::{CallId, ToolCall};
50use rig_ecs::agent::{ActiveTurn, AgentId, Calls, Conversation, Partial};
51use rig_ecs::inbox::{DeliveryMode, Incoming};
52use rig_ecs::stage::{Effect, ForAgent, Holds, Outcome, Settled, Started, SupersededBy};
53use rig_ecs::tools::ToolRequest;
54use rig_ecs::turn::{ModelRequest, SentTo};
55use rig_harness::main_agent::ShownAgent;
56use rig_harness::pick::{PickPreview, Picker};
57use rig_harness::prelude::{CallOutput, ToolProgress};
58
59/// Most items the completion list shows at once.
60const COMPLETION_ROWS: usize = 8;
61
62/// The shortest time between two frames drawn only for a streaming
63/// reply's new text: the loop's own limit of 60 frames a second would lay
64/// the whole reply out again for every few tokens.
65const STREAM_FRAME: Duration = Duration::from_millis(33);
66
67/// When [`needs_redraw`] last drew, and whether streamed text waits to be
68/// drawn.
69#[derive(Default)]
70pub(crate) struct Paced {
71    drawn: Option<Instant>,
72    waiting: bool,
73}
74
75/// Whether anything drawn changed since the last frame; the parameters
76/// are the list. A streaming reply alone draws at most every
77/// [`STREAM_FRAME`]; its waiting text is drawn by one of the frames the
78/// kernel runs after the wake that brought it ([`Wake`](rig_ecs::tasks::Wake)).
79pub(crate) fn needs_redraw(
80    (view, notices, facts): (Res<TuiView>, Res<Notices>, Res<CallFacts>),
81    (focus, shown, screen): (Res<KeyFocus>, Res<ShownAgent>, Res<TerminalSize>),
82    changed: Query<
83        (),
84        Or<(
85            Changed<Conversation>,
86            Changed<ActiveTurn>,
87            Changed<Calls>,
88            Changed<StatusItems>,
89            Changed<TuiPanel>,
90            Changed<ToolRenderer>,
91            Changed<EntryRenderer>,
92            Changed<ContentRenderer>,
93            Changed<CallDecorator>,
94            Changed<AsideRenderer>,
95            Changed<MessageDecorator>,
96        )>,
97    >,
98    widgets: Query<
99        (),
100        Or<(
101            Changed<TranscriptPane>,
102            Changed<TranscriptView>,
103            Changed<PaneState>,
104            Changed<Editor>,
105            Changed<StatusLine>,
106            Changed<PickerInput>,
107            Changed<PickPreview>,
108            Changed<Picker>,
109        )>,
110    >,
111    partials: Query<(), Or<(Changed<Partial>, Changed<StreamingCalls>)>>,
112    messages: Query<(), (With<Incoming>, Or<(Added<Started>, Added<Outcome>)>)>,
113    calls: Query<
114        (),
115        (
116            With<ToolRequest>,
117            Or<(
118                Added<Started>,
119                Added<Outcome>,
120                Changed<Holds>,
121                Changed<ToolProgress>,
122            )>,
123        ),
124    >,
125    (app_status, test): (Option<Res<AppStatus>>, Option<Res<TuiTestScreen>>),
126    (mut requests, mut repaints): (MessageReader<RequestRedraw>, MessageReader<RedrawArea>),
127    mut paced: Local<Paced>,
128) -> bool {
129    // Both readers are drained, so neither redraws again for the same change.
130    let requested = requests.read().count() + repaints.read().count() > 0;
131    let changed = requested
132        || view.is_changed()
133        || notices.is_changed()
134        || facts.is_changed()
135        || shown.is_changed()
136        || !widgets.is_empty()
137        || focus.is_changed()
138        || screen.is_changed()
139        || app_status.is_some_and(|status| status.is_changed())
140        || test.is_some_and(|test| test.is_changed())
141        || !changed.is_empty()
142        || !messages.is_empty()
143        || !calls.is_empty();
144    paced.waiting |= !partials.is_empty();
145    let now = Instant::now();
146    let next = paced.drawn.map(|drawn| drawn + STREAM_FRAME);
147    if changed || (paced.waiting && next.is_none_or(|next| now >= next)) {
148        paced.drawn = Some(now);
149        paced.waiting = false;
150        return true;
151    }
152    false
153}
154
155/// Redraws once something drawn went away.
156pub(crate) fn redraw_on_remove(
157    _: On<
158        Remove<(
159            TuiPanel,
160            StatusItems,
161            Holds,
162            ToolProgress,
163            TranscriptPane,
164            Editor,
165            PickerInput,
166        )>,
167    >,
168    mut redraw: MessageWriter<RequestRedraw>,
169) {
170    redraw.write(RequestRedraw);
171}
172
173/// Lays every pane's transcript out again once a look went away.
174pub(crate) fn forget_looks(
175    _: On<
176        Remove<(
177            ToolRenderer,
178            EntryRenderer,
179            ContentRenderer,
180            AsideRenderer,
181            CallDecorator,
182            MessageDecorator,
183        )>,
184    >,
185    mut panes: Query<&mut PaneState>,
186    mut redraw: MessageWriter<RequestRedraw>,
187) {
188    for mut pane in &mut panes {
189        pane.bypass_change_detection().transcript.clear();
190    }
191    redraw.write(RequestRedraw);
192}
193
194/// Whether [`layout`] laid out a frame that [`render`] did not draw yet.
195#[derive(Resource, Default)]
196pub(crate) struct FrameLayout {
197    due: bool,
198    /// The box of the picker shown with a preview, its list at the left
199    /// and its preview at the right.
200    picker: Option<Rect>,
201}
202
203/// Whether [`layout`] laid out a frame that is not drawn yet: the
204/// condition of [`TuiSystems::Draw`](super::TuiSystems::Draw) and
205/// [`render`].
206pub(crate) fn frame_due(frame: Res<FrameLayout>) -> bool {
207    frame.due
208}
209
210/// The view's layout of a frame: the panels ([`layout`]), then the panes
211/// in what they leave, each scroll placed
212/// ([`lay_out_panes`](crate::pane::lay_out_panes)). Run in
213/// [`TuiSystems::Layout`](crate::TuiSystems::Layout) ([`lay_out_frame`]),
214/// and again after [`TuiSystems::Fit`](crate::TuiSystems::Fit) when a
215/// panel changed there ([`lay_out_fitted`]).
216#[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)]
217pub(crate) struct LayOut;
218
219/// When [`lay_out_frame`] last ran [`LayOut`].
220#[derive(Resource, Default)]
221pub(crate) struct LaidOutAt(Option<Tick>);
222
223/// Lays out the frame ([`LayOut`]), in
224/// [`TuiSystems::Layout`](crate::TuiSystems::Layout).
225pub(crate) fn lay_out_frame(world: &mut World) {
226    if world.try_run_schedule(LayOut).is_ok() {
227        let now = world.change_tick();
228        world.insert_resource(LaidOutAt(Some(now)));
229    }
230}
231
232/// Lays out the frame again when a panel was spawned or changed since
233/// [`lay_out_frame`], in [`TuiSystems::Fit`](crate::TuiSystems::Fit), so
234/// a panel fitted to a pane's scroll is drawn in the frame that placed it.
235pub(crate) fn lay_out_fitted(world: &mut World, panels: &mut QueryState<Ref<TuiPanel>>) {
236    let Some(at) = world.get_resource::<LaidOutAt>().and_then(|laid| laid.0) else {
237        return;
238    };
239    let now = world.read_change_tick();
240    let fitted = panels
241        .iter(world)
242        .any(|panel| panel.last_changed().is_newer_than(at, now));
243    if fitted {
244        world.try_run_schedule(LayOut).ok();
245    }
246}
247
248/// Lays out a frame: every panel by its placement, in the order spawned
249/// (the default screen's first), an editor as tall as its text and a strip
250/// as tall as what it shows.
251pub(crate) fn layout(
252    screens: (Option<Res<Tui>>, Option<Res<TuiTestScreen>>),
253    (view, previewed): (
254        Res<TuiView>,
255        Query<(Entity, &PickerInput), With<PickPreview>>,
256    ),
257    mut frame: ResMut<FrameLayout>,
258    mut panels: Query<(
259        Entity,
260        &TuiPanel,
261        &mut PanelCanvas,
262        Option<&Editor>,
263        Has<InlineStrip>,
264    )>,
265    (spawns, ticks): (PanelSpawns, SystemChangeTick),
266) {
267    let size = match screens {
268        (Some(tui), _) => tui.terminal.size().ok(),
269        (None, Some(test)) => Some(test.size()),
270        (None, None) => None,
271    };
272    let Some(size) = size else {
273        return;
274    };
275    let screen = Rect::new(0, 0, size.width, size.height);
276    let inline = u16::from(!view.inline.is_empty());
277    let mut panels: Vec<_> = panels.iter_mut().collect();
278    panels.sort_by_key(|(entity, ..)| panel::spawn_order(ticks.this_run(), &spawns, *entity));
279    let panels = panels
280        .into_iter()
281        .map(|(entity, panel, canvas, editor, strip)| {
282            let fits = editor.map(|editor| editor.fit_rows(screen.width));
283            (entity, panel, fits.or(strip.then_some(inline)), canvas)
284        });
285    let previewed = previewed.iter().find(|(_, state)| !state.in_input());
286    let boxed = Constraint::Percentage(90);
287    let picker = previewed.map(|(entity, _)| (entity, screen.centered(boxed, boxed)));
288    panel::lay_out(panels, screen, picker.as_slice());
289    *frame = FrameLayout {
290        due: true,
291        picker: picker.map(|(_, area)| area),
292    };
293}
294
295/// What the transcript reads of where a turn's tool calls stand.
296#[derive(SystemParam)]
297pub(crate) struct CallStates<'w, 's> {
298    calls: Query<
299        'w,
300        's,
301        (
302            &'static ToolRequest,
303            Option<&'static Holds>,
304            Has<Started>,
305            Has<Outcome>,
306            Option<&'static ToolProgress>,
307        ),
308    >,
309    names: Query<'w, 's, &'static Name>,
310    outputs: Query<
311        'w,
312        's,
313        (&'static ToolRequest, &'static CallOutput),
314        (With<Started>, Without<Outcome>),
315    >,
316}
317
318impl CallStates<'_, '_> {
319    /// Where each tool call of `calls` stands, by its id: held while it
320    /// has holds, else done once answered, running (with its progress
321    /// line) once started, else waiting.
322    pub(crate) fn of(
323        &self,
324        calls: impl IntoIterator<Item = Entity>,
325    ) -> BTreeMap<CallId, CallState> {
326        let found = calls
327            .into_iter()
328            .filter_map(|call| self.calls.get(call).ok());
329        found
330            .map(|(request, holds, started, answered, progress)| {
331                let state = match holds {
332                    Some(holds) => {
333                        CallState::Held(holds.iter().map(|hold| self.name(hold)).collect())
334                    }
335                    None if answered => CallState::Done,
336                    None if started => {
337                        CallState::Running(progress.map(|ToolProgress(line)| line.clone()))
338                    }
339                    None => CallState::Waiting,
340                };
341                (request.call.id.clone(), state)
342            })
343            .collect()
344    }
345
346    /// The output so far of each running call of `calls` whose tool
347    /// reported some, by its id.
348    pub(crate) fn outputs(
349        &self,
350        calls: impl IntoIterator<Item = Entity>,
351    ) -> BTreeMap<CallId, String> {
352        let found = calls
353            .into_iter()
354            .filter_map(|call| self.outputs.get(call).ok());
355        let outputs = found.map(|(request, output)| (request.call.id.clone(), output.text()));
356        outputs.filter(|(_, text)| !text.is_empty()).collect()
357    }
358
359    /// A hold's name, which says why it holds.
360    fn name(&self, hold: Entity) -> String {
361        let name = self.names.get(hold).map_or(UNNAMED_HOLD, Name::as_str);
362        name.to_owned()
363    }
364}
365
366/// An agent, as its transcript draws it.
367pub(crate) struct ShownConversation<'a> {
368    pub(crate) entity: Entity,
369    /// Its id; `None` for a conversation no agent of this app holds,
370    /// which shows no notices.
371    pub(crate) id: Option<&'a AgentId>,
372    pub(crate) conversation: &'a conversation::Conversation,
373    /// Whether its conversation changed since the last frame.
374    pub(crate) changed: bool,
375    /// The messages it accepted and has not sent yet, in the order they
376    /// came.
377    pub(crate) inbox: Vec<&'a Incoming>,
378    /// Where its running turn's calls stand.
379    pub(crate) calls: BTreeMap<CallId, CallState>,
380    /// The output so far of its running calls that report it.
381    pub(crate) outputs: BTreeMap<CallId, String>,
382    /// The reply streaming in, if any, and the call streaming it.
383    pub(crate) partial: Option<(Entity, &'a Partial)>,
384    /// The tool calls it streams.
385    pub(crate) streaming: Vec<ToolCall>,
386    /// The asides in its conversation, in the order put.
387    pub(crate) asides: Vec<ShownAside<'a>>,
388    /// The calls under each of its tool calls that started any.
389    pub(crate) children: ChildCalls,
390}
391
392impl<'a> ShownConversation<'a> {
393    /// `conversation`, which no agent of this app holds, drawn as
394    /// `entity`'s: no live parts, no notices.
395    pub(crate) fn value(conversation: &'a conversation::Conversation, entity: Entity) -> Self {
396        Self {
397            entity,
398            id: None,
399            conversation,
400            changed: true,
401            inbox: Vec::new(),
402            calls: BTreeMap::new(),
403            outputs: BTreeMap::new(),
404            partial: None,
405            streaming: Vec::new(),
406            asides: asides(conversation),
407            children: ChildCalls::default(),
408        }
409    }
410}
411
412/// The asides of `conversation`, in the order put.
413fn asides(conversation: &conversation::Conversation) -> Vec<ShownAside<'_>> {
414    let asides = conversation.asides();
415    asides.map(|(id, aside)| ShownAside { id, aside }).collect()
416}
417
418/// An effect not settled yet, a tool or model call or any other, as
419/// [`TranscriptLayout`] reads it for [`ToolCallView::children`].
420type LiveEffect = (
421    Entity,
422    &'static Effect,
423    (
424        Option<&'static ToolRequest>,
425        Option<&'static ModelRequest>,
426        Option<&'static SentTo>,
427    ),
428    (Option<&'static ForAgent>, Option<&'static Outcome>),
429    (Has<Started>, Has<Partial>),
430);
431
432/// What the transcript is drawn from besides the agent.
433pub(crate) struct Sources<'a> {
434    /// The pane's view as plugins steer it: what is marked.
435    pub(crate) view: &'a TranscriptView,
436    /// The notices shown.
437    pub(crate) notices: &'a Notices,
438    /// The looks plugins added.
439    pub(crate) renderers: &'a Renderers<'a>,
440    /// What settled calls published and how long they ran.
441    pub(crate) facts: &'a CallFacts,
442    /// The transcript's colors.
443    pub(crate) theme: &'a Theme,
444}
445
446/// Lays out an agent's transcript as the terminal view draws it, without
447/// drawing it: for any agent, shown or not, in a run with the terminal or
448/// without, by the view's own layout (its order, its renderers, wrapping
449/// and the matches it lights), so an export, a copy, a search or a screen
450/// reader reads what the view shows. Each layout renders and wraps the
451/// whole transcript, so lay one out when asked, not every frame. It is
452/// there in any run with the [`TuiPlugin`](crate::TuiPlugin), and takes
453/// the [`TranscriptView`] to lay out with, such as a pane's, so a system
454/// that changes the view lays out with it too.
455///
456/// ```no_run
457/// use rig_harness::prelude::*;
458/// use rig_tui::{MainPane, TranscriptLayout, TranscriptView};
459///
460/// /// `/export`: the agent's transcript as the main pane draws it, a line
461/// /// per row.
462/// fn export(
463///     In(args): In<CommandArgs>,
464///     layout: TranscriptLayout,
465///     view: Single<&TranscriptView, With<MainPane>>,
466/// ) {
467///     let width = view.width().unwrap_or(100);
468///     let Some(laid_out) = layout.lay_out(&view, args.agent, width) else {
469///         return;
470///     };
471///     let rows: Vec<&str> = laid_out.rows().iter().map(|row| row.text.as_str()).collect();
472///     std::fs::write("transcript.txt", rows.join("\n")).ok();
473/// }
474/// ```
475#[derive(SystemParam)]
476pub struct TranscriptLayout<'w, 's> {
477    agents: Query<'w, 's, (&'static AgentId, &'static Conversation)>,
478    pub(crate) active: Query<'w, 's, &'static ActiveTurn>,
479    turns: Query<'w, 's, &'static Calls>,
480    /// The replies streaming in that nothing superseded, such as a race's
481    /// running members.
482    partials: Query<'w, 's, &'static Partial, Without<SupersededBy>>,
483    accepted: Query<
484        'w,
485        's,
486        (&'static Effect, &'static Incoming, &'static ForAgent),
487        (With<Started>, Without<Outcome>),
488    >,
489    pub(crate) looks: Looks<'w, 's>,
490    calls: CallStates<'w, 's>,
491    live: Query<'w, 's, LiveEffect, Without<Settled>>,
492    names: Query<'w, 's, &'static Name>,
493    facts: Res<'w, CallFacts>,
494    streaming: Query<'w, 's, &'static StreamingCalls>,
495    notices: Res<'w, Notices>,
496    theme: Res<'w, Theme>,
497}
498
499impl TranscriptLayout<'_, '_> {
500    /// `agent`'s transcript laid out in rows `width` columns wide (as
501    /// [`TranscriptView::width`] says the view draws it; `u16::MAX` keeps
502    /// a line in one row), the matches of `view`'s highlights counted, as
503    /// the view would draw it with `view`. It has no rows on screen. [`DrawnTranscript::item`] takes one item's rows out of
504    /// it. `None` when `agent` is no agent.
505    pub fn lay_out(
506        &self,
507        view: &TranscriptView,
508        agent: Entity,
509        width: u16,
510    ) -> Option<DrawnTranscript> {
511        let shown = self.agent(agent, true)?;
512        let renderers = self.looks.by_kind(None);
513        let sources = Sources {
514            view,
515            notices: self.notices(),
516            renderers: &renderers,
517            facts: &self.facts,
518            theme: &self.theme,
519        };
520        let mut transcript = Transcript::default();
521        let parts = lay_out_transcript(&mut transcript, &sources, Some(&shown), width);
522        Some(transcript.laid_out(Some(agent), &parts, &view.highlights, 0..0))
523    }
524
525    /// The agent `entity` as its transcript draws it, its conversation
526    /// `changed` since the last frame or not; `None` when it is no agent.
527    pub(crate) fn agent(&self, entity: Entity, changed: bool) -> Option<ShownConversation<'_>> {
528        let (id, conversation) = self.agents.get(entity).ok()?;
529        let turn = self.active.get(entity).ok();
530        let calls = turn.and_then(|turn| self.turns.get(turn.turn()).ok());
531        // The newest of the turn's replies streaming in that nothing
532        // superseded: in a race, the member still running.
533        let partial = calls.and_then(|calls| {
534            let streams = calls.iter();
535            let mut streams =
536                streams.filter_map(|call| Some((call, self.partials.get(call).ok()?)));
537            streams.next_back()
538        });
539        let streaming = partial.and_then(|(call, _)| self.streaming.get(call).ok());
540        let mut inbox: Vec<(&Effect, &Incoming)> = self
541            .accepted
542            .iter()
543            .filter(|(.., to)| to.0 == entity)
544            .map(|(effect, message, _)| (effect, message))
545            .collect();
546        inbox.sort_by_key(|(effect, _)| effect.id());
547        Some(ShownConversation {
548            entity,
549            id: Some(id),
550            conversation,
551            changed,
552            inbox: inbox.into_iter().map(|(_, message)| message).collect(),
553            calls: self.calls.of(calls.into_iter().flat_map(Calls::iter)),
554            outputs: self.calls.outputs(calls.into_iter().flat_map(Calls::iter)),
555            partial,
556            streaming: streaming
557                .into_iter()
558                .flat_map(StreamingCalls::calls)
559                .collect(),
560            asides: asides(conversation),
561            children: self.facts.children(&self.live_calls(), &id.0),
562        })
563    }
564
565    /// The effects not settled yet, by the effects that started them, the
566    /// calls standing as they stand now.
567    fn live_calls(&self) -> CallTree {
568        let mut live = CallTree::default();
569        for (entity, effect, requests, (agent, outcome), (started, streams)) in &self.live {
570            let state = match requests.0 {
571                Some(request) => self.calls.of([entity]).remove(&request.call.id),
572                None if outcome.is_some() => Some(CallState::Done),
573                None if streams => Some(CallState::Streaming),
574                None if started => Some(CallState::Running(None)),
575                None => None,
576            };
577            let agent = agent.map(|ForAgent(agent)| *agent);
578            let name = agent.and_then(|agent| self.names.get(agent).ok());
579            let state = state.unwrap_or(CallState::Waiting);
580            let child = live_call(requests, (name, outcome), state);
581            live.add(effect.parent, effect.id(), child, None);
582            let id = agent.and_then(|agent| self.agents.get(agent).ok());
583            if let (Some(request), Some((id, _))) = (requests.0, id) {
584                live.tool_call(effect.id(), &id.0, request.call.id.clone());
585            }
586        }
587        live
588    }
589
590    /// What settled calls published and how long they ran.
591    pub(crate) fn facts(&self) -> &CallFacts {
592        &self.facts
593    }
594
595    /// The notices shown.
596    pub(crate) fn notices(&self) -> &Notices {
597        &self.notices
598    }
599
600    /// The transcript's colors.
601    pub(crate) fn theme(&self) -> &Theme {
602        &self.theme
603    }
604}
605
606/// Brings `transcript` up to date with the shown agent and returns its
607/// parts at `width`: each message, with the notices written inside it,
608/// after the notes and notices written before it; then those after the
609/// last message, the reply streaming in with the tool calls it streams,
610/// and the messages the agent accepted and has not sent yet. Without an
611/// agent, the notices about every agent.
612pub(crate) fn lay_out_transcript(
613    transcript: &mut Transcript,
614    sources: &Sources<'_>,
615    shown: Option<&ShownConversation<'_>>,
616    width: u16,
617) -> Vec<Part> {
618    let wrap_width = usize::from(width.max(1));
619    let mut parts = Vec::new();
620    let view = sources.view;
621    let Some(shown) = shown else {
622        let (mut extra, mut marks) = (Vec::new(), Marks::new());
623        let notices = sources.notices.numbered();
624        for notice in notices.filter(|(_, notice)| notice.is_for_every_agent()) {
625            notice_lines(notice, &mut extra, &mut marks);
626        }
627        parts.push(Part::rows(&extra, &marks, wrap_width));
628        return parts;
629    };
630    let (conversation, count) = (shown.conversation, shown.conversation.messages().len());
631    // Notes and notices before the first message, after each message,
632    // and notices inside one.
633    let mut after: Vec<Vec<(usize, &ShownNotice)>> = vec![Vec::new(); count + 1];
634    let mut asides_after: Vec<Vec<&ShownAside<'_>>> = vec![Vec::new(); count + 1];
635    let mut look = Look {
636        renderers: sources.renderers,
637        calls: &shown.calls,
638        outputs: &shown.outputs,
639        inside: HashMap::new(),
640        facts: sources.facts,
641        theme: sources.theme,
642        children: &shown.children,
643        marks: &view.marks,
644    };
645    let notices = shown.id.map(|id| (id, sources.notices));
646    let notices = notices
647        .into_iter()
648        .flat_map(|(id, notices)| notices.numbered().map(move |(at, notice)| (id, at, notice)));
649    for (id, at, notice) in notices {
650        let before = match notice.slot(id, conversation) {
651            None => continue,
652            Some(TranscriptSlot::Top) => 0,
653            Some(TranscriptSlot::After(index)) => index + 1,
654            Some(TranscriptSlot::Inside(index, item)) => {
655                let inside = look.inside.entry(index).or_default();
656                inside.push((item, Inside::Notice(at, notice)));
657                continue;
658            }
659        };
660        if let Some(after) = after.get_mut(before) {
661            after.push((at, notice));
662        }
663    }
664    let drawn = shown.asides.iter();
665    for aside in drawn.filter(|shown| sources.renderers.draws(shown.aside)) {
666        let before = match TranscriptSlot::of_aside(aside.aside, conversation) {
667            TranscriptSlot::Top => 0,
668            TranscriptSlot::After(index) => index + 1,
669            TranscriptSlot::Inside(index, item) => {
670                let inside = look.inside.entry(index).or_default();
671                inside.push((item, Inside::Aside(aside)));
672                continue;
673            }
674        };
675        if let Some(at) = asides_after.get_mut(before) {
676            at.push(aside);
677        }
678    }
679    transcript.update(shown.entity, conversation, shown.changed, &look, width);
680    let mut after = after.into_iter().zip(asides_after);
681    for index in 0..=count {
682        let (notices, asides) = after.next().unwrap_or_default();
683        for aside in asides {
684            let mut lines = vec![Line::default()];
685            lines.extend(on_band(
686                aside.lines(sources.renderers, sources.theme),
687                sources.theme.tool,
688            ));
689            let marks = [(aside.item(), 0..lines.len())];
690            parts.push(Part::rows(&lines, &marks, wrap_width));
691        }
692        let (mut extra, mut marks) = (Vec::new(), Marks::new());
693        for notice in notices {
694            notice_lines(notice, &mut extra, &mut marks);
695        }
696        if index == count {
697            if let Some((call, partial)) = shown.partial {
698                let view = |role, text, marks| MessageView {
699                    role,
700                    message: None,
701                    block: 0,
702                    text,
703                    marks,
704                    width: wrap_width,
705                    theme: *sources.theme,
706                };
707                let reasoning = view(Role::Reasoning, &partial.reasoning, &[]);
708                extra.extend(reasoning.default_lines());
709                if !partial.text.is_empty() {
710                    extra.push(Line::default());
711                    let marks = look.marks_in(MarkIn::Streaming(call));
712                    extra.extend(view(Role::Assistant, &partial.text, &marks).default_lines());
713                }
714            }
715            for call in &shown.streaming {
716                let mut view = ToolCallView::new(call, None, CallState::Streaming);
717                view.theme = *sources.theme;
718                extra.extend(on_band(sources.renderers.call(&view), sources.theme.tool));
719            }
720            inbox_lines(&shown.inbox, sources.theme, &mut extra);
721            parts.push(Part::rows(&extra, &marks, wrap_width));
722            break;
723        }
724        if !extra.is_empty() {
725            parts.push(Part::rows(&extra, &marks, wrap_width));
726        }
727        parts.push(Part::Message(index));
728    }
729    parts
730}
731
732/// `rows`, the first being row `first` of the transcript, with the rows of
733/// the `selected` item on `theme`'s selected color, over their bands.
734pub(crate) fn decorate(
735    rows: Vec<Line<'static>>,
736    first: usize,
737    selected: Option<Range<usize>>,
738    theme: &Theme,
739) -> Vec<Line<'static>> {
740    let on = theme.selection();
741    rows.into_iter()
742        .enumerate()
743        .map(|(index, row)| match &selected {
744            Some(rows) if rows.contains(&(first + index)) => {
745                let spans = row.spans.into_iter().map(|span| span.patch_style(on));
746                Line::from(spans.collect::<Vec<_>>()).style(row.style.patch(on))
747            }
748            _ => row,
749        })
750        .collect()
751}
752
753/// The highlights to light, and where the spans of those with any are:
754/// by the highlight's index and the first row of their line in the
755/// transcript.
756#[derive(Default)]
757pub(crate) struct Lights<'a> {
758    pub(crate) highlights: &'a [Highlight],
759    pub(crate) placed: HashMap<(usize, usize), Vec<Range<usize>>>,
760}
761
762impl Lights<'_> {
763    /// Whether any of them lights anything.
764    pub(crate) fn any(&self) -> bool {
765        let lights =
766            |highlight: &Highlight| !highlight.text.is_empty() || !highlight.spans.is_empty();
767        self.highlights.iter().any(lights)
768    }
769}
770
771/// `rows`, the first being row `first` of the transcript, each continuing
772/// the one before it as `continues` says, with each match of `lights` in
773/// its style. A match is sought in each line the rows wrap, so one a wrap
774/// splits lights on both rows.
775pub(crate) fn light(
776    rows: &[Line<'static>],
777    continues: &[Option<RowWrap>],
778    first: usize,
779    lights: &Lights<'_>,
780) -> Vec<Line<'static>> {
781    let joints = continues.iter().map(Option::as_ref);
782    let lit_rows = lit(rows.iter().zip(joints), first, lights);
783    rows.iter()
784        .zip(lit_rows)
785        .map(|(line, row)| {
786            if !row.cells.iter().any(|(.., lit)| *lit) {
787                return line.clone();
788            }
789            let mut spans: Vec<Span<'static>> = Vec::new();
790            for (character, style, _) in row.cells {
791                match spans.last_mut() {
792                    Some(span) if span.style == style => span.content.to_mut().push(character),
793                    _ => spans.push(Span::styled(character.to_string(), style)),
794                }
795            }
796            let mut lit = Line::from(spans).style(line.style);
797            lit.alignment = line.alignment;
798            lit
799        })
800        .collect()
801}
802
803/// A row once highlights are lit: its characters, each with its style and
804/// whether a match lit it, and how many matches of each highlight start
805/// in it.
806pub(crate) struct LitRow {
807    pub(crate) cells: Vec<(char, Style, bool)>,
808    pub(crate) matches: Vec<usize>,
809}
810
811/// A character of a line being searched: in which row it is drawn, at
812/// which character of it (`None` for a blank the wrap dropped), and
813/// whether a match lit it.
814struct Slot {
815    row: usize,
816    at: Option<usize>,
817    character: char,
818    lit: bool,
819}
820
821/// `rows`, the first being row `first` of the transcript, each with how it
822/// continues the row before it, with the matches of `lights` lit. A match
823/// is sought in the line the rows wrap, so it may span rows; it counts in
824/// the row it starts in. A match over a character an earlier match lit is
825/// no match.
826pub(crate) fn lit<'a, 'line: 'a>(
827    rows: impl IntoIterator<Item = (&'a Line<'line>, Option<&'a RowWrap>)>,
828    first: usize,
829    lights: &Lights<'_>,
830) -> Vec<LitRow> {
831    let mut lit = Vec::new();
832    // The line being gathered, and its first row in the transcript.
833    let mut line = Vec::new();
834    let mut start = first;
835    for (row, joint) in rows {
836        if joint.is_none() {
837            light_line(&mut lit, &mut line, start, lights);
838            line.clear();
839            start = first + lit.len();
840        }
841        let at = lit.len();
842        let cells: Vec<(char, Style, bool)> = row
843            .spans
844            .iter()
845            .flat_map(|span| span.content.chars().map(move |c| (c, span.style, false)))
846            .collect();
847        let indent = joint.map_or(0, |joint| joint.indent);
848        let gap = joint.map(|joint| joint.gap.chars()).into_iter().flatten();
849        line.extend(gap.map(|character| Slot {
850            row: at,
851            at: None,
852            character,
853            lit: false,
854        }));
855        let drawn = cells.iter().enumerate().skip(indent);
856        line.extend(drawn.map(|(index, (character, ..))| Slot {
857            row: at,
858            at: Some(index),
859            character: *character,
860            lit: false,
861        }));
862        lit.push(LitRow {
863            cells,
864            matches: vec![0; lights.highlights.len()],
865        });
866    }
867    light_line(&mut lit, &mut line, start, lights);
868    lit
869}
870
871/// Lights the matches of `lights` in `line`, which starts at row `start`
872/// of the transcript, in the `rows` it is drawn in.
873fn light_line(rows: &mut [LitRow], line: &mut [Slot], start: usize, lights: &Lights<'_>) {
874    for (index, highlight) in lights.highlights.iter().enumerate() {
875        if !highlight.spans.is_empty() {
876            let spans = lights.placed.get(&(index, start));
877            for bytes in spans.into_iter().flatten() {
878                if let Some(window) = slots_of(line, bytes) {
879                    light_match(rows, line, window, (index, highlight.style));
880                }
881            }
882            continue;
883        }
884        let needle: Vec<char> = highlight.text.chars().collect();
885        let same = |a: char, b: char| {
886            if highlight.match_case {
887                a == b
888            } else {
889                a.to_lowercase().eq(b.to_lowercase())
890            }
891        };
892        let mut at = 0;
893        while !needle.is_empty() && at + needle.len() <= line.len() {
894            let window = at..at + needle.len();
895            let text = line.get(window.clone()).unwrap_or_default();
896            let found = text
897                .iter()
898                .zip(&needle)
899                .all(|(slot, n)| same(slot.character, *n));
900            if found && light_match(rows, line, window, (index, highlight.style)) {
901                at += needle.len();
902            } else {
903                at += 1;
904            }
905        }
906    }
907}
908
909/// The characters of `line` that `bytes` of its text are; `None` for none,
910/// or for bytes that cut a character or reach past the line.
911fn slots_of(line: &[Slot], bytes: &Range<usize>) -> Option<Range<usize>> {
912    let mut at = 0;
913    let (mut from, mut to) = (None, None);
914    for (index, slot) in line.iter().enumerate() {
915        if at == bytes.start {
916            from = Some(index);
917        }
918        if at == bytes.end {
919            to = Some(index);
920        }
921        at += slot.character.len_utf8();
922    }
923    if at == bytes.end {
924        to = Some(line.len());
925    }
926    Some(from?..to?).filter(|window| !window.is_empty())
927}
928
929/// Lights the characters `window` of `line` as a match of highlight
930/// `index` in `style`, counted in the row it starts in, unless an earlier
931/// match lit one of them; says whether it did.
932fn light_match(
933    rows: &mut [LitRow],
934    line: &mut [Slot],
935    window: Range<usize>,
936    (index, style): (usize, Style),
937) -> bool {
938    let window = line.get_mut(window).unwrap_or_default();
939    if window.iter().any(|slot| slot.lit) {
940        return false;
941    }
942    let start = window.first().map(|slot| slot.row);
943    let count = start.and_then(|row| rows.get_mut(row)?.matches.get_mut(index));
944    if let Some(count) = count {
945        *count += 1;
946    }
947    for slot in window {
948        slot.lit = true;
949        let row = rows.get_mut(slot.row);
950        let cell = slot.at.and_then(|at| row?.cells.get_mut(at));
951        if let Some((_, style_there, lit)) = cell {
952            *style_there = style_there.patch(style);
953            *lit = true;
954        }
955    }
956    true
957}
958
959/// Draws the frame [`layout`] laid out: a compositor. It copies every
960/// panel's canvas in z-order (the sides and the rest, then the boxes over
961/// the screen), then draws the view's own overlays over everything: the
962/// text of a question answered in the input above the editor with the
963/// keys, the picker or question shown, the completion list, and the caret
964/// of the editor with the keys.
965pub(crate) fn render(
966    (mut tui, mut test): (Option<ResMut<Tui>>, Option<ResMut<TuiTestScreen>>),
967    mut drew: ResMut<TuiFrame>,
968    view: Res<TuiView>,
969    (state, focus, choices): (Res<InputContext>, Res<KeyFocus>, Choices),
970    mut frame_layout: ResMut<FrameLayout>,
971    (panels, carets): (Query<(Entity, &TuiPanel, &PanelCanvas)>, Query<&Caret>),
972    (spawns, ticks): (PanelSpawns, SystemChangeTick),
973    mut showing: Query<(Entity, &mut PickerInput, Option<&PickPreview>)>,
974    mut repaints: MessageReader<RedrawArea>,
975    theme: Res<Theme>,
976) -> Result {
977    if !std::mem::take(&mut frame_layout.due) {
978        return Ok(());
979    }
980    let repaint: Vec<Rect> = repaints.read().map(|repaint| repaint.0).collect();
981    let picker = showing.iter().find(|(_, choosing, _)| !choosing.in_input());
982    let picker = picker.map(|(entity, ..)| entity);
983    // The panels at the sides, then the boxes over the screen, then those
984    // within another, those within the picker over it.
985    let layer = |panel: &TuiPanel| match panel.placement {
986        Placement::Over { .. } => 1,
987        Placement::Within { panel, .. } if Some(panel) == picker => 3,
988        Placement::Within { .. } => 2,
989        _ => 0,
990    };
991    let keyed = state.editor().and_then(|editor| {
992        let found = panels.get(editor).ok();
993        found.map(|(.., canvas)| canvas.area())
994    });
995    let mut panels: Vec<_> = panels.iter().collect();
996    let order = |entity| panel::spawn_order(ticks.this_run(), &spawns, entity);
997    panels.sort_by_key(|(entity, panel, _)| (layer(panel), order(*entity)));
998    let (under, over_picker): (Vec<_>, Vec<_>) = panels
999        .into_iter()
1000        .partition(|(_, panel, _)| layer(panel) < 3);
1001    let edit = showing.iter().find(|(_, choosing, _)| choosing.in_input());
1002    let edit = edit.and_then(|(entity, ..)| choices.shown(entity));
1003    let cursor = carets.iter().find_map(|caret| caret.0);
1004    let cursor = cursor.map(|at| MoveTo(at.x, at.y));
1005    let mut areas = TuiFrame::default();
1006    let draw_frame = |frame: &mut Frame| {
1007        let screen = frame.area();
1008        let mut overlays = Vec::new();
1009        let mut drawn = Vec::new();
1010        let mut copy =
1011            |frame: &mut Frame, (entity, panel, canvas): &(Entity, &TuiPanel, &PanelCanvas)| {
1012                canvas.copy_to(frame.buffer_mut());
1013                drawn.push((*entity, canvas.area()));
1014                if layer(panel) > 0 {
1015                    overlays.push(canvas.area());
1016                }
1017            };
1018        for panel in &under {
1019            copy(frame, panel);
1020        }
1021        let body = edit.as_ref().zip(keyed);
1022        let body = body.and_then(|(edit, at)| draw_body(frame, &theme, edit.body, at));
1023        // Drawing the picker keeps its scroll within its body: drawing's
1024        // own bookkeeping, not a change to redraw for.
1025        let shown = picker.and_then(|picker| {
1026            let (_, choosing, preview) = showing.get_mut(picker).ok()?;
1027            Some((choices.shown(picker)?, choosing, preview))
1028        });
1029        let mut over = body.into_iter().collect::<Vec<_>>();
1030        match (shown, &view.completion, keyed) {
1031            (Some((shown, mut choosing, preview)), ..) => {
1032                let preview = preview.zip(frame_layout.picker);
1033                let choosing = choosing.bypass_change_detection();
1034                over.push(draw_choice(frame, &theme, choosing, &shown, preview));
1035                for panel in &over_picker {
1036                    copy(frame, panel);
1037                }
1038            }
1039            (None, Some(completion), Some(at)) if focus.0.is_none() => {
1040                over.extend(draw_completion(frame, &theme, completion, at));
1041            }
1042            _ => {}
1043        }
1044        overlays.extend(over);
1045        if theme.plain {
1046            strip_colors(frame.buffer_mut());
1047        }
1048        areas = TuiFrame {
1049            screen,
1050            areas: drawn,
1051            overlays,
1052        };
1053    };
1054    let drawn = match (tui.as_deref_mut(), test.as_mut()) {
1055        (Some(tui), _) => {
1056            tui.begin_frame()?;
1057            let drawn = tui.terminal.draw(draw_frame);
1058            let drawn = drawn.map(|completed| cells_in(completed.buffer, &repaint));
1059            let drawn = drawn.and_then(|cells| {
1060                let cells = cells.iter().map(|(x, y, cell)| (*x, *y, cell));
1061                tui.terminal.backend_mut().draw(cells)
1062            });
1063            tui.end_frame(cursor.filter(|_| drawn.is_ok()))?;
1064            drawn
1065        }
1066        (None, Some(test)) => {
1067            // Drawing is not a change to redraw for; a resize is.
1068            test.bypass_change_detection().draw(draw_frame, &repaint);
1069            Ok(())
1070        }
1071        (None, None) => Ok(()),
1072    };
1073    drew.set_if_neq(areas);
1074    drawn?;
1075    Ok(())
1076}
1077
1078/// The cells of `buffer` in `areas`, clipped to it, to write again.
1079fn cells_in(buffer: &Buffer, areas: &[Rect]) -> Vec<(u16, u16, Cell)> {
1080    let areas = areas.iter().map(|area| area.intersection(buffer.area));
1081    let positions = areas.flat_map(|area| area.positions());
1082    positions
1083        .filter_map(|at| Some((at.x, at.y, buffer.cell(at)?.clone())))
1084        .collect()
1085}
1086
1087/// Whether a selection moves through `item`: all but a decorator's rows,
1088/// which belong to their call.
1089pub(crate) fn selectable(item: &TranscriptItem) -> bool {
1090    !matches!(
1091        item,
1092        TranscriptItem::Decoration(..) | TranscriptItem::MessageDecoration(..)
1093    )
1094}
1095
1096/// Where the last frame drew each part of the screen: for a plugin that
1097/// writes to the terminal itself after the frame ([`TerminalWrite`]),
1098/// such as an image over the rows its content renderer left blank,
1099/// which finds those rows in a pane's [`DrawnTranscript`] (see
1100/// [`TuiFrame::screen_row`]) and leaves out what an overlay covers. The
1101/// view writes it after each frame it draws, in
1102/// [`TuiSystems::Render`](crate::TuiSystems::Render); read it after that.
1103/// All empty in a run without the terminal.
1104///
1105/// [`TerminalWrite`]: crate::TerminalWrite
1106#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)]
1107pub struct TuiFrame {
1108    /// The whole screen.
1109    pub screen: Rect,
1110    /// Each panel drawn, the default screen's transcript, input and status
1111    /// line among them, with its area, in the order drawn.
1112    pub areas: Vec<(Entity, Rect)>,
1113    /// What was drawn over the rest: the panels placed over the screen,
1114    /// the picker or question, the completion list, a question's text
1115    /// above the input.
1116    pub overlays: Vec<Rect>,
1117}
1118
1119impl TuiFrame {
1120    /// The screen row the `row` of `drawn` (a pane's transcript this frame
1121    /// drew) is on, when it is on screen.
1122    pub fn screen_row(&self, drawn: &DrawnTranscript, row: usize) -> Option<u16> {
1123        let on_screen = drawn.on_screen();
1124        if !on_screen.contains(&row) {
1125            return None;
1126        }
1127        let below_top = u16::try_from(row - on_screen.start).ok()?;
1128        Some(drawn.area().y.saturating_add(below_top))
1129    }
1130
1131    /// Where the panel `entity` was drawn, if it was.
1132    pub fn area(&self, entity: Entity) -> Option<Rect> {
1133        let drawn = self.areas.iter().find(|(drawn, _)| *drawn == entity);
1134        drawn.map(|(_, area)| *area)
1135    }
1136
1137    /// Whether an overlay covers any of `area`.
1138    pub fn covered(&self, area: Rect) -> bool {
1139        self.overlays.iter().any(|over| over.intersects(area))
1140    }
1141}
1142
1143/// What a question answered in the input is about, on a raised surface
1144/// just above the input; where, if it is drawn.
1145fn draw_body(frame: &mut Frame, theme: &Theme, body: &str, input: Rect) -> Option<Rect> {
1146    if body.trim().is_empty() {
1147        return None;
1148    }
1149    let lines = wrap_all(
1150        &plain_lines(body, Style::new()),
1151        usize::from(input.width.saturating_sub(2).max(1)),
1152    );
1153    let rows = u16::try_from(lines.len()).unwrap_or(u16::MAX);
1154    let height = rows.saturating_add(2).min(input.y);
1155    if height < 3 {
1156        return None;
1157    }
1158    let area = Rect::new(input.x, input.y - height, input.width, height);
1159    frame.render_widget(Clear, area);
1160    frame.render_widget(Paragraph::new(lines).block(theme.raised("")), area);
1161    Some(area)
1162}
1163
1164/// The completion list on a raised surface just above the input, its keys
1165/// on its last row; where, if it fit.
1166fn draw_completion(
1167    frame: &mut Frame,
1168    theme: &Theme,
1169    completion: &Completion,
1170    input: Rect,
1171) -> Option<Rect> {
1172    let rows = completion.items.len().min(COMPLETION_ROWS);
1173    let height = u16::try_from(rows + 2).unwrap_or(3).min(input.y);
1174    if height < 3 {
1175        return None;
1176    }
1177    let area = Rect::new(input.x, input.y - height, input.width, height);
1178    frame.render_widget(Clear, area);
1179    let marker = completion
1180        .kind
1181        .marker()
1182        .map(String::from)
1183        .unwrap_or_default();
1184    let items: Vec<Line<'static>> = completion
1185        .items
1186        .iter()
1187        .map(|item| {
1188            let mut spans = vec![Span::from(format!("{marker}{}", item.text))];
1189            if !item.detail.is_empty() {
1190                spans.push(Span::from(format!("  {}", item.detail)).dim());
1191            }
1192            Line::from(spans)
1193        })
1194        .collect();
1195    let mut state = ListState::default().with_selected(Some(completion.selected));
1196    frame.render_stateful_widget(
1197        List::new(items)
1198            .block(with_keys(
1199                theme.raised(""),
1200                "Tab or Enter completes · Esc closes",
1201            ))
1202            .highlight_style(theme.selection()),
1203        area,
1204        &mut state,
1205    );
1206    Some(area)
1207}
1208
1209/// The rows a picker's list keeps, at least, when a long body takes the
1210/// rest of its box.
1211const LEAST_LIST: usize = 3;
1212
1213/// The picker or question on a raised surface centred over the
1214/// transcript, titled, its keys on its last row: its body,
1215/// scrolled to `choosing.scroll` (kept within it), what is typed, then the
1216/// rows it leaves, each with its detail at the right and, in a choice of
1217/// several, its tick box; for free text, the typed answer.
1218fn draw_choice(
1219    frame: &mut Frame,
1220    theme: &Theme,
1221    choosing: &mut PickerInput,
1222    shown: &Listing<'_>,
1223    preview: Option<(&PickPreview, Rect)>,
1224) -> Rect {
1225    let area = frame.area();
1226    // With a preview, the list takes the left of its box, the preview the
1227    // right: text drawn here, a conversation by its pane.
1228    let boxed = preview.map(|(preview, at)| {
1229        let [list, beside] =
1230            Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(at);
1231        (at, list, preview, beside)
1232    });
1233    let width = boxed.map_or(area.width.saturating_mul(4) / 5, |(_, list, ..)| list.width);
1234    let more = shown.more(choosing);
1235    let body = wrap_all(
1236        &plain_lines(more.unwrap_or(shown.body), Style::new()),
1237        usize::from(width.saturating_sub(2).max(1)),
1238    );
1239    let rows = shown.rows(&choosing.filter);
1240    // Room for the body, the typed line and the rows, up to 3/4 of the
1241    // screen.
1242    let wanted = body.len() + usize::from(!body.is_empty()) + 1 + rows.len() + 2;
1243    let height = u16::try_from(wanted)
1244        .unwrap_or(u16::MAX)
1245        .min(area.height.saturating_mul(3) / 4)
1246        .max(5);
1247    let popup = match boxed {
1248        Some((_, list, ..)) => list,
1249        None => area.centered(Constraint::Length(width), Constraint::Length(height)),
1250    };
1251    frame.render_widget(Clear, popup);
1252    if let Some((_, _, PickPreview::Text(text), beside)) = boxed {
1253        let wide = usize::from(beside.width.saturating_sub(2).max(1));
1254        let lines = wrap_all(&plain_lines(text, Style::new()), wide);
1255        frame.render_widget(Clear, beside);
1256        let block = theme.raised("preview");
1257        frame.render_widget(Paragraph::new(lines).block(block), beside);
1258    }
1259    let inner = theme.raised("").inner(popup);
1260    // The body takes what the typed line, a gap and the list's least
1261    // leave, and scrolls when that is not all of it.
1262    let list_least = rows.len().clamp(1, LEAST_LIST);
1263    let room = usize::from(inner.height).saturating_sub(2 + list_least);
1264    let body_height = body.len().min(room);
1265    choosing.scroll = choosing.scroll.min(body.len() - body_height);
1266    let mut keys = vec![if shown.free_text {
1267        "type to filter or answer"
1268    } else {
1269        "type to filter"
1270    }];
1271    if body_height < body.len() {
1272        keys.push("Shift+↑↓ scrolls");
1273    }
1274    if shown.ticks {
1275        keys.push("Tab ticks");
1276    }
1277    if more.is_some() {
1278        keys.push("← hides more");
1279    } else if shown.items.iter().any(|item| item.more.is_some()) {
1280        keys.push("→ shows more");
1281    }
1282    let keys = format!("{}, Enter picks, Esc closes", keys.join(", "));
1283    frame.render_widget(with_keys(theme.raised(shown.title), &keys), popup);
1284    let [body_area, _, typed, list] = Layout::vertical([
1285        Constraint::Length(u16::try_from(body_height).unwrap_or(u16::MAX)),
1286        Constraint::Length(u16::from(body_height > 0)),
1287        Constraint::Length(1),
1288        Constraint::Min(1),
1289    ])
1290    .areas(inner);
1291    let scroll = u16::try_from(choosing.scroll).unwrap_or(u16::MAX);
1292    frame.render_widget(Paragraph::new(body).scroll((scroll, 0)), body_area);
1293    frame.render_widget(Line::from(format!("› {}▏", choosing.filter)), typed);
1294    let width = usize::from(list.width);
1295    let items: Vec<Line<'static>> = rows
1296        .iter()
1297        .map(|row| match row {
1298            Row::Item(index) => {
1299                let Some(item) = shown.items.get(*index) else {
1300                    return Line::default();
1301                };
1302                if !shown.ticks {
1303                    return item.line(width);
1304                }
1305                let tick = match (shown.multiple, choosing.ticked.contains(index)) {
1306                    (true, true) => "[x] ",
1307                    (true, false) => "[ ] ",
1308                    (false, true) => "(x) ",
1309                    (false, false) => "( ) ",
1310                };
1311                let mut line = item.line(width.saturating_sub(tick.len()));
1312                line.spans.insert(0, Span::from(tick));
1313                line
1314            }
1315            Row::Typed if choosing.filter.trim().is_empty() => {
1316                Line::from("✎ or type an answer").dim()
1317            }
1318            Row::Typed if choosing.ticked.is_empty() => {
1319                Line::from(format!("✎ {}", choosing.filter.trim()))
1320            }
1321            Row::Typed => Line::from(format!("✎ {} (with the ticked)", choosing.filter.trim())),
1322        })
1323        .collect();
1324    let mut state = ListState::default().with_selected(Some(choosing.selected));
1325    frame.render_stateful_widget(
1326        List::new(items).highlight_style(theme.selection()),
1327        list,
1328        &mut state,
1329    );
1330    boxed.map_or(popup, |(whole, ..)| whole)
1331}
1332
1333/// Every cell of `buffer` in the terminal's own colors, keeping its bold,
1334/// underline and the like, for a [`plain`](Theme::plain) theme.
1335fn strip_colors(buffer: &mut Buffer) {
1336    for cell in &mut buffer.content {
1337        cell.fg = Color::Reset;
1338        cell.bg = Color::Reset;
1339        cell.underline_color = Color::Reset;
1340    }
1341}
1342
1343/// `surface` with `keys` dim at the right of its last row, where its
1344/// padding was.
1345fn with_keys<'a>(surface: Block<'a>, keys: &str) -> Block<'a> {
1346    surface
1347        .padding(Padding::horizontal(1))
1348        .title_bottom(Line::from(format!("{keys} ")).dim().right_aligned())
1349}
1350
1351/// The messages the agent accepted and has not sent yet, under the
1352/// transcript, in the order they came.
1353fn inbox_lines(inbox: &[&Incoming], theme: &Theme, lines: &mut Vec<Line<'static>>) {
1354    if inbox.is_empty() {
1355        return;
1356    }
1357    lines.push(Line::default());
1358    for message in inbox {
1359        let when = match message.mode {
1360            DeliveryMode::Steer => "steering",
1361            DeliveryMode::Queue => "after this turn",
1362            DeliveryMode::Inform => "noted",
1363        };
1364        let text = message.text();
1365        let mut rows = text.lines();
1366        let first = Line::from(vec![
1367            Span::from(format!("  ⏵ {when}: ")).dim(),
1368            Span::from(rows.next().unwrap_or_default().to_owned()).dim(),
1369        ]);
1370        let rest = rows.map(|row| Line::from(format!("    {row}")).dim());
1371        lines.extend(on_band(std::iter::once(first).chain(rest), theme.user));
1372    }
1373}
1374
1375#[cfg(test)]
1376mod tests;