1#![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
36static TERM_SET_UP: AtomicUsize = AtomicUsize::new(0);
38
39pub struct TermUi {
41 term: Option<DefaultTerminal>,
48
49 events: ct::EventStream,
51
52 agents: UiAgents,
54
55 input_area: ratatui_textarea::TextArea<'static>,
57
58 queued_prompts: VecDeque<String>,
60
61 processing: bool,
64
65 pending_permissions: VecDeque<(String, oneshot::Sender<std::result::Result<(), String>>)>,
69
70 app_name: String,
72
73 creation: Instant,
75}
76
77struct UiAgents {
79 state: Vec<AgentState>,
81
82 active_agent: usize,
84}
85
86struct AgentState {
88 client_info: ClientInfo,
90
91 client_state: Arc<RwLock<ClientState>>,
93
94 history: Arc<Mutex<ChatHistory>>,
96
97 subagent_state: Option<SubagentState>,
99
100 scroll: Saturating<usize>,
102
103 lines_on_screen: usize,
105}
106
107struct SubagentState {
109 id: SubagentId,
111
112 task: String,
114}
115
116impl TermUi {
117 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 pub fn draw(&mut self) -> Result<()> {
146 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 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 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 fn handle_key_event(&mut self, event: ct::KeyEvent) -> Result<Option<ui::Event>> {
179 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 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 return Ok(Some(ui::Event::Continue));
214 }
215 } else {
216 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 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 fn handle_paste_event(&mut self, text: String) -> Result<Option<ui::Event>> {
262 if self.agents.is_main() {
264 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 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 fn send_osc99_notification(&self, title: &str, body: Option<&str>) {
296 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 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 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 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 fn render(&mut self, frame: &mut Frame) {
351 let area = frame.area();
352
353 let input_outer_height = if self.agents.is_main() {
354 const MAX_HEIGHT: usize = 5;
360 let input_inner_width = area.width.saturating_sub(2) as usize; 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) } 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; 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; 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 if let Some((prompt, _)) = self.pending_permissions.front() {
498 TermUi::render_permission_popup(frame, area, prompt);
499 }
500 }
501
502 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); 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); frame.render_widget(paragraph, popup_area);
530 }
531}
532
533impl ui::UiState for TermUi {
534 type Error = anyhow::Error;
535
536 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 Ok(ui::Event::Exit)
555 }
556
557 fn notify(&mut self, notification: ui::Notification) -> Result<()> {
558 match notification {
559 ui::Notification::Exit => (), ui::Notification::Update => (),
561 ui::Notification::UpdateAgent { agent_id } => {
562 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 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
624fn 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
636fn 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
648fn 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
668fn 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 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 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 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 fn active(&self) -> &AgentState {
746 self.state.get(self.active_agent).expect("No agents left")
747 }
748
749 fn active_mut(&mut self) -> &mut AgentState {
751 self.state.get_mut(self.active_agent).expect("No agents left")
752 }
753
754 fn is_main(&self) -> bool {
756 self.active_agent == 0
757 }
758
759 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 fn subagent_index(&self) -> Option<usize> {
769 self.active_agent.checked_sub(1)
770 }
771
772 fn subagent_count(&self) -> usize {
774 self.state.len().checked_sub(1).expect("No agents left")
775 }
776
777 fn subagent_task(&self) -> Option<&str> {
779 self.active()
780 .subagent_state
781 .as_ref()
782 .map(|state| -> &str { &state.task })
783 }
784
785 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 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 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
821struct AgentStatsDisplay<'a> {
823 state: &'a RwLock<ClientState>,
825 info: &'a ClientInfo,
827 time_ref: Instant,
829}
830
831impl AgentStatsDisplay<'_> {
832 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 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 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 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}