diff --git a/src/tui/app.rs b/src/tui/app.rs index c6f24fd..3abcb75 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -180,6 +180,74 @@ impl App { (KeyCode::Char('i'), KeyModifiers::NONE) if self.active_tab == ActiveTab::Planning => { self.planning.insert_mode = true; } + // Planning tab scrolling (not in insert mode) + (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) + if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => + { + self.planning.scroll_up(1); + } + (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) + if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => + { + self.planning.scroll_down(1); + } + (KeyCode::PageUp, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => + { + let page_size = self.planning.viewport_height.saturating_sub(1).max(1); + self.planning.scroll_up(page_size); + } + (KeyCode::PageDown, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => + { + let page_size = self.planning.viewport_height.saturating_sub(1).max(1); + self.planning.scroll_down(page_size); + } + (KeyCode::Home, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => + { + self.planning.scroll_offset = 0; + self.planning.auto_scroll = false; + } + (KeyCode::End, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => + { + self.planning.scroll_to_bottom(); + } + // Execution tab scrolling + (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) + if self.active_tab == ActiveTab::Execution => + { + self.execution.scroll_up(1); + } + (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) + if self.active_tab == ActiveTab::Execution => + { + self.execution.scroll_down(1); + } + (KeyCode::PageUp, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Execution => + { + let page_size = self.execution.viewport_height.saturating_sub(1).max(1); + self.execution.scroll_up(page_size); + } + (KeyCode::PageDown, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Execution => + { + let page_size = self.execution.viewport_height.saturating_sub(1).max(1); + self.execution.scroll_down(page_size); + } + (KeyCode::Home, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Execution => + { + self.execution.scroll_offset = 0; + self.execution.auto_scroll = false; + } + (KeyCode::End, KeyModifiers::NONE) + if self.active_tab == ActiveTab::Execution => + { + self.execution.scroll_to_bottom(); + } (KeyCode::Char('['), KeyModifiers::NONE) | (KeyCode::Char(']'), KeyModifiers::NONE) => { self.side_panel.toggle(); } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 34de478..4cf7e2f 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -32,7 +32,7 @@ pub fn draw(frame: &mut Frame, app: &mut App) { draw_planning(frame, chunks[1], &mut app.planning, app.spinner.current()); } ActiveTab::Execution => { - draw_execution(frame, chunks[1], &app.execution, app.spinner.current()); + draw_execution(frame, chunks[1], &mut app.execution, app.spinner.current()); } } diff --git a/src/tui/views/execution.rs b/src/tui/views/execution.rs index 3e476c2..9a1de2d 100644 --- a/src/tui/views/execution.rs +++ b/src/tui/views/execution.rs @@ -29,6 +29,11 @@ pub struct ExecutionState { pub tasks: Vec, pub output: Vec, pub scroll_offset: usize, + pub auto_scroll: bool, + pub scroll_to_bottom_pending: bool, + pub content_height: usize, + pub viewport_height: usize, + pub last_area_width: u16, } impl ExecutionState { @@ -39,6 +44,11 @@ impl ExecutionState { tasks: Vec::new(), output: Vec::new(), scroll_offset: 0, + auto_scroll: true, + scroll_to_bottom_pending: false, + content_height: 0, + viewport_height: 20, + last_area_width: 0, } } @@ -55,7 +65,12 @@ impl ExecutionState { self.output.push(item); if self.output.len() > MAX_OUTPUT_ITEMS { self.output.remove(0); - self.scroll_offset = self.scroll_offset.saturating_sub(1); + // Note: scroll_offset will be recalculated on next render + // based on actual content height, so this removal is handled + } + if self.auto_scroll { + // Set flag to scroll on next render + self.scroll_to_bottom_pending = true; } } @@ -63,6 +78,38 @@ impl ExecutionState { let max_scroll = content_height.saturating_sub(viewport_height); self.scroll_offset = self.scroll_offset.min(max_scroll); } + + pub fn scroll_up(&mut self, lines: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(lines); + // Disable auto-scroll when scrolling up from bottom + let near_bottom_threshold = 3; + if self.scroll_offset + near_bottom_threshold < self.max_scroll() { + self.auto_scroll = false; + } + } + + pub fn scroll_down(&mut self, lines: usize) { + let max_scroll = self.max_scroll(); + self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); + // Re-enable auto-scroll when near bottom (within 3 lines) + let near_bottom_threshold = 3; + if self.scroll_offset + near_bottom_threshold >= max_scroll { + self.auto_scroll = true; + } + } + + pub fn max_scroll(&self) -> usize { + // Guard against zero viewport height + if self.viewport_height == 0 { + return 0; + } + self.content_height.saturating_sub(self.viewport_height) + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = self.max_scroll(); + self.auto_scroll = true; + } } impl Default for ExecutionState { @@ -71,7 +118,7 @@ impl Default for ExecutionState { } } -pub fn draw_execution(frame: &mut Frame, area: Rect, state: &ExecutionState, spinner_char: char) { +pub fn draw_execution(frame: &mut Frame, area: Rect, state: &mut ExecutionState, spinner_char: char) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) @@ -140,7 +187,7 @@ fn draw_task_pane(frame: &mut Frame, area: Rect, state: &ExecutionState, spinner frame.render_widget(list, chunks[1]); } -fn draw_output_pane(frame: &mut Frame, area: Rect, state: &ExecutionState) { +fn draw_output_pane(frame: &mut Frame, area: Rect, state: &mut ExecutionState) { let block = Block::default().borders(Borders::ALL).title(" Output "); let inner = block.inner(area); @@ -170,7 +217,7 @@ fn draw_output_pane(frame: &mut Frame, area: Rect, state: &ExecutionState) { lines.push(Line::from(vec![ Span::raw("┌─ "), Span::styled( - &tc.name, + tc.name.clone(), Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), @@ -192,6 +239,35 @@ fn draw_output_pane(frame: &mut Frame, area: Rect, state: &ExecutionState) { lines.push(Line::from(" Waiting for execution...")); } + // Calculate actual wrapped content height + let viewport_height = inner.height as usize; + let viewport_width = inner.width as usize; + + // Calculate wrapped line count + let mut content_height = 0; + for line in &lines { + let line_width = line.width(); + if line_width == 0 { + content_height += 1; + } else { + // Account for wrapping + content_height += (line_width + viewport_width - 1) / viewport_width.max(1); + } + } + + // Update state and handle pending scroll + state.content_height = content_height; + state.viewport_height = viewport_height; + state.last_area_width = inner.width; + + if state.scroll_to_bottom_pending { + state.scroll_offset = content_height.saturating_sub(viewport_height); + state.scroll_to_bottom_pending = false; + } + + // Clamp scroll + state.clamp_scroll(content_height, viewport_height); + let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; let paragraph = Paragraph::new(lines) @@ -199,4 +275,34 @@ fn draw_output_pane(frame: &mut Frame, area: Rect, state: &ExecutionState) { .scroll((scroll_y, 0)); frame.render_widget(paragraph, inner); + + // Show scroll indicator if content is scrollable + if content_height > viewport_height { + let max_scroll = content_height.saturating_sub(viewport_height); + let scroll_pct = if max_scroll > 0 { + (state.scroll_offset * 100) / max_scroll + } else { + 100 + }; + + // Add [auto] indicator if auto-scroll is enabled + let indicator = if state.auto_scroll { + format!(" {}% [auto] ", scroll_pct) + } else { + format!(" {}% ", scroll_pct) + }; + + let indicator_width = indicator.len() as u16; + let indicator_area = Rect::new( + inner.x + inner.width.saturating_sub(indicator_width), + inner.y + inner.height.saturating_sub(1), + indicator_width, + 1, + ); + + let indicator_widget = Paragraph::new(indicator) + .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + + frame.render_widget(indicator_widget, indicator_area); + } } diff --git a/src/tui/views/planning.rs b/src/tui/views/planning.rs index 757fab9..b7d7829 100644 --- a/src/tui/views/planning.rs +++ b/src/tui/views/planning.rs @@ -25,6 +25,11 @@ pub struct PlanningState { pub insert_mode: bool, pub scroll_offset: usize, pub thinking: bool, + pub auto_scroll: bool, + pub scroll_to_bottom_pending: bool, + pub content_height: usize, + pub viewport_height: usize, + pub last_area_width: u16, } impl PlanningState { @@ -42,11 +47,20 @@ impl PlanningState { insert_mode: false, scroll_offset: 0, thinking: false, + auto_scroll: true, + scroll_to_bottom_pending: false, + content_height: 0, + viewport_height: 20, + last_area_width: 0, } } pub fn add_message(&mut self, role: MessageRole, content: String) { self.messages.push(ChatMessage { role, content }); + if self.auto_scroll { + // Set flag to scroll on next render + self.scroll_to_bottom_pending = true; + } } pub fn submit_input(&mut self) -> Option { @@ -58,6 +72,38 @@ impl PlanningState { self.input.cut(); Some(text) } + + pub fn scroll_up(&mut self, lines: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(lines); + // Disable auto-scroll when scrolling up from bottom + let near_bottom_threshold = 3; + if self.scroll_offset + near_bottom_threshold < self.max_scroll() { + self.auto_scroll = false; + } + } + + pub fn scroll_down(&mut self, lines: usize) { + let max_scroll = self.max_scroll(); + self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); + // Re-enable auto-scroll when near bottom (within 3 lines) + let near_bottom_threshold = 3; + if self.scroll_offset + near_bottom_threshold >= max_scroll { + self.auto_scroll = true; + } + } + + pub fn max_scroll(&self) -> usize { + // Guard against zero viewport height + if self.viewport_height == 0 { + return 0; + } + self.content_height.saturating_sub(self.viewport_height) + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = self.max_scroll(); + self.auto_scroll = true; + } } impl Default for PlanningState { @@ -103,7 +149,7 @@ pub fn draw_planning(frame: &mut Frame, area: Rect, state: &mut PlanningState, s frame.render_widget(hints_bar, chunks[2]); } -fn draw_chat_history(frame: &mut Frame, area: Rect, state: &PlanningState, spinner_char: char) { +fn draw_chat_history(frame: &mut Frame, area: Rect, state: &mut PlanningState, spinner_char: char) { let block = Block::default() .borders(Borders::ALL) .title(" Chat "); @@ -150,6 +196,41 @@ fn draw_chat_history(frame: &mut Frame, area: Rect, state: &PlanningState, spinn ])); } + // Calculate actual wrapped content height + let viewport_height = inner.height as usize; + let viewport_width = inner.width as usize; + + // Calculate wrapped line count + let mut content_height = 0; + for line in &lines { + let line_width = line.width(); + if line_width == 0 { + content_height += 1; + } else { + // Account for wrapping + content_height += (line_width + viewport_width - 1) / viewport_width.max(1); + } + } + + // Update state dimensions + state.content_height = content_height; + state.viewport_height = viewport_height; + state.last_area_width = inner.width; + + // Handle pending scroll to bottom + if state.scroll_to_bottom_pending { + state.scroll_offset = content_height.saturating_sub(viewport_height); + state.scroll_to_bottom_pending = false; + } + + // Clamp scroll to valid range + let max_scroll = if viewport_height == 0 { + 0 + } else { + content_height.saturating_sub(viewport_height) + }; + state.scroll_offset = state.scroll_offset.min(max_scroll); + let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; let paragraph = Paragraph::new(lines) @@ -157,4 +238,33 @@ fn draw_chat_history(frame: &mut Frame, area: Rect, state: &PlanningState, spinn .scroll((scroll_y, 0)); frame.render_widget(paragraph, inner); + + // Show scroll indicator if content is scrollable + if content_height > viewport_height { + let scroll_pct = if max_scroll > 0 { + (state.scroll_offset * 100) / max_scroll + } else { + 100 + }; + + // Add [auto] indicator if auto-scroll is enabled + let indicator = if state.auto_scroll { + format!(" {}% [auto] ", scroll_pct) + } else { + format!(" {}% ", scroll_pct) + }; + + let indicator_width = indicator.len() as u16; + let indicator_area = Rect::new( + inner.x + inner.width.saturating_sub(indicator_width), + inner.y + inner.height.saturating_sub(1), + indicator_width, + 1, + ); + + let indicator_widget = Paragraph::new(indicator) + .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + + frame.render_widget(indicator_widget, indicator_area); + } } diff --git a/src/tui/widgets/help.rs b/src/tui/widgets/help.rs index 8187fd6..75a4d74 100644 --- a/src/tui/widgets/help.rs +++ b/src/tui/widgets/help.rs @@ -57,6 +57,14 @@ pub fn draw_help(frame: &mut Frame, area: Rect) { Line::from(" i Enter insert mode"), Line::from(" Esc Exit insert mode"), Line::from(" Enter Send message"), + Line::from(" ↑↓/jk Scroll (not in insert)"), + Line::from(" PgUp/PgDn Page scroll"), + Line::from(" Home/End Jump to top/bottom"), + Line::from(""), + Line::from(Span::styled("Execution", Style::default().add_modifier(Modifier::BOLD))), + Line::from(" ↑↓/jk Scroll output"), + Line::from(" PgUp/PgDn Page scroll"), + Line::from(" Home/End Jump to top/bottom"), ]; let paragraph = Paragraph::new(lines).block(block);