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}
131
132impl App {
133    /// Create a new App instance
134    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    /// Run the main event loop (async version)
174    /// 
175    /// Task 52: Converted from synchronous to async to enable:
176    /// - Running async ABK workflows concurrently with TUI
177    /// - Using tokio::select! for responsive event handling
178    /// - Non-blocking terminal event polling
179    pub async fn run(&mut self) -> Result<()> {
180        // Setup terminal
181        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        // Main async loop with tokio::select!
188        loop {
189            // Draw the UI
190            terminal.draw(|f| self.render(f))?;
191
192            // Use tokio::select! to handle both terminal events and workflow messages
193            tokio::select! {
194                // Handle terminal events (non-blocking poll)
195                result = Self::poll_event() => {
196                    if let Some(event) = result? {
197                        self.handle_event(event)?;
198                    }
199                }
200
201                // Handle messages from async workflows
202                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        // Restore terminal
215        disable_raw_mode()?;
216        execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableBracketedPaste, DisableMouseCapture)?;
217        terminal.show_cursor()?;
218
219        Ok(())
220    }
221
222    /// Poll for terminal events asynchronously
223    /// Uses tokio::task::spawn_blocking to avoid blocking the async runtime
224    /// with synchronous crossterm event polling
225    async fn poll_event() -> Result<Option<Event>> {
226        // Spawn a blocking task to poll for events
227        // This prevents the synchronous event::poll from blocking the Tokio runtime
228        tokio::task::spawn_blocking(|| {
229            // Poll with a short timeout to remain responsive
230            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    /// Handle a terminal event
240    fn handle_event(&mut self, event: Event) -> Result<()> {
241        // Handle bracketed paste: pasted text arrives as a single event,
242        // newlines are replaced with spaces to prevent auto-submit.
243        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        // Handle mouse events: click to focus, scroll wheel to scroll panel
254        if let Event::Mouse(mouse) = event {
255            let col = mouse.column;
256            let row = mouse.row;
257            match mouse.kind {
258                MouseEventKind::Down(_) => {
259                    // Click sets focus to the panel under the cursor
260                    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            // Global keys — work regardless of focus
304            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                // Tab cycles focus: Input → Output → Todo → Input
314                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                // Shift+Tab cycles backwards: Input → Todo → Output → Input
323                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            // Focus-specific key handling
335            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    /// Keys when Output panel is focused: scroll output
345    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            // Typing while output focused → switch to input and type there
386            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    /// Keys when Todo panel is focused: scroll todo list
404    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            // Typing while todo focused → switch to input
423            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    /// Keys when Input panel is focused: edit text + scroll input
441    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                // Move cursor up by one visual line width
464                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    /// Handle messages from async workflows
503    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                // Append streaming delta to the last line (print-style)
510                // instead of creating a new line (println-style).
511                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                // Same as StreamDelta but prefix with \x01 marker for grey rendering.
519                // The marker is stripped during render and the line is styled grey.
520                if let Some(last) = self.output_lines.last_mut() {
521                    if !last.starts_with('\x01') {
522                        // First reasoning on this line — mark it
523                        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        // Auto-scroll to bottom when enabled
551        if self.auto_scroll {
552            self.scroll = u16::MAX;
553        }
554    }
555
556    /// Execute the current command in the input buffer
557    /// 
558    /// Task 50: Wired to ABK's run_task_from_raw_config
559    /// Task 55: Creates TuiSink to bridge OutputEvent → TuiMessage channel
560    fn execute_command(&mut self) {
561        let command = self.input.trim().to_string();
562        
563        // Clear welcome text and start fresh for this task
564        self.output_lines.clear();
565        self.scroll = 0;
566        
567        // Add command to output
568        self.output_lines.push(format!("> {}", command));
569
570        
571        // Check if config is available
572        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        // Take resume_info (one-time use — consumed on next command)
586        let resume_info = self.resume_info.take();
587        
588        // Mark workflow as running, re-enable auto-scroll
589        self.workflow_running = true;
590        self.auto_scroll = true;
591        
592        // Spawn the workflow with TuiSink-based output
593        tokio::spawn(async move {
594            // Create TuiSink that bridges OutputEvent → TuiMessage channel.
595            let tui_sink: abk::orchestration::output::SharedSink =
596                std::sync::Arc::new(TuiSink::new(tx.clone()));
597
598            // Run ABK workflow with the task — bypasses CLI arg parsing.
599            // TUI mode is enabled to suppress ABK's console output (stdout/stderr).
600            // Output events flow through TuiSink directly to the TUI display.
601            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            // Send completion message
619            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            // Send resume info back for storage in App
627            tx.send(TuiMessage::ResumeInfo(result.resume_info)).ok();
628        });
629        
630        // Clear input buffer and reset cursor
631        self.input.clear();
632        self.cursor_position = 0;
633        
634        // Auto-scroll to bottom
635        self.scroll = u16::MAX;
636    }
637
638    /// Render the TUI
639    pub fn render(&mut self, frame: &mut Frame) {
640        // Create main layout: output takes remaining space, input gets fixed height
641        let main_chunks = Layout::default()
642            .direction(Direction::Vertical)
643            .margin(2)
644            .constraints([
645                Constraint::Min(0),    // Output + Todo area - all remaining space
646                Constraint::Length(7), // Input area - fixed 7 rows (5 content + 2 borders)
647            ])
648            .split(frame.area());
649
650        // Cache rects for mouse hit-testing
651        self.input_rect = main_chunks[1];
652
653        // Split output area horizontally: 70% output, 30% todo panel
654        let content_chunks = Layout::default()
655            .direction(Direction::Horizontal)
656            .constraints([
657                Constraint::Percentage(70), // Main output
658                Constraint::Percentage(30), // Todo panel
659            ])
660            .split(main_chunks[0]);
661
662        // Cache rects for mouse hit-testing
663        self.output_rect = content_chunks[0];
664        self.todo_rect = content_chunks[1];
665
666        // Output area title shows scroll mode
667
668        // Render output area with scrollable content.
669        // Lines prefixed with \x01 are reasoning lines and rendered in dark grey.
670        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            // A single output_line may contain embedded newlines (e.g. tool output).
679            // Split them so ratatui wraps correctly.
680            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        // Use wrapped visual line count for scroll clamping (not raw line count).
687        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        // Render todo panel on the right side
720        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        // Render input text with a visible block cursor (reversed colors).
749        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            // Cursor at end — show a block space as the cursor
766            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        // Show status in input title
774        let input_title = if self.workflow_running {
775            "Input (Running...)".to_string()
776        } else {
777            "Input (Ready)".to_string()
778        };
779
780        // Compute input scroll: auto-follow cursor, but allow manual override
781        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        // Auto-scroll to keep cursor visible
791        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}