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}
131
132impl App {
133 pub fn new() -> Self {
135 let (workflow_tx, workflow_rx) = mpsc::unbounded_channel();
136 Self {
137 input: String::new(),
138 cursor_position: 0,
139 output_lines: vec![
140 "Welcome to Trustee TUI".to_string(),
141 "Type a task and press Enter to execute".to_string(),
142 "Press Ctrl+C to exit".to_string(),
143 "".to_string(),
144 "Keyboard shortcuts:".to_string(),
145 " ↑/↓ or Page Up/Down - Scroll output".to_string(),
146 " Enter - Execute task".to_string(),
147 " Esc or Ctrl+C - Exit".to_string(),
148 ],
149 scroll: 0,
150 auto_scroll: true,
151 max_scroll_cache: 0,
152 focus: FocusPanel::Input,
153 todo_scroll: 0,
154 todo_max_scroll_cache: 0,
155 input_scroll: 0,
156 input_max_scroll_cache: 0,
157 should_quit: false,
158 workflow_rx,
159 workflow_tx,
160 workflow_running: false,
161 config_toml: None,
162 secrets: None,
163 build_info: None,
164 resume_info: None,
165 todo_lines: Vec::new(),
166 input_inner_width_cache: 80,
167 output_rect: Rect::default(),
168 todo_rect: Rect::default(),
169 input_rect: Rect::default(),
170 }
171 }
172
173 pub async fn run(&mut self) -> Result<()> {
180 enable_raw_mode()?;
182 let mut stdout = io::stdout();
183 execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?;
184 let backend = CrosstermBackend::new(stdout);
185 let mut terminal = Terminal::new(backend)?;
186
187 loop {
189 terminal.draw(|f| self.render(f))?;
191
192 tokio::select! {
194 result = Self::poll_event() => {
196 if let Some(event) = result? {
197 self.handle_event(event)?;
198 }
199 }
200
201 msg = self.workflow_rx.recv() => {
203 if let Some(msg) = msg {
204 self.handle_workflow_message(msg);
205 }
206 }
207 }
208
209 if self.should_quit {
210 break;
211 }
212 }
213
214 disable_raw_mode()?;
216 execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableBracketedPaste, DisableMouseCapture)?;
217 terminal.show_cursor()?;
218
219 Ok(())
220 }
221
222 async fn poll_event() -> Result<Option<Event>> {
226 tokio::task::spawn_blocking(|| {
229 if event::poll(std::time::Duration::from_millis(50))? {
231 Ok(Some(event::read()?))
232 } else {
233 Ok(None)
234 }
235 })
236 .await?
237 }
238
239 fn handle_event(&mut self, event: Event) -> Result<()> {
241 if let Event::Paste(text) = event {
244 let sanitized = text.replace('\n', " ").replace('\r', "");
245 for c in sanitized.chars() {
246 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
247 self.input.insert(byte_pos, c);
248 self.cursor_position += 1;
249 }
250 return Ok(());
251 }
252
253 if let Event::Mouse(mouse) = event {
255 let col = mouse.column;
256 let row = mouse.row;
257 match mouse.kind {
258 MouseEventKind::Down(_) => {
259 if self.output_rect.contains((col, row).into()) {
261 self.focus = FocusPanel::Output;
262 } else if self.todo_rect.contains((col, row).into()) {
263 self.focus = FocusPanel::Todo;
264 } else if self.input_rect.contains((col, row).into()) {
265 self.focus = FocusPanel::Input;
266 }
267 }
268 MouseEventKind::ScrollUp => {
269 if self.output_rect.contains((col, row).into()) {
270 self.auto_scroll = false;
271 if self.scroll == u16::MAX {
272 self.scroll = self.max_scroll_cache;
273 }
274 self.scroll = self.scroll.saturating_sub(3);
275 } else if self.todo_rect.contains((col, row).into()) {
276 self.todo_scroll = self.todo_scroll.saturating_sub(3);
277 } else if self.input_rect.contains((col, row).into()) {
278 self.input_scroll = self.input_scroll.saturating_sub(1);
279 }
280 }
281 MouseEventKind::ScrollDown => {
282 if self.output_rect.contains((col, row).into()) {
283 if self.scroll == u16::MAX { return Ok(()); }
284 self.scroll = self.scroll.saturating_add(3);
285 if self.scroll >= self.max_scroll_cache {
286 self.auto_scroll = true;
287 self.scroll = u16::MAX;
288 }
289 } else if self.todo_rect.contains((col, row).into()) {
290 self.todo_scroll = self.todo_scroll.saturating_add(3)
291 .min(self.todo_max_scroll_cache);
292 } else if self.input_rect.contains((col, row).into()) {
293 self.input_scroll = self.input_scroll.saturating_add(1)
294 .min(self.input_max_scroll_cache);
295 }
296 }
297 _ => {}
298 }
299 return Ok(());
300 }
301
302 if let Event::Key(key) = event {
303 match key.code {
305 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
306 self.should_quit = true;
307 return Ok(());
308 }
309 KeyCode::Esc => {
310 self.should_quit = true;
311 return Ok(());
312 }
313 KeyCode::Tab => {
315 self.focus = match self.focus {
316 FocusPanel::Input => FocusPanel::Output,
317 FocusPanel::Output => FocusPanel::Todo,
318 FocusPanel::Todo => FocusPanel::Input,
319 };
320 return Ok(());
321 }
322 KeyCode::BackTab => {
324 self.focus = match self.focus {
325 FocusPanel::Input => FocusPanel::Todo,
326 FocusPanel::Todo => FocusPanel::Output,
327 FocusPanel::Output => FocusPanel::Input,
328 };
329 return Ok(());
330 }
331 _ => {}
332 }
333
334 match self.focus {
336 FocusPanel::Output => self.handle_output_keys(key.code)?,
337 FocusPanel::Todo => self.handle_todo_keys(key.code)?,
338 FocusPanel::Input => self.handle_input_keys(key.code)?,
339 }
340 }
341 Ok(())
342 }
343
344 fn handle_output_keys(&mut self, code: KeyCode) -> Result<()> {
346 match code {
347 KeyCode::Up => {
348 self.auto_scroll = false;
349 if self.scroll == u16::MAX {
350 self.scroll = self.max_scroll_cache;
351 }
352 self.scroll = self.scroll.saturating_sub(1);
353 }
354 KeyCode::Down => {
355 if self.scroll == u16::MAX { return Ok(()); }
356 self.scroll = self.scroll.saturating_add(1);
357 if self.scroll >= self.max_scroll_cache {
358 self.auto_scroll = true;
359 self.scroll = u16::MAX;
360 }
361 }
362 KeyCode::PageUp => {
363 self.auto_scroll = false;
364 if self.scroll == u16::MAX {
365 self.scroll = self.max_scroll_cache;
366 }
367 self.scroll = self.scroll.saturating_sub(10);
368 }
369 KeyCode::PageDown => {
370 if self.scroll == u16::MAX { return Ok(()); }
371 self.scroll = self.scroll.saturating_add(10);
372 if self.scroll >= self.max_scroll_cache {
373 self.auto_scroll = true;
374 self.scroll = u16::MAX;
375 }
376 }
377 KeyCode::Home => {
378 self.auto_scroll = false;
379 self.scroll = 0;
380 }
381 KeyCode::End => {
382 self.auto_scroll = true;
383 self.scroll = u16::MAX;
384 }
385 KeyCode::Char(c) => {
387 self.focus = FocusPanel::Input;
388 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
389 self.input.insert(byte_pos, c);
390 self.cursor_position += 1;
391 }
392 KeyCode::Enter => {
393 if !self.input.is_empty() && !self.workflow_running {
394 self.focus = FocusPanel::Input;
395 self.execute_command();
396 }
397 }
398 _ => {}
399 }
400 Ok(())
401 }
402
403 fn handle_todo_keys(&mut self, code: KeyCode) -> Result<()> {
405 match code {
406 KeyCode::Up => {
407 self.todo_scroll = self.todo_scroll.saturating_sub(1);
408 }
409 KeyCode::Down => {
410 self.todo_scroll = self.todo_scroll.saturating_add(1)
411 .min(self.todo_max_scroll_cache);
412 }
413 KeyCode::PageUp => {
414 self.todo_scroll = self.todo_scroll.saturating_sub(10);
415 }
416 KeyCode::PageDown => {
417 self.todo_scroll = self.todo_scroll.saturating_add(10)
418 .min(self.todo_max_scroll_cache);
419 }
420 KeyCode::Home => { self.todo_scroll = 0; }
421 KeyCode::End => { self.todo_scroll = self.todo_max_scroll_cache; }
422 KeyCode::Char(c) => {
424 self.focus = FocusPanel::Input;
425 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
426 self.input.insert(byte_pos, c);
427 self.cursor_position += 1;
428 }
429 KeyCode::Enter => {
430 if !self.input.is_empty() && !self.workflow_running {
431 self.focus = FocusPanel::Input;
432 self.execute_command();
433 }
434 }
435 _ => {}
436 }
437 Ok(())
438 }
439
440 fn handle_input_keys(&mut self, code: KeyCode) -> Result<()> {
442 match code {
443 KeyCode::Enter => {
444 if !self.input.is_empty() && !self.workflow_running {
445 self.execute_command();
446 }
447 }
448 KeyCode::Backspace => {
449 if self.cursor_position > 0 {
450 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position - 1);
451 self.input.remove(byte_pos);
452 self.cursor_position -= 1;
453 }
454 }
455 KeyCode::Delete => {
456 let char_count = self.input.chars().count();
457 if self.cursor_position < char_count {
458 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
459 self.input.remove(byte_pos);
460 }
461 }
462 KeyCode::Up => {
463 let w = self.input_inner_width_cache.max(1);
465 if self.cursor_position >= w {
466 self.cursor_position -= w;
467 } else {
468 self.cursor_position = 0;
469 }
470 }
471 KeyCode::Down => {
472 let w = self.input_inner_width_cache.max(1);
473 let char_count = self.input.chars().count();
474 self.cursor_position = (self.cursor_position + w).min(char_count);
475 }
476 KeyCode::PageUp => {
477 self.input_scroll = self.input_scroll.saturating_sub(3);
478 }
479 KeyCode::PageDown => {
480 self.input_scroll = self.input_scroll.saturating_add(3)
481 .min(self.input_max_scroll_cache);
482 }
483 KeyCode::Home => { self.cursor_position = 0; }
484 KeyCode::End => { self.cursor_position = self.input.chars().count(); }
485 KeyCode::Left => {
486 if self.cursor_position > 0 { self.cursor_position -= 1; }
487 }
488 KeyCode::Right => {
489 let char_count = self.input.chars().count();
490 if self.cursor_position < char_count { self.cursor_position += 1; }
491 }
492 KeyCode::Char(c) => {
493 let byte_pos = char_to_byte_offset(&self.input, self.cursor_position);
494 self.input.insert(byte_pos, c);
495 self.cursor_position += 1;
496 }
497 _ => {}
498 }
499 Ok(())
500 }
501
502 fn handle_workflow_message(&mut self, msg: TuiMessage) {
504 match msg {
505 TuiMessage::OutputLine(line) => {
506 self.output_lines.push(line);
507 }
508 TuiMessage::StreamDelta(delta) => {
509 if let Some(last) = self.output_lines.last_mut() {
512 last.push_str(&delta);
513 } else {
514 self.output_lines.push(delta);
515 }
516 }
517 TuiMessage::ReasoningDelta(delta) => {
518 if let Some(last) = self.output_lines.last_mut() {
521 if !last.starts_with('\x01') {
522 last.insert(0, '\x01');
524 }
525 last.push_str(&delta);
526 } else {
527 self.output_lines.push(format!("\x01{}", delta));
528 }
529 }
530 TuiMessage::WorkflowCompleted => {
531 self.output_lines.push("✓ Workflow completed".to_string());
532 self.output_lines.push("".to_string());
533 self.workflow_running = false;
534 }
535 TuiMessage::WorkflowError(err) => {
536 self.output_lines.push(format!("✗ Error: {}", err));
537 self.output_lines.push("".to_string());
538 self.workflow_running = false;
539 }
540 TuiMessage::TodoUpdate(content) => {
541 self.todo_lines = content.lines().map(|l| l.to_string()).collect();
542 }
543 TuiMessage::ResumeInfo(info) => {
544 self.resume_info = info;
545 if self.resume_info.is_some() {
546 self.output_lines.push("🔄 Session preserved — next command will continue this session".to_string());
547 }
548 }
549 }
550 if self.auto_scroll {
552 self.scroll = u16::MAX;
553 }
554 }
555
556 fn execute_command(&mut self) {
561 let command = self.input.trim().to_string();
562
563 self.output_lines.clear();
565 self.scroll = 0;
566
567 self.output_lines.push(format!("> {}", command));
569
570
571 let config_toml = match &self.config_toml {
573 Some(c) => c.clone(),
574 None => {
575 self.output_lines.push("✗ Error: Configuration not loaded".to_string());
576 self.output_lines.push("".to_string());
577 return;
578 }
579 };
580
581 let secrets = self.secrets.clone().unwrap_or_default();
582 let build_info = self.build_info.clone();
583 let tx = self.workflow_tx.clone();
584
585 let resume_info = self.resume_info.take();
587
588 self.workflow_running = true;
590 self.auto_scroll = true;
591
592 tokio::spawn(async move {
594 let tui_sink: abk::orchestration::output::SharedSink =
596 std::sync::Arc::new(TuiSink::new(tx.clone()));
597
598 abk::observability::set_tui_mode(true);
602
603 let result: abk::cli::TaskResult = abk::cli::run_task_from_raw_config(
604 &config_toml,
605 secrets,
606 build_info,
607 &command,
608 Some(tui_sink),
609 resume_info,
610 ).await.unwrap_or_else(|e| abk::cli::TaskResult {
611 success: false,
612 error: Some(e.to_string()),
613 resume_info: None,
614 });
615
616 abk::observability::set_tui_mode(false);
617
618 let msg = if result.success {
620 TuiMessage::WorkflowCompleted
621 } else {
622 TuiMessage::WorkflowError(result.error.unwrap_or_default())
623 };
624 tx.send(msg).ok();
625
626 tx.send(TuiMessage::ResumeInfo(result.resume_info)).ok();
628 });
629
630 self.input.clear();
632 self.cursor_position = 0;
633
634 self.scroll = u16::MAX;
636 }
637
638 pub fn render(&mut self, frame: &mut Frame) {
640 let main_chunks = Layout::default()
642 .direction(Direction::Vertical)
643 .margin(2)
644 .constraints([
645 Constraint::Min(0), Constraint::Length(7), ])
648 .split(frame.area());
649
650 self.input_rect = main_chunks[1];
652
653 let content_chunks = Layout::default()
655 .direction(Direction::Horizontal)
656 .constraints([
657 Constraint::Percentage(70), Constraint::Percentage(30), ])
660 .split(main_chunks[0]);
661
662 self.output_rect = content_chunks[0];
664 self.todo_rect = content_chunks[1];
665
666 let grey_style = Style::default().fg(Color::DarkGray);
671 let normal_style = Style::default();
672 let styled_lines: Vec<Line> = self.output_lines.iter().flat_map(|raw| {
673 let (style, text) = if let Some(stripped) = raw.strip_prefix('\x01') {
674 (grey_style, stripped)
675 } else {
676 (normal_style, raw.as_str())
677 };
678 text.split('\n').map(move |segment| {
681 Line::from(Span::styled(segment.to_string(), style))
682 }).collect::<Vec<_>>()
683 }).collect();
684
685 let display_text = Text::from(styled_lines);
686 let content_height = estimate_visual_lines(&display_text, content_chunks[0].width);
688 let viewport_height = content_chunks[0].height.saturating_sub(2) as usize;
689 let max_scroll = content_height.saturating_sub(viewport_height) as u16;
690 self.max_scroll_cache = max_scroll;
691 let clamped_scroll = if self.scroll == u16::MAX {
692 max_scroll
693 } else {
694 self.scroll.min(max_scroll)
695 };
696 let output_title = if self.auto_scroll {
697 "Output (↑/↓ to scroll)".to_string()
698 } else {
699 format!("Output (line {}/{} — ↓ to follow)", clamped_scroll, max_scroll)
700 };
701
702 let output_border = if self.focus == FocusPanel::Output {
703 Style::default().fg(Color::Cyan)
704 } else {
705 Style::default().fg(Color::DarkGray)
706 };
707 let output_paragraph = Paragraph::new(display_text)
708 .block(
709 Block::default()
710 .title(output_title)
711 .title_style(Style::default().add_modifier(Modifier::BOLD))
712 .borders(Borders::ALL)
713 .border_style(output_border),
714 )
715 .wrap(Wrap { trim: false })
716 .scroll((clamped_scroll, 0));
717 frame.render_widget(output_paragraph, content_chunks[0]);
718
719 let todo_title = format!("Todos ({})", self.todo_lines.len());
721 let todo_text = if self.todo_lines.is_empty() {
722 Text::from("No tasks")
723 } else {
724 Text::from(self.todo_lines.iter().map(|l| Line::from(l.as_str())).collect::<Vec<_>>())
725 };
726 let todo_content_height = estimate_visual_lines(&todo_text, content_chunks[1].width);
727 let todo_viewport = content_chunks[1].height.saturating_sub(2) as usize;
728 let todo_max = todo_content_height.saturating_sub(todo_viewport) as u16;
729 self.todo_max_scroll_cache = todo_max;
730 let todo_clamped = self.todo_scroll.min(todo_max);
731 let todo_border = if self.focus == FocusPanel::Todo {
732 Style::default().fg(Color::Yellow)
733 } else {
734 Style::default().fg(Color::DarkGray)
735 };
736 let todo_paragraph = Paragraph::new(todo_text)
737 .block(
738 Block::default()
739 .title(todo_title)
740 .title_style(Style::default().add_modifier(Modifier::BOLD))
741 .borders(Borders::ALL)
742 .border_style(todo_border),
743 )
744 .wrap(Wrap { trim: false })
745 .scroll((todo_clamped, 0));
746 frame.render_widget(todo_paragraph, content_chunks[1]);
747
748 let char_count = self.input.chars().count();
750 let cursor_style = if self.focus == FocusPanel::Input {
751 Style::default().fg(Color::Black).bg(Color::White)
752 } else {
753 Style::default().fg(Color::Black).bg(Color::DarkGray)
754 };
755 let input_spans = if self.cursor_position < char_count {
756 let before: String = self.input.chars().take(self.cursor_position).collect();
757 let at: String = self.input.chars().skip(self.cursor_position).take(1).collect();
758 let after: String = self.input.chars().skip(self.cursor_position + 1).collect();
759 vec![
760 Span::raw(before),
761 Span::styled(at, cursor_style),
762 Span::raw(after),
763 ]
764 } else {
765 vec![
767 Span::raw(self.input.clone()),
768 Span::styled(" ", cursor_style),
769 ]
770 };
771 let input_text = Text::from(Line::from(input_spans));
772
773 let input_title = if self.workflow_running {
775 "Input (Running...)".to_string()
776 } else {
777 "Input (Ready)".to_string()
778 };
779
780 let input_inner_width = main_chunks[1].width.saturating_sub(2).max(1) as usize;
782 self.input_inner_width_cache = input_inner_width;
783 let input_inner_height = main_chunks[1].height.saturating_sub(2) as usize;
784 let input_char_count = self.input.chars().count();
785 let input_total_visual = if input_inner_width > 0 {
786 ((input_char_count + input_inner_width - 1) / input_inner_width).max(1)
787 } else { 1 };
788 let input_max = input_total_visual.saturating_sub(input_inner_height) as u16;
789 self.input_max_scroll_cache = input_max;
790 let cursor_visual_line = if input_inner_width > 0 {
792 (self.cursor_position / input_inner_width) as u16
793 } else { 0 };
794 if cursor_visual_line < self.input_scroll {
795 self.input_scroll = cursor_visual_line;
796 } else if cursor_visual_line >= self.input_scroll + input_inner_height as u16 {
797 self.input_scroll = cursor_visual_line - input_inner_height as u16 + 1;
798 }
799 self.input_scroll = self.input_scroll.min(input_max);
800 let input_border = if self.focus == FocusPanel::Input {
801 Style::default().fg(Color::Green)
802 } else {
803 Style::default().fg(Color::DarkGray)
804 };
805 let input_paragraph = Paragraph::new(input_text)
806 .block(
807 Block::default()
808 .title(input_title)
809 .title_style(Style::default().add_modifier(Modifier::BOLD))
810 .borders(Borders::ALL)
811 .border_style(input_border),
812 )
813 .style(Style::default().fg(Color::White))
814 .wrap(Wrap { trim: false })
815 .scroll((self.input_scroll, 0));
816 frame.render_widget(input_paragraph, main_chunks[1]);
817 }
818}
819
820impl Default for App {
821 fn default() -> Self {
822 Self::new()
823 }
824}