diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index ae92197..a355e7e 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use chrono::{DateTime, Local, Utc}; use crossterm::{ - event::KeyEvent, + event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, terminal::{disable_raw_mode, enable_raw_mode}, }; use owlen_core::consent::ConsentScope; @@ -26,8 +26,11 @@ use owlen_core::{ }; use owlen_markdown::from_str; use pathdiff::diff_paths; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; use textwrap::{Options, WordSeparator, wrap}; use tokio::{ sync::mpsc, @@ -95,6 +98,122 @@ const RESIZE_STEP: f32 = 0.05; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14; +const MOUSE_SCROLL_STEP: isize = 3; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct LayoutSnapshot { + pub(crate) frame: Rect, + pub(crate) content: Rect, + pub(crate) file_panel: Option, + pub(crate) chat_panel: Option, + pub(crate) thinking_panel: Option, + pub(crate) actions_panel: Option, + pub(crate) input_panel: Option, + pub(crate) system_panel: Option, + pub(crate) status_panel: Option, + pub(crate) code_panel: Option, + pub(crate) model_info_panel: Option, +} + +impl LayoutSnapshot { + pub(crate) fn new(frame: Rect, content: Rect) -> Self { + Self { + frame, + content, + file_panel: None, + chat_panel: None, + thinking_panel: None, + actions_panel: None, + input_panel: None, + system_panel: None, + status_panel: None, + code_panel: None, + model_info_panel: None, + } + } + + fn contains(rect: Rect, column: u16, row: u16) -> bool { + let x_end = rect.x.saturating_add(rect.width); + let y_end = rect.y.saturating_add(rect.height); + column >= rect.x && column < x_end && row >= rect.y && row < y_end + } + + fn region_at(&self, column: u16, row: u16) -> Option { + if let Some(rect) = self.model_info_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::ModelInfo); + } + } + if let Some(rect) = self.code_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::Code); + } + } + if let Some(rect) = self.file_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::FileTree); + } + } + if let Some(rect) = self.input_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::Input); + } + } + if let Some(rect) = self.system_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::System); + } + } + if let Some(rect) = self.status_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::Status); + } + } + if let Some(rect) = self.actions_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::Actions); + } + } + if let Some(rect) = self.thinking_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::Thinking); + } + } + if let Some(rect) = self.chat_panel { + if Self::contains(rect, column, row) { + return Some(UiRegion::Chat); + } + } + if Self::contains(self.content, column, row) { + Some(UiRegion::Content) + } else if Self::contains(self.frame, column, row) { + Some(UiRegion::Frame) + } else { + None + } + } +} + +impl Default for LayoutSnapshot { + fn default() -> Self { + Self::new(Rect::new(0, 0, 0, 0), Rect::new(0, 0, 0, 0)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UiRegion { + Frame, + Content, + FileTree, + Chat, + Thinking, + Actions, + Input, + System, + Status, + Code, + ModelInfo, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SlashOutcome { @@ -484,6 +603,7 @@ pub struct ChatApp { system_status: String, // System/status messages (tool execution, status, etc) toasts: ToastManager, debug_log: DebugLogState, + last_layout: LayoutSnapshot, /// Simple execution budget: maximum number of tool calls allowed per session. _execution_budget: usize, /// Agent mode enabled @@ -754,6 +874,7 @@ impl ChatApp { }, toasts: ToastManager::new(), debug_log: DebugLogState::new(), + last_layout: LayoutSnapshot::default(), _execution_budget: 50, agent_mode: false, agent_running: false, @@ -1872,6 +1993,14 @@ impl ChatApp { &self.theme } + pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) { + self.last_layout = snapshot; + } + + fn region_for_position(&self, column: u16, row: u16) -> Option { + self.last_layout.region_at(column, row) + } + pub fn is_debug_log_visible(&self) -> bool { self.debug_log.is_visible() } @@ -4973,6 +5102,9 @@ impl ChatApp { } // Ignore paste events in other modes } + Event::Mouse(mouse) => { + return self.handle_mouse_event(mouse); + } Event::Key(key) => { let is_ctrl_c = matches!( (key.code, key.modifiers), @@ -7631,6 +7763,171 @@ impl ChatApp { Ok(AppState::Running) } + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result { + if self.has_pending_consent() { + return Ok(AppState::Running); + } + + let region = self.region_for_position(mouse.column, mouse.row); + + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(region) = region { + self.handle_mouse_scroll(region, -MOUSE_SCROLL_STEP); + } + } + MouseEventKind::ScrollDown => { + if let Some(region) = region { + self.handle_mouse_scroll(region, MOUSE_SCROLL_STEP); + } + } + MouseEventKind::Down(MouseButton::Left) => { + self.pending_key = None; + if let Some(region) = region { + self.handle_mouse_click(region, mouse.column, mouse.row); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if matches!(region, Some(UiRegion::Input)) { + self.pending_key = None; + self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row); + } + } + _ => {} + } + + Ok(AppState::Running) + } + + fn handle_mouse_scroll(&mut self, region: UiRegion, amount: isize) { + if amount == 0 { + return; + } + + match region { + UiRegion::FileTree => { + if self.focus_panel(FocusedPanel::Files) { + self.file_tree_mut().move_cursor(amount); + } + } + UiRegion::Thinking | UiRegion::Actions => { + if self.focus_panel(FocusedPanel::Thinking) { + let viewport = self.thinking_viewport_height.max(1); + self.thinking_scroll.on_user_scroll(amount, viewport); + } + } + UiRegion::Code => { + if self.focus_panel(FocusedPanel::Code) { + let viewport = self.code_view_viewport_height().max(1); + if let Some(scroll) = self.code_view_scroll_mut() { + scroll.on_user_scroll(amount, viewport); + } + } + } + UiRegion::ModelInfo => { + self.scroll_model_info(amount); + } + UiRegion::Input => {} + UiRegion::System + | UiRegion::Status + | UiRegion::Chat + | UiRegion::Content + | UiRegion::Frame => { + if self.focus_panel(FocusedPanel::Chat) { + self.auto_scroll + .on_user_scroll(amount, self.viewport_height); + self.update_new_message_alert_after_scroll(); + } + } + } + } + + fn handle_mouse_click(&mut self, region: UiRegion, column: u16, row: u16) { + match region { + UiRegion::FileTree => { + self.focus_panel(FocusedPanel::Files); + self.set_input_mode(InputMode::Normal); + } + UiRegion::Thinking | UiRegion::Actions => { + if self.focus_panel(FocusedPanel::Thinking) { + self.set_input_mode(InputMode::Normal); + } + } + UiRegion::Code => { + if self.focus_panel(FocusedPanel::Code) { + self.set_input_mode(InputMode::Normal); + } + } + UiRegion::Input => { + self.focus_panel(FocusedPanel::Input); + self.set_input_mode(InputMode::Editing); + if let Some(rect) = self.last_layout.input_panel { + if let Some((line, column)) = self.input_cursor_from_point(rect, column, row) { + let line = line.min(u16::MAX as usize) as u16; + let column = column.min(u16::MAX as usize) as u16; + self.textarea.move_cursor(CursorMove::Jump(line, column)); + } + } + } + UiRegion::ModelInfo => { + self.set_input_mode(InputMode::Normal); + } + UiRegion::System + | UiRegion::Status + | UiRegion::Chat + | UiRegion::Content + | UiRegion::Frame => { + self.focus_panel(FocusedPanel::Chat); + self.set_input_mode(InputMode::Normal); + } + } + } + + fn input_cursor_from_point(&self, rect: Rect, column: u16, row: u16) -> Option<(usize, usize)> { + let lines = self.textarea.lines(); + if lines.is_empty() { + return Some((0, 0)); + } + + let inner_x = usize::from(column.saturating_sub(rect.x.saturating_add(1))); + let inner_y = usize::from(row.saturating_sub(rect.y.saturating_add(1))); + + let max_line = lines.len().saturating_sub(1); + let line_index = inner_y.min(max_line); + let column_index = Self::grapheme_index_for_visual_offset(&lines[line_index], inner_x); + Some((line_index, column_index)) + } + + fn scroll_model_info(&mut self, amount: isize) { + if amount == 0 { + return; + } + + let steps = amount.unsigned_abs(); + let viewport = self.model_info_viewport_height.max(1); + if amount.is_positive() { + for _ in 0..steps { + self.model_info_panel.scroll_down(viewport); + } + } else { + for _ in 0..steps { + self.model_info_panel.scroll_up(); + } + } + } + + fn grapheme_index_for_visual_offset(line: &str, offset: usize) -> usize { + let mut width = 0usize; + for (idx, grapheme) in line.graphemes(true).enumerate() { + let grapheme_width = UnicodeWidthStr::width(grapheme); + if width + grapheme_width > offset { + return idx; + } + width += grapheme_width; + } + line.graphemes(true).count() + } + /// Call this when processing scroll up/down keys pub fn on_scroll(&mut self, delta: isize) { match self.focused_panel { diff --git a/crates/owlen-tui/src/events.rs b/crates/owlen-tui/src/events.rs index a2722dd..fcc9cd7 100644 --- a/crates/owlen-tui/src/events.rs +++ b/crates/owlen-tui/src/events.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; use std::time::Duration; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -8,6 +8,8 @@ use tokio_util::sync::CancellationToken; pub enum Event { /// Terminal key press event Key(KeyEvent), + /// Mouse input event + Mouse(MouseEvent), /// Terminal resize event #[allow(dead_code)] Resize(u16, u16), @@ -27,6 +29,7 @@ pub fn from_crossterm_event(raw: crossterm::event::Event) -> Option { None } } + crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(mouse)), crossterm::event::Event::Resize(width, height) => Some(Event::Resize(width, height)), crossterm::event::Event::Paste(text) => Some(Event::Paste(text)), _ => None, diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index d74202c..59e6ffe 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -12,7 +12,9 @@ use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext}; +use crate::chat_app::{ + ChatApp, HELP_TAB_COUNT, LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, +}; use crate::highlight; use crate::state::{ CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, @@ -204,9 +206,11 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { .title(title_line); let content_area = main_block.inner(frame_area); + let mut snapshot = LayoutSnapshot::new(frame_area, content_area); frame.render_widget(main_block, frame_area); if content_area.width == 0 || content_area.height == 0 { + app.set_layout_snapshot(snapshot); return; } @@ -240,6 +244,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { }; if let Some(file_area) = file_area { + snapshot.file_panel = Some(file_area); render_file_tree(frame, file_area, app); } @@ -308,25 +313,35 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { .split(chat_area); let mut idx = 0; + snapshot.chat_panel = Some(layout[idx]); render_messages(frame, layout[idx], app); idx += 1; if thinking_height > 0 { + snapshot.thinking_panel = Some(layout[idx]); render_thinking(frame, layout[idx], app); idx += 1; + } else { + snapshot.thinking_panel = None; } // Render agent actions panel if present if actions_height > 0 { + snapshot.actions_panel = Some(layout[idx]); render_agent_actions(frame, layout[idx], app); idx += 1; + } else { + snapshot.actions_panel = None; } + snapshot.input_panel = Some(layout[idx]); render_input(frame, layout[idx], app); idx += 1; + snapshot.system_panel = Some(layout[idx]); render_system_output(frame, layout[idx], app, &status_message); idx += 1; + snapshot.status_panel = Some(layout[idx]); render_status(frame, layout[idx], app); // Render consent dialog with highest priority (always on top) @@ -357,14 +372,20 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { .x .saturating_add(content_area.width.saturating_sub(panel_width)); let area = Rect::new(x, content_area.y, panel_width, content_area.height); + snapshot.model_info_panel = Some(area); frame.render_widget(Clear, area); let viewport_height = area.height.saturating_sub(2) as usize; app.set_model_info_viewport_height(viewport_height); app.model_info_panel_mut().render(frame, area, &theme); + } else { + snapshot.model_info_panel = None; } if let Some(area) = code_area { + snapshot.code_panel = Some(area); render_code_workspace(frame, area, app); + } else { + snapshot.code_panel = None; } if app.is_debug_log_visible() { @@ -381,6 +402,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { } } + app.set_layout_snapshot(snapshot); render_toasts(frame, app, content_area); }