Skip to main content

trustee_tui/
app.rs

1//! TUI Application structure and main loop
2//!
3//! Task 52: Async TUI Loop
4//! Converted from synchronous to async to allow concurrent workflow execution
5//! with the TUI event loop using tokio::select!
6
7use 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/// Which panel currently has keyboard focus.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FocusPanel {
31    Output,
32    Todo,
33    Input,
34}
35
36/// Messages that can be sent to the TUI from async workflows
37#[derive(Debug, Clone)]
38pub enum TuiMessage {
39    /// A line of output to display
40    OutputLine(String),
41    /// A streaming delta to append to the last line (print-style, not println)
42    StreamDelta(String),
43    /// A reasoning delta to append to the last line (displayed in grey)
44    ReasoningDelta(String),
45    /// Workflow completed
46    WorkflowCompleted,
47    /// Workflow error
48    WorkflowError(String),
49    /// Resume info from the completed workflow for session continuity
50    ResumeInfo(Option<ResumeInfo>),
51    /// Todo list update from LLM todowrite tool
52    TodoUpdate(String),
53}
54
55/// Build information for ABK (forward declaration)
56pub type BuildInfo = abk::cli::BuildInfo;
57
58/// Convert a char index to a byte offset in a string.
59/// Panics if `char_idx` > number of chars in `s`.
60fn 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
67/// Estimate the number of visual (wrapped) lines a Text will occupy.
68/// Adds +1 buffer because ratatui word-wraps which can produce more lines
69/// than a simple character-division estimate.
70fn 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    // Add 1 to compensate for word-wrap producing extra lines vs char-division
79    raw + 1
80}
81
82/// Main application state for the TUI
83pub struct App {
84    /// Input buffer for user commands
85    pub input: String,
86    /// Cursor position in input buffer (char index, not byte offset)
87    pub cursor_position: usize,
88    /// Output log lines
89    pub output_lines: Vec<String>,
90    /// Scroll position in output (vertical). u16::MAX = auto-follow bottom.
91    pub scroll: u16,
92    /// Whether auto-scroll is enabled (follows new output)
93    pub auto_scroll: bool,
94    /// Cached max scroll value from last render (for keyboard navigation)
95    max_scroll_cache: u16,
96    /// Which panel has keyboard focus (Tab cycles)
97    pub focus: FocusPanel,
98    /// Scroll position in todo panel
99    pub todo_scroll: u16,
100    /// Cached max scroll for todo panel
101    todo_max_scroll_cache: u16,
102    /// Manual scroll offset for input box (user-driven)
103    pub input_scroll: u16,
104    /// Cached max scroll for input box
105    input_max_scroll_cache: u16,
106    /// Whether the app should quit
107    pub should_quit: bool,
108    /// Receiver for messages from async workflows
109    pub workflow_rx: mpsc::UnboundedReceiver<TuiMessage>,
110    /// Sender for messages from async workflows (clone and pass to workflow runners)
111    pub workflow_tx: mpsc::UnboundedSender<TuiMessage>,
112    /// Whether a workflow is currently running
113    pub workflow_running: bool,
114    /// Configuration TOML for ABK workflows (Task 50)
115    pub config_toml: Option<String>,
116    /// Secrets for ABK workflows (Task 50)
117    pub secrets: Option<std::collections::HashMap<String, String>>,
118    /// Build info for ABK workflows (Task 50)
119    pub build_info: Option<BuildInfo>,
120    /// Resume info from the last completed task for session continuity
121    pub resume_info: Option<ResumeInfo>,
122    /// Latest todo list from LLM todowrite tool
123    pub todo_lines: Vec<String>,
124    /// Cached inner width of input box (characters per visual line)
125    input_inner_width_cache: usize,
126    /// Cached panel rectangles for mouse hit-testing (set during render)
127    output_rect: Rect,
128    todo_rect: Rect,
129    input_rect: Rect,
130    /// Whether mouse events are passed through to terminal (for native text selection)
131    mouse_passthrough: bool,
132}
133
134impl App {
135    /// Create a new App instance
136    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    /// Run the main event loop (async version)
179    /// 
180    /// Task 52: Converted from synchronous to async to enable:
181    /// - Running async ABK workflows concurrently with TUI
182    /// - Using tokio::select! for responsive event handling
183    /// - Non-blocking terminal event polling
184    pub async fn run(&mut self) -> Result<()> {
185        // Setup terminal
186        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        // Main async loop with tokio::select!
193        loop {
194            // Draw the UI
195            terminal.draw(|f| self.render(f))?;
196
197            // Use tokio::select! to handle both terminal events and workflow messages
198            tokio::select! {
199                // Handle terminal events (non-blocking poll)
200                result = Self::poll_event() => {
201                    if let Some(event) = result? {
202                        self.handle_event(event)?;
203                    }
204                }
205
206                // Handle messages from async workflows
207                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        // Restore terminal
220        disable_raw_mode()?;
221        execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableBracketedPaste, DisableMouseCapture)?;
222        terminal.show_cursor()?;
223
224        Ok(())
225    }
226
227    /// Poll for terminal events asynchronously
228    /// Uses tokio::task::spawn_blocking to avoid blocking the async runtime
229    /// with synchronous crossterm event polling
230    async fn poll_event() -> Result<Option<Event>> {
231        // Spawn a blocking task to poll for events
232        // This prevents the synchronous event::poll from blocking the Tokio runtime
233        tokio::task::spawn_blocking(|| {
234            // Poll with a short timeout to remain responsive
235            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    /// Handle a terminal event
245    fn handle_event(&mut self, event: Event) -> Result<()> {
246        // Handle bracketed paste: pasted text arrives as a single event,
247        // newlines are replaced with spaces to prevent auto-submit.
248        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        // Handle mouse events: click to focus, scroll wheel to scroll panel
259        if let Event::Mouse(mouse) = event {
260            // In passthrough mode, ignore all mouse events (terminal handles them)
261            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                    // Click sets focus to the panel under the cursor
269                    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            // Exit passthrough mode on any keypress — re-enable mouse capture
313            if self.mouse_passthrough {
314                execute!(std::io::stdout(), EnableMouseCapture).ok();
315                self.mouse_passthrough = false;
316                return Ok(());
317            }
318
319            // Global keys — work regardless of focus
320            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                // Tab cycles focus: Input → Output → Todo → Input
330                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                // Shift+Tab cycles backwards: Input → Todo → Output → Input
339                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                // Ctrl+O: toggle mouse passthrough for native text selection
348                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            // Focus-specific key handling
357            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    /// Keys when Output panel is focused: scroll output
367    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            // Typing while output focused → switch to input and type there
417            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    /// Keys when Todo panel is focused: scroll todo list
429    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            // y = copy todo text to clipboard (must be before the generic Char catch-all)
448            KeyCode::Char('y') => self.copy_to_clipboard(self.todo_lines.join("\n")),
449            // Typing while todo focused → switch to input
450            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    /// Keys when Input panel is focused: edit text + scroll input
468    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                // Move cursor up by one visual line width
491                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    /// Copy output panel text to the system clipboard.
530    fn copy_output_to_clipboard(&mut self) {
531        // Strip the \x01 reasoning marker from each line before copying
532        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    /// Copy a string to the system clipboard and show brief feedback.
540    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    /// Handle messages from async workflows
557    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                // Append streaming delta to the last line (print-style)
564                // instead of creating a new line (println-style).
565                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                // Same as StreamDelta but prefix with \x01 marker for grey rendering.
573                // The marker is stripped during render and the line is styled grey.
574                if let Some(last) = self.output_lines.last_mut() {
575                    if !last.starts_with('\x01') {
576                        // First reasoning on this line — mark it
577                        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        // Auto-scroll to bottom when enabled
605        if self.auto_scroll {
606            self.scroll = u16::MAX;
607        }
608    }
609
610    /// Execute the current command in the input buffer
611    /// 
612    /// Task 50: Wired to ABK's run_task_from_raw_config
613    /// Task 55: Creates TuiSink to bridge OutputEvent → TuiMessage channel
614    fn execute_command(&mut self) {
615        let command = self.input.trim().to_string();
616        
617        // Clear welcome text and start fresh for this task
618        self.output_lines.clear();
619        self.scroll = 0;
620        
621        // Add command to output
622        self.output_lines.push(format!("> {}", command));
623
624        
625        // Check if config is available
626        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        // Take resume_info (one-time use — consumed on next command)
640        let resume_info = self.resume_info.take();
641        
642        // Mark workflow as running, re-enable auto-scroll
643        self.workflow_running = true;
644        self.auto_scroll = true;
645        
646        // Spawn the workflow with TuiSink-based output
647        tokio::spawn(async move {
648            // Create TuiSink that bridges OutputEvent → TuiMessage channel.
649            let tui_sink: abk::orchestration::output::SharedSink =
650                std::sync::Arc::new(TuiSink::new(tx.clone()));
651
652            // Run ABK workflow with the task — bypasses CLI arg parsing.
653            // TUI mode is enabled to suppress ABK's console output (stdout/stderr).
654            // Output events flow through TuiSink directly to the TUI display.
655            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            // Send completion message
673            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            // Send resume info back for storage in App
681            tx.send(TuiMessage::ResumeInfo(result.resume_info)).ok();
682        });
683        
684        // Clear input buffer and reset cursor
685        self.input.clear();
686        self.cursor_position = 0;
687        
688        // Auto-scroll to bottom
689        self.scroll = u16::MAX;
690    }
691
692    /// Render the TUI
693    pub fn render(&mut self, frame: &mut Frame) {
694        // Create main layout: output takes remaining space, input gets fixed height
695        let main_chunks = Layout::default()
696            .direction(Direction::Vertical)
697            .margin(2)
698            .constraints([
699                Constraint::Min(0),    // Output + Todo area - all remaining space
700                Constraint::Length(7), // Input area - fixed 7 rows (5 content + 2 borders)
701            ])
702            .split(frame.area());
703
704        // Cache rects for mouse hit-testing
705        self.input_rect = main_chunks[1];
706
707        // Split output area horizontally: 70% output, 30% todo panel
708        let content_chunks = Layout::default()
709            .direction(Direction::Horizontal)
710            .constraints([
711                Constraint::Percentage(70), // Main output
712                Constraint::Percentage(30), // Todo panel
713            ])
714            .split(main_chunks[0]);
715
716        // Cache rects for mouse hit-testing
717        self.output_rect = content_chunks[0];
718        self.todo_rect = content_chunks[1];
719
720        // Output area title shows scroll mode
721
722        // Render output area with scrollable content.
723        // Lines prefixed with \x01 are reasoning lines and rendered in dark grey.
724        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            // A single output_line may contain embedded newlines (e.g. tool output).
733            // Split them so ratatui wraps correctly.
734            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        // Use wrapped visual line count for scroll clamping (not raw line count).
741        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        // Render todo panel on the right side
774        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        // Render input text with a visible block cursor (reversed colors).
803        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            // Cursor at end — show a block space as the cursor
820            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        // Show status in input title
828        let input_title = if self.workflow_running {
829            "Input (Running...)".to_string()
830        } else {
831            "Input (Ready)".to_string()
832        };
833
834        // Compute input scroll: auto-follow cursor, but allow manual override
835        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        // Auto-scroll to keep cursor visible
845        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        // Render mouse passthrough banner if active
873        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}