1use std::io;
8
9use crossterm::{
10 event::{self, Event, KeyCode, KeyModifiers, MouseEventKind, EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture},
11 execute,
12 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13};
14use ratatui::{
15 backend::CrosstermBackend,
16 layout::{Constraint, Direction, Layout, Rect},
17 style::{Color, Modifier, Style},
18 text::{Line, Span, Text},
19 widgets::{Block, Borders, Paragraph, Wrap},
20 Frame, Terminal,
21};
22use tokio::sync::mpsc;
23use anyhow::Result;
24
25use crate::tui_sink::TuiSink;
26use abk::cli::ResumeInfo;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FocusPanel {
31 Output,
32 Todo,
33 Input,
34}
35
36#[derive(Debug, Clone)]
38pub enum TuiMessage {
39 OutputLine(String),
41 StreamDelta(String),
43 ReasoningDelta(String),
45 WorkflowCompleted,
47 WorkflowError(String),
49 ResumeInfo(Option<ResumeInfo>),
51 TodoUpdate(String),
53}
54
55pub type BuildInfo = abk::cli::BuildInfo;
57
58fn char_to_byte_offset(s: &str, char_idx: usize) -> usize {
61 s.char_indices()
62 .nth(char_idx)
63 .map(|(byte_pos, _)| byte_pos)
64 .unwrap_or(s.len())
65}
66
67fn estimate_visual_lines(text: &Text, viewport_width: u16) -> usize {
71 let w = viewport_width.saturating_sub(2).max(1) as usize;
72 let raw: usize = text.lines.iter().map(|line| {
73 let chars: usize = line.spans.iter()
74 .map(|s| s.content.chars().count())
75 .sum();
76 if chars == 0 { 1 } else { (chars + w - 1) / w }
77 }).sum();
78 raw + 1
80}
81
82pub struct App {
84 pub input: String,
86 pub cursor_position: usize,
88 pub output_lines: Vec<String>,
90 pub scroll: u16,
92 pub auto_scroll: bool,
94 max_scroll_cache: u16,
96 pub focus: FocusPanel,
98 pub todo_scroll: u16,
100 todo_max_scroll_cache: u16,
102 pub input_scroll: u16,
104 input_max_scroll_cache: u16,
106 pub should_quit: bool,
108 pub workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
110 pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
112 pub workflow_running: bool,
114 pub config_toml: Option<String>,
116 pub secrets: Option<std::collections::HashMap<String, String>>,
118 pub build_info: Option<BuildInfo>,
120 pub resume_info: Option<ResumeInfo>,
122 pub todo_lines: Vec<String>,
124 input_inner_width_cache: usize,
126 output_rect: Rect,
128 todo_rect: Rect,
129 input_rect: Rect,
130 mouse_passthrough: bool,
132}
133
134impl App {
135 pub fn new() -> Self {
137 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
138 Self {
139 input: String::new(),
140 cursor_position: 0,
141 output_lines: vec![
142 "Welcome to Trustee TUI".to_string(),
143 "Type a task and press Enter to execute".to_string(),
144 "Press Ctrl+C to exit".to_string(),
145 "".to_string(),
146 "Keyboard shortcuts:".to_string(),
147 " ↑/↓ or Page Up/Down - Scroll output".to_string(),
148 " y - Copy visible text (Output/Todo)".to_string(),
149 " Enter - Execute task".to_string(),
150 " Ctrl+O - Toggle mouse passthrough (select text)".to_string(),
151 " Esc or Ctrl+C - Exit".to_string(),
152 ],
153 scroll: 0,
154 auto_scroll: true,
155 max_scroll_cache: 0,
156 focus: FocusPanel::Input,
157 todo_scroll: 0,
158 todo_max_scroll_cache: 0,
159 input_scroll: 0,
160 input_max_scroll_cache: 0,
161 should_quit: false,
162 workflow_rx,
163 workflow_tx,
164 workflow_running: false,
165 config_toml: None,
166 secrets: None,
167 build_info: None,
168 resume_info: None,
169 todo_lines: Vec::new(),
170 input_inner_width_cache: 80,
171 output_rect: Rect::default(),
172 todo_rect: Rect::default(),
173 input_rect: Rect::default(),
174 mouse_passthrough: false,
175 }
176 }
177
178 pub async fn run(&mut self) -> Result<()> {
185 enable_raw_mode()?;
187 let mut stdout = io::stdout();
188 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?;
189 let backend = CrosstermBackend::new(stdout);
190 let mut terminal = Terminal::new(backend)?;
191
192 loop {
194 terminal.draw(|f| self.render(f))?;
196
197 tokio::select! {
199 result = Self::poll_event() => {
201 if let Some(event) = result? {
202 self.handle_event(event)?;
203 }
204 }
205
206 msg = self.workflow_rx.recv() => {
208 if let Some(msg) = msg {
209 self.handle_workflow_message(msg);
210 }
211 }
212 }
213
214 if self.should_quit {
215 break;
216 }
217 }
218
219 disable_raw_mode()?;
221 execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableBracketedPaste, DisableMouseCapture)?;
222 terminal.show_cursor()?;
223
224 Ok(())
225 }
226
227 async fn poll_event() -> Result<Option<Event>> {
231 tokio::task::spawn_blocking(|| {
234 if event::poll(std::time::Duration::from_millis(50))? {
236 Ok(Some(event::read()?))
237 } else {
238 Ok(None)
239 }
240 })
241 .await?
242 }
243
244 fn handle_event(&mut self, event: Event) -> Result<()> {
246 if let Event::Paste(text) = event {
249 let sanitized = text.replace('\n', " ").replace('\r', "");
250 for c in sanitized.chars() {
251 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
252 self.input.insert(byte_pos, c);
253 self.cursor_position += 1;
254 }
255 return Ok(());
256 }
257
258 if let Event::Mouse(mouse) = event {
260 if self.mouse_passthrough {
262 return Ok(());
263 }
264 let col = mouse.column;
265 let row = mouse.row;
266 match mouse.kind {
267 MouseEventKind::Down(_) => {
268 if self.output_rect.contains((col, row).into()) {
270 self.focus = FocusPanel::Output;
271 } else if self.todo_rect.contains((col, row).into()) {
272 self.focus = FocusPanel::Todo;
273 } else if self.input_rect.contains((col, row).into()) {
274 self.focus = FocusPanel::Input;
275 }
276 }
277 MouseEventKind::ScrollUp => {
278 if self.output_rect.contains((col, row).into()) {
279 self.auto_scroll = false;
280 if self.scroll == u16::MAX {
281 self.scroll = self.max_scroll_cache;
282 }
283 self.scroll = self.scroll.saturating_sub(3);
284 } else if self.todo_rect.contains((col, row).into()) {
285 self.todo_scroll = self.todo_scroll.saturating_sub(3);
286 } else if self.input_rect.contains((col, row).into()) {
287 self.input_scroll = self.input_scroll.saturating_sub(1);
288 }
289 }
290 MouseEventKind::ScrollDown => {
291 if self.output_rect.contains((col, row).into()) {
292 if self.scroll == u16::MAX { return Ok(()); }
293 self.scroll = self.scroll.saturating_add(3);
294 if self.scroll >= self.max_scroll_cache {
295 self.auto_scroll = true;
296 self.scroll = u16::MAX;
297 }
298 } else if self.todo_rect.contains((col, row).into()) {
299 self.todo_scroll = self.todo_scroll.saturating_add(3)
300 .min(self.todo_max_scroll_cache);
301 } else if self.input_rect.contains((col, row).into()) {
302 self.input_scroll = self.input_scroll.saturating_add(1)
303 .min(self.input_max_scroll_cache);
304 }
305 }
306 _ => {}
307 }
308 return Ok(());
309 }
310
311 if let Event::Key(key) = event {
312 if self.mouse_passthrough {
314 execute!(std::io::stdout(), EnableMouseCapture).ok();
315 self.mouse_passthrough = false;
316 return Ok(());
317 }
318
319 match key.code {
321 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
322 self.should_quit = true;
323 return Ok(());
324 }
325 KeyCode::Esc => {
326 self.should_quit = true;
327 return Ok(());
328 }
329 KeyCode::Tab => {
331 self.focus = match self.focus {
332 FocusPanel::Input => FocusPanel::Output,
333 FocusPanel::Output => FocusPanel::Todo,
334 FocusPanel::Todo => FocusPanel::Input,
335 };
336 return Ok(());
337 }
338 KeyCode::BackTab => {
340 self.focus = match self.focus {
341 FocusPanel::Input => FocusPanel::Todo,
342 FocusPanel::Todo => FocusPanel::Output,
343 FocusPanel::Output => FocusPanel::Input,
344 };
345 return Ok(());
346 }
347 KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
349 execute!(std::io::stdout(), DisableMouseCapture).ok();
350 self.mouse_passthrough = true;
351 return Ok(());
352 }
353 _ => {}
354 }
355
356 match self.focus {
358 FocusPanel::Output => self.handle_output_keys(key.code)?,
359 FocusPanel::Todo => self.handle_todo_keys(key.code)?,
360 FocusPanel::Input => self.handle_input_keys(key.code)?,
361 }
362 }
363 Ok(())
364 }
365
366 fn handle_output_keys(&mut self, code: KeyCode) -> Result<()> {
368 match code {
369 KeyCode::Up => {
370 self.auto_scroll = false;
371 if self.scroll == u16::MAX {
372 self.scroll = self.max_scroll_cache;
373 }
374 self.scroll = self.scroll.saturating_sub(1);
375 }
376 KeyCode::Down => {
377 if self.scroll == u16::MAX { return Ok(()); }
378 self.scroll = self.scroll.saturating_add(1);
379 if self.scroll >= self.max_scroll_cache {
380 self.auto_scroll = true;
381 self.scroll = u16::MAX;
382 }
383 }
384 KeyCode::PageUp => {
385 self.auto_scroll = false;
386 if self.scroll == u16::MAX {
387 self.scroll = self.max_scroll_cache;
388 }
389 self.scroll = self.scroll.saturating_sub(10);
390 }
391 KeyCode::PageDown => {
392 if self.scroll == u16::MAX { return Ok(()); }
393 self.scroll = self.scroll.saturating_add(10);
394 if self.scroll >= self.max_scroll_cache {
395 self.auto_scroll = true;
396 self.scroll = u16::MAX;
397 }
398 }
399 KeyCode::Home => {
400 self.auto_scroll = false;
401 self.scroll = 0;
402 }
403 KeyCode::End => {
404 self.auto_scroll = true;
405 self.scroll = u16::MAX;
406 }
407 KeyCode::Char('y') => {
408 self.copy_output_to_clipboard();
409 }
410 KeyCode::Enter => {
411 if !self.input.is_empty() && !self.workflow_running {
412 self.focus = FocusPanel::Input;
413 self.execute_command();
414 }
415 }
416 KeyCode::Char(c) => {
418 self.focus = FocusPanel::Input;
419 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
420 self.input.insert(byte_pos, c);
421 self.cursor_position += 1;
422 }
423 _ => {}
424 }
425 Ok(())
426 }
427
428 fn handle_todo_keys(&mut self, code: KeyCode) -> Result<()> {
430 match code {
431 KeyCode::Up => {
432 self.todo_scroll = self.todo_scroll.saturating_sub(1);
433 }
434 KeyCode::Down => {
435 self.todo_scroll = self.todo_scroll.saturating_add(1)
436 .min(self.todo_max_scroll_cache);
437 }
438 KeyCode::PageUp => {
439 self.todo_scroll = self.todo_scroll.saturating_sub(10);
440 }
441 KeyCode::PageDown => {
442 self.todo_scroll = self.todo_scroll.saturating_add(10)
443 .min(self.todo_max_scroll_cache);
444 }
445 KeyCode::Home => { self.todo_scroll = 0; }
446 KeyCode::End => { self.todo_scroll = self.todo_max_scroll_cache; }
447 KeyCode::Char('y') => self.copy_to_clipboard(self.todo_lines.join("\n")),
449 KeyCode::Char(c) if c != 'y' => {
451 self.focus = FocusPanel::Input;
452 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
453 self.input.insert(byte_pos, c);
454 self.cursor_position += 1;
455 }
456 KeyCode::Enter => {
457 if !self.input.is_empty() && !self.workflow_running {
458 self.focus = FocusPanel::Input;
459 self.execute_command();
460 }
461 }
462 _ => {}
463 }
464 Ok(())
465 }
466
467 fn handle_input_keys(&mut self, code: KeyCode) -> Result<()> {
469 match code {
470 KeyCode::Enter => {
471 if !self.input.is_empty() && !self.workflow_running {
472 self.execute_command();
473 }
474 }
475 KeyCode::Backspace => {
476 if self.cursor_position > 0 {
477 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position - 1);
478 self.input.remove(byte_pos);
479 self.cursor_position -= 1;
480 }
481 }
482 KeyCode::Delete => {
483 let char_count = self.input.chars().count();
484 if self.cursor_position < char_count {
485 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
486 self.input.remove(byte_pos);
487 }
488 }
489 KeyCode::Up => {
490 let w = self.input_inner_width_cache.max(1);
492 if self.cursor_position >= w {
493 self.cursor_position -= w;
494 } else {
495 self.cursor_position = 0;
496 }
497 }
498 KeyCode::Down => {
499 let w = self.input_inner_width_cache.max(1);
500 let char_count = self.input.chars().count();
501 self.cursor_position = (self.cursor_position + w).min(char_count);
502 }
503 KeyCode::PageUp => {
504 self.input_scroll = self.input_scroll.saturating_sub(3);
505 }
506 KeyCode::PageDown => {
507 self.input_scroll = self.input_scroll.saturating_add(3)
508 .min(self.input_max_scroll_cache);
509 }
510 KeyCode::Home => { self.cursor_position = 0; }
511 KeyCode::End => { self.cursor_position = self.input.chars().count(); }
512 KeyCode::Left => {
513 if self.cursor_position > 0 { self.cursor_position -= 1; }
514 }
515 KeyCode::Right => {
516 let char_count = self.input.chars().count();
517 if self.cursor_position < char_count { self.cursor_position += 1; }
518 }
519 KeyCode::Char(c) => {
520 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
521 self.input.insert(byte_pos, c);
522 self.cursor_position += 1;
523 }
524 _ => {}
525 }
526 Ok(())
527 }
528
529 fn copy_output_to_clipboard(&mut self) {
531 let clean: String = self.output_lines.iter()
533 .map(|l| l.strip_prefix('\x01').unwrap_or(l).to_owned())
534 .collect::<Vec<String>>()
535 .join("\n");
536 self.copy_to_clipboard(clean);
537 }
538
539 fn copy_to_clipboard(&mut self, text: String) {
541 match arboard::Clipboard::new() {
542 Ok(mut clipboard) => match clipboard.set_text(&text) {
543 Ok(()) => {
544 self.output_lines.push("📋 Copied to clipboard".to_string());
545 }
546 Err(e) => {
547 self.output_lines.push(format!("✗ Clipboard error: {}", e));
548 }
549 },
550 Err(e) => {
551 self.output_lines.push(format!("✗ Clipboard unavailable: {}", e));
552 }
553 }
554 }
555
556 fn handle_workflow_message(&mut self, msg: TuiMessage) {
558 match msg {
559 TuiMessage::OutputLine(line) => {
560 self.output_lines.push(line);
561 }
562 TuiMessage::StreamDelta(delta) => {
563 if let Some(last) = self.output_lines.last_mut() {
566 last.push_str(&delta);
567 } else {
568 self.output_lines.push(delta);
569 }
570 }
571 TuiMessage::ReasoningDelta(delta) => {
572 if let Some(last) = self.output_lines.last_mut() {
575 if !last.starts_with('\x01') {
576 last.insert(0, '\x01');
578 }
579 last.push_str(&delta);
580 } else {
581 self.output_lines.push(format!("\x01{}", delta));
582 }
583 }
584 TuiMessage::WorkflowCompleted => {
585 self.output_lines.push("✓ Workflow completed".to_string());
586 self.output_lines.push("".to_string());
587 self.workflow_running = false;
588 }
589 TuiMessage::WorkflowError(err) => {
590 self.output_lines.push(format!("✗ Error: {}", err));
591 self.output_lines.push("".to_string());
592 self.workflow_running = false;
593 }
594 TuiMessage::TodoUpdate(content) => {
595 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
596 }
597 TuiMessage::ResumeInfo(info) => {
598 self.resume_info = info;
599 if self.resume_info.is_some() {
600 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
601 }
602 }
603 }
604 if self.auto_scroll {
606 self.scroll = u16::MAX;
607 }
608 }
609
610 fn execute_command(&mut self) {
615 let command = self.input.trim().to_string();
616
617 self.output_lines.clear();
619 self.scroll = 0;
620
621 self.output_lines.push(format!("> {}", command));
623
624
625 let config_toml = match &self.config_toml {
627 Some(c) => c.clone(),
628 None => {
629 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
630 self.output_lines.push("".to_string());
631 return;
632 }
633 };
634
635 let secrets = self.secrets.clone().unwrap_or_default();
636 let build_info = self.build_info.clone();
637 let tx = self.workflow_tx.clone();
638
639 let resume_info = self.resume_info.take();
641
642 self.workflow_running = true;
644 self.auto_scroll = true;
645
646 tokio::spawn(async move {
648 let tui_sink: abk::orchestration::output::SharedSink =
650 std::sync::Arc::new(TuiSink::new(tx.clone()));
651
652 abk::observability::set_tui_mode(true);
656
657 let result: abk::cli::TaskResult = abk::cli::run_task_from_raw_config(
658 &config_toml,
659 secrets,
660 build_info,
661 &command,
662 Some(tui_sink),
663 resume_info,
664 ).await.unwrap_or_else(|e| abk::cli::TaskResult {
665 success: false,
666 error: Some(e.to_string()),
667 resume_info: None,
668 });
669
670 abk::observability::set_tui_mode(false);
671
672 let msg = if result.success {
674 TuiMessage::WorkflowCompleted
675 } else {
676 TuiMessage::WorkflowError(result.error.unwrap_or_default())
677 };
678 tx.send(msg).ok();
679
680 tx.send(TuiMessage::ResumeInfo(result.resume_info)).ok();
682 });
683
684 self.input.clear();
686 self.cursor_position = 0;
687
688 self.scroll = u16::MAX;
690 }
691
692 pub fn render(&mut self, frame: &mut Frame) {
694 let main_chunks = Layout::default()
696 .direction(Direction::Vertical)
697 .margin(2)
698 .constraints([
699 Constraint::Min(0), Constraint::Length(7), ])
702 .split(frame.area());
703
704 self.input_rect = main_chunks[1];
706
707 let content_chunks = Layout::default()
709 .direction(Direction::Horizontal)
710 .constraints([
711 Constraint::Percentage(70), Constraint::Percentage(30), ])
714 .split(main_chunks[0]);
715
716 self.output_rect = content_chunks[0];
718 self.todo_rect = content_chunks[1];
719
720 let grey_style = Style::default().fg(Color::DarkGray);
725 let normal_style = Style::default();
726 let styled_lines: Vec<Line> = self.output_lines.iter().flat_map(|raw| {
727 let (style, text) = if let Some(stripped) = raw.strip_prefix('\x01') {
728 (grey_style, stripped)
729 } else {
730 (normal_style, raw.as_str())
731 };
732 text.split('\n').map(move |segment| {
735 Line::from(Span::styled(segment.to_string(), style))
736 }).collect::<Vec<_>>()
737 }).collect();
738
739 let display_text = Text::from(styled_lines);
740 let content_height = estimate_visual_lines(&display_text, content_chunks[0].width);
742 let viewport_height = content_chunks[0].height.saturating_sub(2) as usize;
743 let max_scroll = content_height.saturating_sub(viewport_height) as u16;
744 self.max_scroll_cache = max_scroll;
745 let clamped_scroll = if self.scroll == u16::MAX {
746 max_scroll
747 } else {
748 self.scroll.min(max_scroll)
749 };
750 let output_title = if self.auto_scroll {
751 "Output (↑/↓ to scroll)".to_string()
752 } else {
753 format!("Output (line {}/{} — ↓ to follow)", clamped_scroll, max_scroll)
754 };
755
756 let output_border = if self.focus == FocusPanel::Output {
757 Style::default().fg(Color::Cyan)
758 } else {
759 Style::default().fg(Color::DarkGray)
760 };
761 let output_paragraph = Paragraph::new(display_text)
762 .block(
763 Block::default()
764 .title(output_title)
765 .title_style(Style::default().add_modifier(Modifier::BOLD))
766 .borders(Borders::ALL)
767 .border_style(output_border),
768 )
769 .wrap(Wrap { trim: false })
770 .scroll((clamped_scroll, 0));
771 frame.render_widget(output_paragraph, content_chunks[0]);
772
773 let todo_title = format!("Todos ({})", self.todo_lines.len());
775 let todo_text = if self.todo_lines.is_empty() {
776 Text::from("No tasks")
777 } else {
778 Text::from(self.todo_lines.iter().map(|l| Line::from(l.as_str())).collect::<Vec<_>>())
779 };
780 let todo_content_height = estimate_visual_lines(&todo_text, content_chunks[1].width);
781 let todo_viewport = content_chunks[1].height.saturating_sub(2) as usize;
782 let todo_max = todo_content_height.saturating_sub(todo_viewport) as u16;
783 self.todo_max_scroll_cache = todo_max;
784 let todo_clamped = self.todo_scroll.min(todo_max);
785 let todo_border = if self.focus == FocusPanel::Todo {
786 Style::default().fg(Color::Yellow)
787 } else {
788 Style::default().fg(Color::DarkGray)
789 };
790 let todo_paragraph = Paragraph::new(todo_text)
791 .block(
792 Block::default()
793 .title(todo_title)
794 .title_style(Style::default().add_modifier(Modifier::BOLD))
795 .borders(Borders::ALL)
796 .border_style(todo_border),
797 )
798 .wrap(Wrap { trim: false })
799 .scroll((todo_clamped, 0));
800 frame.render_widget(todo_paragraph, content_chunks[1]);
801
802 let char_count = self.input.chars().count();
804 let cursor_style = if self.focus == FocusPanel::Input {
805 Style::default().fg(Color::Black).bg(Color::White)
806 } else {
807 Style::default().fg(Color::Black).bg(Color::DarkGray)
808 };
809 let input_spans = if self.cursor_position < char_count {
810 let before: String = self.input.chars().take(self.cursor_position).collect();
811 let at: String = self.input.chars().skip(self.cursor_position).take(1).collect();
812 let after: String = self.input.chars().skip(self.cursor_position + 1).collect();
813 vec![
814 Span::raw(before),
815 Span::styled(at, cursor_style),
816 Span::raw(after),
817 ]
818 } else {
819 vec![
821 Span::raw(self.input.clone()),
822 Span::styled(" ", cursor_style),
823 ]
824 };
825 let input_text = Text::from(Line::from(input_spans));
826
827 let input_title = if self.workflow_running {
829 "Input (Running...)".to_string()
830 } else {
831 "Input (Ready)".to_string()
832 };
833
834 let input_inner_width = main_chunks[1].width.saturating_sub(2).max(1) as usize;
836 self.input_inner_width_cache = input_inner_width;
837 let input_inner_height = main_chunks[1].height.saturating_sub(2) as usize;
838 let input_char_count = self.input.chars().count();
839 let input_total_visual = if input_inner_width > 0 {
840 ((input_char_count + input_inner_width - 1) / input_inner_width).max(1)
841 } else { 1 };
842 let input_max = input_total_visual.saturating_sub(input_inner_height) as u16;
843 self.input_max_scroll_cache = input_max;
844 let cursor_visual_line = if input_inner_width > 0 {
846 (self.cursor_position / input_inner_width) as u16
847 } else { 0 };
848 if cursor_visual_line < self.input_scroll {
849 self.input_scroll = cursor_visual_line;
850 } else if cursor_visual_line >= self.input_scroll + input_inner_height as u16 {
851 self.input_scroll = cursor_visual_line - input_inner_height as u16 + 1;
852 }
853 self.input_scroll = self.input_scroll.min(input_max);
854 let input_border = if self.focus == FocusPanel::Input {
855 Style::default().fg(Color::Green)
856 } else {
857 Style::default().fg(Color::DarkGray)
858 };
859 let input_paragraph = Paragraph::new(input_text)
860 .block(
861 Block::default()
862 .title(input_title)
863 .title_style(Style::default().add_modifier(Modifier::BOLD))
864 .borders(Borders::ALL)
865 .border_style(input_border),
866 )
867 .style(Style::default().fg(Color::White))
868 .wrap(Wrap { trim: false })
869 .scroll((self.input_scroll, 0));
870 frame.render_widget(input_paragraph, main_chunks[1]);
871
872 if self.mouse_passthrough {
874 let banner = Paragraph::new(Span::styled(
875 "📋 Mouse passthrough — select text, press any key to return",
876 Style::default().fg(Color::Yellow).bg(Color::DarkGray),
877 ));
878 let banner_area = Rect {
879 x: frame.area().x,
880 y: frame.area().y + frame.area().height.saturating_sub(1),
881 width: frame.area().width,
882 height: 1,
883 };
884 frame.render_widget(banner, banner_area);
885 }
886 }
887}
888
889impl Default for App {
890 fn default() -> Self {
891 Self::new()
892 }
893}