Skip to main content

term_ui/
lib.rs

1//! UI part of the WorkBuddy application
2
3#![warn(missing_docs)]
4#![warn(clippy::missing_docs_in_private_items)]
5
6mod wrap;
7
8use anyhow::Result;
9use crossterm::event as ct;
10use futures::StreamExt;
11use helpers::TruncatedDisplay;
12use llimorse::Agent;
13use llimorse::agent::AgentStage;
14use llimorse::client::{ClientInfo, ClientState};
15use llimorse_chat::history::HistoryEntryType;
16use llimorse_chat::ui::{AgentId, SubagentId};
17use llimorse_chat::{ChatHistory, ui};
18use parking_lot::RwLock;
19use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};
20use ratatui::style::Style;
21use ratatui::text::{Line, Span, Text};
22use ratatui::widgets::{
23    Block, BorderType, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
24    Wrap,
25};
26use ratatui::{DefaultTerminal, Frame};
27use std::borrow::Cow;
28use std::collections::VecDeque;
29use std::num::Saturating;
30use std::sync::atomic::{AtomicUsize, Ordering};
31use std::sync::{Arc, Mutex};
32use std::time::Instant;
33use std::{cmp, env, fmt, io};
34use tokio::sync::oneshot;
35
36/// Counts users of the ratatui terminal (honestly only should be one or none...)
37static TERM_SET_UP: AtomicUsize = AtomicUsize::new(0);
38
39/// UI state for WorkBuddy
40pub struct TermUi {
41    /// ratatui terminal object
42    ///
43    /// Always set, except during rendering, because for some reason ratatui needs ownership access
44    /// to this. (Well, “for some reason” is that it found it clever to have a `term.do(|x| ...)`
45    /// pattern for rendering, so if we want access to `self` in that callback, we cannot have
46    /// `term` stored here.)
47    term: Option<DefaultTerminal>,
48
49    /// Produces terminal events, asynchronously
50    events: ct::EventStream,
51
52    /// All agents currently running
53    agents: UiAgents,
54
55    /// User message input widget
56    input_area: ratatui_textarea::TextArea<'static>,
57
58    /// Messages that are queued for sending
59    queued_prompts: VecDeque<String>,
60
61    /// Whether a prompt iteration is currently being processed (from `PromptSubmitted`
62    /// until `AwaitingPrompt`); the input title is not bolded while processing
63    processing: bool,
64
65    /// Tool calls awaiting a permission decision from the user, oldest first.
66    ///
67    /// Only the first one is shown; the rest wait their turn.
68    pending_permissions: VecDeque<(String, oneshot::Sender<std::result::Result<(), String>>)>,
69
70    /// The application name to use e.g. for notifications
71    app_name: String,
72
73    /// When the object was created, purely for visual purposes
74    creation: Instant,
75}
76
77/// Data for the agents currently running
78struct UiAgents {
79    /// State of the main agent and subagents
80    state: Vec<AgentState>,
81
82    /// Which agent is being viewed (0 = main agent)
83    active_agent: usize,
84}
85
86/// Overall state for an agent (main agent or subagent), including its view
87struct AgentState {
88    /// Immutable client information
89    client_info: ClientInfo,
90
91    /// The current general state of the agent’s client
92    client_state: Arc<RwLock<ClientState>>,
93
94    /// The chat history as shared with the agent
95    history: Arc<Mutex<ChatHistory>>,
96
97    /// If a subagent: Additional information about it
98    subagent_state: Option<SubagentState>,
99
100    /// First line of the chat history to show (`usize::MAX` to follow the tail)
101    scroll: Saturating<usize>,
102
103    /// How many lines (elements of `chat_history`) are visible on screen right now
104    lines_on_screen: usize,
105}
106
107/// Additional information about subagents
108struct SubagentState {
109    /// The ID by which the subagent is identified in UI notifications
110    id: SubagentId,
111
112    /// The task given to this agent
113    task: String,
114}
115
116impl TermUi {
117    /// Create the term state with `chat_history`, for `agent`, with the application name
118    /// `app_name` (e.g. for notifications).
119    pub fn new(app_name: &str, agent: &Agent, chat_history: Arc<Mutex<ChatHistory>>) -> Self {
120        let term = ratatui::init();
121        set_up_term();
122
123        let mut input_area: ratatui_textarea::TextArea<'static> = Default::default();
124        input_area.set_cursor_line_style(Default::default());
125        input_area.set_wrap_mode(ratatui_textarea::WrapMode::Word);
126
127        TermUi {
128            term: Some(term),
129            events: ct::EventStream::new(),
130            agents: UiAgents::new(
131                agent.client_state_arc(),
132                agent.client_info().clone(),
133                chat_history,
134            ),
135            input_area,
136            queued_prompts: VecDeque::new(),
137            processing: false,
138            pending_permissions: VecDeque::new(),
139            app_name: app_name.to_string(),
140            creation: Instant::now(),
141        }
142    }
143
144    /// Render the current state to screen
145    pub fn draw(&mut self) -> Result<()> {
146        // I really hate libraries/crates that thing they need to take ownership of everything via
147        // callbacks or prescribing traits/interfaces
148        let mut term = self.term.take().unwrap();
149        term.draw(|frame| self.render(frame))?;
150        self.term = Some(term);
151
152        Ok(())
153    }
154
155    /// Scroll the active view up by the given number of lines.
156    fn scroll_up(&mut self, lines: usize) {
157        let agent = self.agents.active_mut();
158        let history_len = agent.history.lock().unwrap().lines().len();
159
160        if agent.scroll.0 == usize::MAX {
161            agent.scroll.0 = history_len.saturating_sub(agent.lines_on_screen);
162        }
163        agent.scroll -= lines;
164    }
165
166    /// Scroll the active view down by the given number of lines.
167    fn scroll_down(&mut self, lines: usize) {
168        let agent = self.agents.active_mut();
169        let history_len = agent.history.lock().unwrap().lines().len();
170
171        agent.scroll += lines;
172        if agent.scroll.0 >= history_len.saturating_sub(agent.lines_on_screen) {
173            agent.scroll.0 = usize::MAX;
174        }
175    }
176
177    /// Handle the given keyboard event.
178    fn handle_key_event(&mut self, event: ct::KeyEvent) -> Result<Option<ui::Event>> {
179        // Ctrl-C is a hard exit, even while a permission request is on screen.
180        if event.kind == ct::KeyEventKind::Press
181            && event.code == ct::KeyCode::Char('c')
182            && event.modifiers.contains(ct::KeyModifiers::CONTROL)
183        {
184            return Ok(Some(ui::Event::Exit));
185        }
186
187        if event.kind == ct::KeyEventKind::Press && !self.pending_permissions.is_empty() {
188            // A permission request is on screen: it is modal. Enter approves, Esc denies, all
189            // other keys are ignored.
190            let decision: std::result::Result<(), String> = match event.code {
191                ct::KeyCode::Enter if event.modifiers.is_empty() => Ok(()),
192                ct::KeyCode::Esc => Err("denied by user".to_string()),
193                _ => return Ok(None),
194            };
195            let (_, approval) = self.pending_permissions.pop_front().unwrap();
196            let _ = approval.send(decision);
197            return Ok(None);
198        }
199
200        if event.kind == ct::KeyEventKind::Press {
201            match event.code {
202                ct::KeyCode::Enter if event.modifiers.is_empty() => {
203                    if self.agents.is_main() {
204                        if !self.input_area.is_empty() {
205                            let message = self.input_area.lines().join("\n");
206                            self.input_area.clear();
207                            return Ok(Some(ui::Event::Input(message)));
208                        } else if !self.queued_prompts.is_empty() {
209                            return Ok(Some(ui::Event::ForceSubmitQueued));
210                        } else {
211                            // Empty input: submit the current state. The agent thread ignores this
212                            // unless the history tops out on a tool result or a user message.
213                            return Ok(Some(ui::Event::Continue));
214                        }
215                    } else {
216                        // Do not do anything unless we’re in the main agent view
217                        return Ok(None);
218                    }
219                }
220
221                ct::KeyCode::PageUp => {
222                    if event.modifiers.contains(ct::KeyModifiers::SHIFT) {
223                        self.agents.switch_prev();
224                    } else {
225                        self.scroll_up(self.agents.active().lines_on_screen.div_ceil(2));
226                    }
227                    return Ok(None);
228                }
229                ct::KeyCode::PageDown => {
230                    if event.modifiers.contains(ct::KeyModifiers::SHIFT) {
231                        self.agents.switch_next();
232                    } else {
233                        self.scroll_down(self.agents.active().lines_on_screen.div_ceil(2));
234                    }
235                    return Ok(None);
236                }
237
238                _ => (),
239            }
240        }
241
242        if self.agents.is_main() {
243            self.input_area.input(event);
244        }
245        Ok(None)
246    }
247
248    /// Handle the given mouse event.
249    fn handle_mouse_event(&mut self, event: ct::MouseEvent) -> Result<Option<ui::Event>> {
250        match event.kind {
251            ct::MouseEventKind::ScrollDown => self.scroll_down(1),
252            ct::MouseEventKind::ScrollUp => self.scroll_up(1),
253
254            _ => (),
255        }
256
257        Ok(None)
258    }
259
260    /// Handle clipboard pasting.
261    fn handle_paste_event(&mut self, text: String) -> Result<Option<ui::Event>> {
262        // Ignore pasting in subagent views
263        if self.agents.is_main() {
264            // Normalize line endings: `ratatui_textarea::TextArea::insert_str()` does not handle
265            // \r.
266            let text = text.replace("\r\n", "\n").replace("\r", "\n");
267            self.input_area.insert_str(&text);
268        }
269
270        Ok(None)
271    }
272
273    /// The input field’s block: the title is bolded while the agent awaits a prompt, and not
274    /// bolded while a request is being processed.
275    fn input_block(processing: bool) -> Block<'static> {
276        let input_title_style = if processing {
277            Style::default()
278        } else {
279            Style::default().bold()
280        };
281        Block::bordered()
282            .title(Line::from(" Input ").style(input_title_style))
283            .title_bottom(Line::from(" [Ctrl-C to quit] ").right_aligned())
284    }
285
286    /// Send a terminal (OSC 99) notification with the given title and optional body.
287    ///
288    /// Under tmux (detected via `$TERM`) the OSC 99 messages are wrapped in tmux’s
289    /// passthrough envelope (which requires `allow-passthrough on`); otherwise the
290    /// messages are sent bare.
291    ///
292    /// The title and body are trimmed, and newlines, tabs, semicolons and other ASCII
293    /// control characters are replaced or dropped, since they would corrupt the OSC 99
294    /// payload; both are truncated to 200 characters.
295    fn send_osc99_notification(&self, title: &str, body: Option<&str>) {
296        /// Strip characters that would corrupt an OSC 99 payload.
297        fn sanitize(text: &str) -> String {
298            text.trim()
299                .chars()
300                .filter_map(|c| match c {
301                    '\n' | '\t' => Some(' '),
302                    '\r' => None,
303                    ';' => Some(','),
304                    c if c.is_ascii_control() => Some('�'),
305                    c => Some(c),
306                })
307                .collect()
308        }
309
310        // Without the leading ESC and the ST terminator: the bare path and the tmux
311        // wrapper both supply them.
312        let title_payload = format!(
313            "]99;i=1:d=0;{}: {}",
314            sanitize(&self.app_name),
315            sanitize(title).truncated_display(200)
316        );
317        let body_payload = match body {
318            Some(body) => {
319                format!(
320                    "]99;i=1:d=1:p=body;{}",
321                    sanitize(body).truncated_display(200)
322                )
323            }
324            None => String::from("]99;i=1:d=1:p=body;(No body)"),
325        };
326
327        let seq = if env::var("TERM").is_ok_and(|term| term.starts_with("tmux")) {
328            // Each message wrapped in tmux’s DCS passthrough envelope: the OSC’s leading
329            // ESC is doubled, its ST is spelled as `ESC ESC \`, and the envelope is closed
330            // with `ESC \`.
331            let title_env =
332                format!("\u{1b}Ptmux;\u{1b}\u{1b}{title_payload}\u{1b}\u{1b}\\\u{1b}\\");
333            let body_env = format!("\u{1b}Ptmux;\u{1b}\u{1b}{body_payload}\u{1b}\u{1b}\\\u{1b}\\");
334            format!("{title_env}{body_env}")
335        } else {
336            format!("\u{1b}{title_payload}\u{1b}\\\u{1b}{body_payload}\u{1b}\\")
337        };
338
339        let _ = crossterm::execute!(io::stdout(), crossterm::style::Print(seq));
340    }
341
342    /// Raise a terminal notification that the response is ready and the agent is awaiting a
343    /// new prompt.
344    fn notify_prompt_done(&self, response: Option<String>) {
345        let body = response.as_deref().unwrap_or("(Awaiting prompt.)");
346        self.send_osc99_notification("Turn done, awaiting prompt", Some(body));
347    }
348
349    /// Render the current state onto the screen.
350    fn render(&mut self, frame: &mut Frame) {
351        let area = frame.area();
352
353        let input_outer_height = if self.agents.is_main() {
354            // Input height field: Number of lines, maximum 5. Note that `.lines()` is always
355            // guaranteed to at least return one (empty) line, and `line_ranges` likewise always
356            // yields at least one row per line.  The `TextArea` does not expose its on-screen row
357            // count, so count rows with the same wrapping algorithm the widget renders with
358            // (vendored in `wrap`).
359            const MAX_HEIGHT: usize = 5;
360            let input_inner_width = area.width.saturating_sub(2) as usize; // account for the border
361            let input_inner_height = self
362                .input_area
363                .lines()
364                .iter()
365                .take(MAX_HEIGHT)
366                .map(|line| {
367                    wrap::wrapped_line_count(line, self.input_area.wrap_mode(), input_inner_width)
368                })
369                .sum::<usize>()
370                .min(MAX_HEIGHT) as u16;
371            Some(input_inner_height + 2) // account for the border
372        } else {
373            None
374        };
375
376        let mut layout = Vec::with_capacity(self.queued_prompts.len() + 2);
377        layout.push(Constraint::Percentage(100));
378        if let Some(input_outer_height) = input_outer_height {
379            for _ in 0..self.queued_prompts.len() {
380                layout.push(Constraint::Min(1));
381            }
382            layout.push(Constraint::Min(input_outer_height));
383        }
384
385        let layout = Layout::vertical(layout).split(area);
386
387        let history_cell = layout[0];
388        let input_cell = input_outer_height.map(|_| layout[self.queued_prompts.len() + 1]);
389
390        let subagent_count = self.agents.subagent_count();
391        let title_bottom = if let Some(subagent_i) = self.agents.subagent_index() {
392            let subagent_no = subagent_i + 1;
393            let title = if let Some(subagent_task) = self.agents.subagent_task() {
394                format!(
395                    " Subagent {subagent_no}/{subagent_count}: {} [Shift+PgDn/PgUp] ",
396                    subagent_task.truncated_display(40),
397                )
398            } else {
399                format!(" Subagent {subagent_no}/{subagent_count} [Shift+PgDn/PgUp] ")
400            };
401            Some(title)
402        } else if subagent_count > 0 {
403            Some(format!(" {subagent_count} subagents [Shift+PgDn/PgUp] "))
404        } else {
405            None
406        };
407
408        let agent = self.agents.active_mut();
409        let chat_history = agent.history.lock().unwrap();
410        let history_line_count = history_cell.height.saturating_sub(2) as usize;
411        let history_width = history_cell.width.saturating_sub(2) as usize;
412
413        let mut lines_on_screen = 0;
414
415        let history_lines = if agent.scroll.0 == usize::MAX {
416            let mut history_lines = history_into_ratatui_lines(
417                chat_history
418                    .lines()
419                    .iter()
420                    .rev()
421                    .flat_map(|line| {
422                        lines_on_screen += 1; // diabolical
423                        textwrap::wrap(&line.0, history_width)
424                            .into_iter()
425                            .rev()
426                            .map(|l| (l, line.1))
427                    })
428                    .take(history_line_count),
429            );
430            history_lines.reverse();
431            history_lines
432        } else {
433            history_into_ratatui_lines(
434                chat_history
435                    .lines()
436                    .iter()
437                    .skip(agent.scroll.0)
438                    .flat_map(|line| {
439                        lines_on_screen += 1; // diabolical
440                        textwrap::wrap(&line.0, history_width)
441                            .into_iter()
442                            .map(|l| (l, line.1))
443                    })
444                    .take(history_line_count),
445            )
446        };
447
448        agent.lines_on_screen = lines_on_screen;
449
450        let paragraph_content = Text {
451            alignment: None,
452            style: Default::default(),
453            lines: history_lines,
454        };
455
456        let mut chat_block = Block::bordered()
457            .title_style(Style::new().bold())
458            .title(format!(" {} ", agent.stats(self.creation)));
459        if let Some(title_bottom) = title_bottom {
460            chat_block = chat_block.title_bottom(Line::from(title_bottom).right_aligned());
461        }
462        let paragraph = Paragraph::new(paragraph_content).block(chat_block);
463
464        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
465        let history_len = chat_history.lines().len();
466        let scroll_len = history_len.saturating_sub(lines_on_screen);
467        let mut scrollbar_state =
468            ScrollbarState::new(scroll_len).position(cmp::min(agent.scroll.0, scroll_len));
469
470        frame.render_widget(paragraph, history_cell);
471        frame.render_stateful_widget(
472            scrollbar,
473            history_cell.inner(Margin {
474                vertical: 1,
475                horizontal: 0,
476            }),
477            &mut scrollbar_state,
478        );
479        if let Some(input_cell) = input_cell {
480            for (i, p) in self.queued_prompts.iter().enumerate() {
481                let line = Line {
482                    style: Style::new().white().on_blue(),
483                    alignment: None,
484                    spans: vec![Span {
485                        style: Default::default(),
486                        content: p.into(),
487                    }],
488                };
489                frame.render_widget(line, layout[i + 1]);
490            }
491            self.input_area
492                .set_block(Self::input_block(self.processing));
493            frame.render_widget(&self.input_area, input_cell);
494        }
495
496        // Render the permission popup last, so that it occludes the main layout.
497        if let Some((prompt, _)) = self.pending_permissions.front() {
498            TermUi::render_permission_popup(frame, area, prompt);
499        }
500    }
501
502    /// Render the given permission request as a popup centered on the screen, on top of the main
503    /// layout.
504    ///
505    /// The popup is 80% of the terminal’s width (at most) and as tall as its wrapped prompt
506    /// requires (at most the terminal’s height), and clears the area it covers, so that nothing
507    /// from the main layout shows through.
508    fn render_permission_popup(frame: &mut Frame, area: Rect, prompt: &str) {
509        let width = (area.width.saturating_mul(8) / 10).max(4).min(area.width);
510        let inner_width = width.saturating_sub(2).max(1); // account for the border
511
512        let block = Block::bordered()
513            .border_style(Style::new().yellow())
514            .border_type(BorderType::Thick)
515            .title(" Tool permission requested ")
516            .title_style(Style::new().white().bold())
517            .title_bottom(Line::from(" [Enter: allow]  [Esc: deny] ").right_aligned())
518            .padding(Padding::proportional(1));
519        let paragraph = Paragraph::new(prompt)
520            .wrap(Wrap { trim: true })
521            .block(block);
522        let height = paragraph
523            .line_count(inner_width)
524            .min(area.height as usize)
525            .max(1) as u16;
526
527        let popup_area = area.centered(Constraint::Length(width), Constraint::Length(height));
528        frame.render_widget(Clear, popup_area); // clear what is underneath
529        frame.render_widget(paragraph, popup_area);
530    }
531}
532
533impl ui::UiState for TermUi {
534    type Error = anyhow::Error;
535
536    /// Handle input on the terminal, and redraw
537    async fn get_event(&mut self) -> Result<ui::Event> {
538        while let Some(result) = self.events.next().await {
539            let event = match result? {
540                ct::Event::Paste(text) => self.handle_paste_event(text),
541                ct::Event::Key(key) => self.handle_key_event(key),
542                ct::Event::Mouse(mouse) => self.handle_mouse_event(mouse),
543                _ => Ok(None),
544            };
545
546            self.draw()?;
547
548            if let Some(event) = event? {
549                return Ok(event);
550            }
551        }
552
553        // Stream ended
554        Ok(ui::Event::Exit)
555    }
556
557    fn notify(&mut self, notification: ui::Notification) -> Result<()> {
558        match notification {
559            ui::Notification::Exit => (), // To be handled by the parent
560            ui::Notification::Update => (),
561            ui::Notification::UpdateAgent { agent_id } => {
562                // Skip the redraw if the updated agent is not the one on screen
563                if agent_id != self.agents.active_agent_id() {
564                    return Ok(());
565                }
566            }
567            ui::Notification::PromptQueued(p) => {
568                let sanitized = p
569                    .chars()
570                    .filter_map(|c| match c {
571                        '\n' => Some('↵'),
572                        '\t' => Some(' '),
573                        '\r' => None,
574                        c if c.is_ascii_control() => Some('�'),
575                        c => Some(c),
576                    })
577                    .collect::<String>();
578                self.queued_prompts.push_back(sanitized);
579            }
580            ui::Notification::PromptSubmitted => {
581                self.queued_prompts.pop_front();
582                self.processing = true;
583            }
584            ui::Notification::AwaitingPrompt { response } => {
585                self.processing = false;
586                self.notify_prompt_done(response);
587            }
588            ui::Notification::RequestPermission { prompt, approval } => {
589                // Alert the user out of band: the modal popup only reaches them while
590                // they are viewing this agent.
591                self.send_osc99_notification("Tool permission requested", Some(&prompt));
592                self.pending_permissions.push_back((prompt, approval));
593            }
594            ui::Notification::SubagentCreated {
595                subagent_id,
596                prompt,
597                client_info,
598                client_state,
599                chat_history,
600            } => {
601                self.agents.add_subagent(
602                    subagent_id,
603                    prompt,
604                    client_info,
605                    client_state,
606                    chat_history,
607                );
608            }
609            ui::Notification::SubagentDropped { subagent_id } => {
610                self.agents.remove_subagent(subagent_id);
611            }
612        }
613
614        self.draw()
615    }
616}
617
618impl Drop for TermUi {
619    fn drop(&mut self) {
620        tear_down_term();
621    }
622}
623
624/// Basic terminal set-up
625fn set_up_term() {
626    if TERM_SET_UP.fetch_add(1, Ordering::Relaxed) == 0 {
627        color_eyre::install().expect("Failed to install crash handler");
628        let _ = crossterm::execute!(
629            io::stdout(),
630            ct::EnableBracketedPaste,
631            ct::EnableMouseCapture
632        );
633    }
634}
635
636/// Basic terminal tear-down
637fn tear_down_term() {
638    if TERM_SET_UP.fetch_sub(1, Ordering::Relaxed) == 1 {
639        let _ = crossterm::execute!(
640            io::stdout(),
641            ct::DisableBracketedPaste,
642            ct::DisableMouseCapture
643        );
644        ratatui::restore();
645    }
646}
647
648/// Helper function to convert the given iterator of `HistoryEntryType`-annotated lines into
649/// ratatui lines.
650fn history_into_ratatui_lines<'a, I: Iterator<Item = (Cow<'a, str>, HistoryEntryType)>>(
651    iter: I,
652) -> Vec<Line<'a>> {
653    iter.map(|line| {
654        let (style, alignment) = ratatui_style(line.1);
655
656        Line {
657            style,
658            alignment: Some(alignment),
659            spans: vec![Span {
660                style: Default::default(),
661                content: line.0,
662            }],
663        }
664    })
665    .collect()
666}
667
668/// Converts a `HistoryEntryType` into the corresponding ratatui styles
669fn ratatui_style(het: HistoryEntryType) -> (Style, Alignment) {
670    match het {
671        HistoryEntryType::Empty => (Style::default(), Alignment::Left),
672        HistoryEntryType::User => (Style::default().bold().magenta(), Alignment::Right),
673        HistoryEntryType::Content => (Style::default().white(), Alignment::Left),
674        HistoryEntryType::Reasoning => (Style::default().dim(), Alignment::Left),
675        HistoryEntryType::ToolCall => (Style::default().blue(), Alignment::Left),
676        HistoryEntryType::ToolResultOk => (Style::default().green(), Alignment::Left),
677        HistoryEntryType::ToolResultErr => (Style::default().bold().red(), Alignment::Left),
678    }
679}
680
681impl UiAgents {
682    /// Create a new collection of agents, with a single main agent
683    fn new(
684        main_client_state: Arc<RwLock<ClientState>>,
685        main_client_info: ClientInfo,
686        main_history: Arc<Mutex<ChatHistory>>,
687    ) -> Self {
688        UiAgents {
689            state: vec![AgentState {
690                client_state: main_client_state,
691                client_info: main_client_info,
692                history: main_history,
693                subagent_state: None,
694                scroll: Saturating(usize::MAX),
695                lines_on_screen: 0,
696            }],
697            active_agent: 0,
698        }
699    }
700
701    /// Add a new subagent at the end of our list
702    fn add_subagent(
703        &mut self,
704        subagent_id: SubagentId,
705        prompt: String,
706        client_info: ClientInfo,
707        client_state: Arc<RwLock<ClientState>>,
708        history: Arc<Mutex<ChatHistory>>,
709    ) {
710        self.state.push(AgentState {
711            client_info,
712            client_state,
713            history,
714            subagent_state: Some(SubagentState {
715                id: subagent_id,
716                task: prompt,
717            }),
718            scroll: Saturating(usize::MAX),
719            lines_on_screen: 0,
720        });
721    }
722
723    /// Remove a subagent by its ID
724    ///
725    /// If the subagent is currently active, change the active view to the main agent.
726    fn remove_subagent(&mut self, subagent_id: SubagentId) {
727        let Some(index) = self.state.iter().position(|agent| {
728            agent
729                .subagent_state
730                .as_ref()
731                .is_some_and(|state| state.id == subagent_id)
732        }) else {
733            return;
734        };
735
736        if self.active_agent == index {
737            self.active_agent = 0;
738        } else if self.active_agent > index {
739            self.active_agent -= 1;
740        }
741        self.state.remove(index);
742    }
743
744    /// Return the currently active view’s agent
745    fn active(&self) -> &AgentState {
746        self.state.get(self.active_agent).expect("No agents left")
747    }
748
749    /// Return the currently active view’s agent, mutably
750    fn active_mut(&mut self) -> &mut AgentState {
751        self.state.get_mut(self.active_agent).expect("No agents left")
752    }
753
754    /// Return whether the currently active view is the main agent’s
755    fn is_main(&self) -> bool {
756        self.active_agent == 0
757    }
758
759    /// Return the ID of the agent in the currently active view
760    fn active_agent_id(&self) -> AgentId {
761        self.active()
762            .subagent_state
763            .as_ref()
764            .map_or(AgentId::Main, |state| AgentId::Subagent(state.id))
765    }
766
767    /// Return the subagent index, if viewing a subagent
768    fn subagent_index(&self) -> Option<usize> {
769        self.active_agent.checked_sub(1)
770    }
771
772    /// Return the number of subagents
773    fn subagent_count(&self) -> usize {
774        self.state.len().checked_sub(1).expect("No agents left")
775    }
776
777    /// If the currently active view is a subagent, return its task prompt (if known)
778    fn subagent_task(&self) -> Option<&str> {
779        self.active()
780            .subagent_state
781            .as_ref()
782            .map(|state| -> &str { &state.task })
783    }
784
785    /// Switch to the previous agent (wrapping)
786    fn switch_prev(&mut self) {
787        self.active_agent = self
788            .active_agent
789            .checked_sub(1)
790            .or_else(|| self.state.len().checked_sub(1))
791            .expect("No agents left");
792    }
793
794    /// Switch to the next agent (wrapping)
795    fn switch_next(&mut self) {
796        if self.state.is_empty() {
797            panic!("No agents left");
798        }
799
800        self.active_agent = self.active_agent.checked_add(1).unwrap_or(0);
801        if self.active_agent >= self.state.len() {
802            self.active_agent = 0;
803        }
804    }
805}
806
807impl AgentState {
808    /// Format the client-state statistics that appear in the history pane title: model name,
809    /// operation stage, and context usage (including live prefill progress).
810    ///
811    /// `time_ref` is an arbitrary (but fixed) point in time so we can animate spinners.
812    fn stats(&self, time_ref: Instant) -> impl fmt::Display {
813        AgentStatsDisplay {
814            state: &self.client_state,
815            info: &self.client_info,
816            time_ref,
817        }
818    }
819}
820
821/// Helper struct to display agent/client stats
822struct AgentStatsDisplay<'a> {
823    /// Client/agent state to display
824    state: &'a RwLock<ClientState>,
825    /// Client/agent info to display (immutable state)
826    info: &'a ClientInfo,
827    /// An arbitrary fixed point in time to animate spinners
828    time_ref: Instant,
829}
830
831impl AgentStatsDisplay<'_> {
832    /// Return a representative emoji of the current stage, animated
833    ///
834    /// Specifically the stages where there is no visible text generation in the output window
835    /// (i.e. all phases but reasoning and response generation) should have animated emoji, so the
836    /// user knows we are not stuck. The exception of course is the idle (awaiting prompt) phase,
837    /// where an animation would be distracting.
838    fn animated_stage_emoji(&self, stage: AgentStage) -> &'static str {
839        let animation_phase = self.time_ref.elapsed().as_secs();
840
841        match stage {
842            AgentStage::Idle => "🟢",
843            AgentStage::Prefill => match animation_phase % 6 {
844                0 | 2 | 4 => "⏳",
845                1 => "🧪",
846                3 => "⚗️",
847                _ => "☕",
848            },
849            AgentStage::Reasoning => "🤔",
850            AgentStage::ResponseGeneration => "🗣️",
851            AgentStage::ToolCallGeneration => match animation_phase % 2 {
852                0 => "🧨",
853                _ => "💥",
854            },
855            AgentStage::ToolExecution => match animation_phase % 2 {
856                0 => "🎆",
857                _ => "✨",
858            },
859        }
860    }
861}
862
863impl fmt::Display for AgentStatsDisplay<'_> {
864    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865        let state = self.state.read();
866
867        let stage = self.animated_stage_emoji(state.operation_stage);
868
869        let tokens = state.token_usage.sum();
870        let target_suffix = if let Some(prefill_target) = state.token_usage.prefill_target
871            && state.operation_stage == AgentStage::Prefill
872        {
873            Cow::Owned(format!("… [{:.1}k]", prefill_target as f32 * 1.0e-3))
874        } else if state.operation_stage.is_processing() {
875            Cow::Borrowed("…")
876        } else {
877            Cow::Borrowed("")
878        };
879
880        if let Some(context_size) = self.info.context_size {
881            write!(
882                f,
883                " {} {stage} {:.1}k{target_suffix} / {:.1}k ",
884                self.info.model_name,
885                tokens as f32 * 1.0e-3,
886                context_size as f32 * 1.0e-3,
887            )
888        } else {
889            write!(
890                f,
891                " {} {stage} {:.1}k{target_suffix} ",
892                self.info.model_name,
893                tokens as f32 * 1.0e-3,
894            )
895        }
896    }
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use ratatui::backend::TestBackend;
903
904    /// Draw a busy background and the permission popup for the given prompt, and return the
905    /// resulting screen as lines of symbols.
906    fn screen_with_popup(width: u16, height: u16, prompt: &str) -> Vec<String> {
907        let backend = TestBackend::new(width, height);
908        let mut terminal = ratatui::Terminal::new(backend).unwrap();
909        let frame = terminal
910            .draw(|frame| {
911                let background = vec![Line::from("x".repeat(width as usize)); height as usize];
912                frame.render_widget(Paragraph::new(background), frame.area());
913                TermUi::render_permission_popup(frame, frame.area(), prompt);
914            })
915            .unwrap();
916        frame
917            .buffer
918            .content()
919            .chunks(width as usize)
920            .map(|row| row.iter().map(|cell| cell.symbol()).collect())
921            .collect()
922    }
923
924    #[test]
925    fn popup_is_centered_and_opaque() {
926        // 60x20 terminal: the popup is 48 wide (80%) and 5 tall (one prompt line plus thick border
927        // and proportional padding), at (6, 8) (the layout solver gives the leftover row to the top
928        // when centering).
929        let title = " Tool permission requested ";
930        let hint = " [Enter: allow]  [Esc: deny] ";
931        let mut expected = vec!["x".repeat(60); 20];
932        expected[8] = format!(
933            "{}┏{}{}┓{}",
934            "x".repeat(6),
935            title,
936            "━".repeat(46 - 27),
937            "x".repeat(6)
938        );
939        expected[9] = format!("{}┃{}┃{}", "x".repeat(6), " ".repeat(46), "x".repeat(6));
940        expected[10] = format!(
941            "{}┃  {}{}┃{}",
942            "x".repeat(6),
943            "echo hello",
944            " ".repeat(46 - 12),
945            "x".repeat(6)
946        );
947        expected[11] = format!("{}┃{}┃{}", "x".repeat(6), " ".repeat(46), "x".repeat(6));
948        expected[12] = format!(
949            "{}┗{}{}┛{}",
950            "x".repeat(6),
951            "━".repeat(46 - 29),
952            hint,
953            "x".repeat(6)
954        );
955
956        assert_eq!(screen_with_popup(60, 20, "echo hello"), expected);
957    }
958
959    #[test]
960    fn popup_does_not_panic_on_small_terminals() {
961        // A prompt that would be far too large for the terminal must be clamped, not panic.
962        let prompt = "x".repeat(1000);
963        for (width, height) in [(12u16, 6), (4u16, 3), (1u16, 1)] {
964            let screen = screen_with_popup(width, height, &prompt);
965            assert_eq!(screen.len(), height as usize);
966        }
967
968        let screen = screen_with_popup(12, 6, &prompt);
969        assert!(screen[0].contains('┏') && screen[0].contains('┓'));
970        assert!(screen[5].contains('┗') && screen[5].contains('┛'));
971    }
972}