Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ use crate::services::shell_mode::run_background_shell_command;
#[cfg(unix)]
use crate::services::shell_mode::run_pty_command;
use crate::services::shell_mode::{SHELL_PROMPT_PREFIX, ShellCommand, ShellEvent};
use crate::services::text_selection::SelectionState;
use crate::services::textarea::{TextArea, TextAreaState};
use crate::services::toast::Toast;
use ratatui::layout::Size;
use ratatui::text::Line;
use stakpak_api::models::ListRuleBook;
Expand Down Expand Up @@ -68,8 +70,8 @@ pub struct AppState {
/// Per-message rendered line cache for efficient incremental rendering
pub per_message_cache: PerMessageCache,
/// Assembled lines cache (the final combined output of all message lines)
/// Format: (cache_key, lines, generation_counter)
pub assembled_lines_cache: Option<(usize, Vec<Line<'static>>, u64)>,
/// Format: (cache_key_hash, lines, generation_counter)
pub assembled_lines_cache: Option<(u64, Vec<Line<'static>>, u64)>,
/// Cache for visible lines on screen (avoids cloning on every frame)
pub visible_lines_cache: Option<VisibleLinesCache>,
/// Generation counter for assembled cache (increments on each rebuild)
Expand All @@ -78,6 +80,9 @@ pub struct AppState {
pub render_metrics: RenderMetrics,
/// Last width used for rendering (to detect width changes)
pub last_render_width: usize,
/// Maps line ranges to message info for click detection
/// Format: Vec<(start_line, end_line, message_id, is_user_message, message_text)>
pub line_to_message_map: Vec<(usize, usize, Uuid, bool, String)>,

// ========== Loading State ==========
pub loading: bool,
Expand Down Expand Up @@ -207,6 +212,23 @@ pub struct AppState {
pub interactive_shell_message_id: Option<Uuid>,
pub shell_interaction_occurred: bool,

// ========== Text Selection State ==========
pub selection: SelectionState,
pub toast: Option<Toast>,

// ========== Message Action Popup State ==========
pub show_message_action_popup: bool,
pub message_action_popup_selected: usize,
pub message_action_popup_position: Option<(u16, u16)>, // (x, y) position for popup
pub message_action_target_message_id: Option<Uuid>, // The user message being acted on
pub message_action_target_text: Option<String>, // The text of the target message
pub message_area_y: u16, // Y offset of message area for click detection
pub hover_row: Option<u16>, // Current mouse hover row for debugging

// ========== Input Area State ==========
/// Stores the input area content rect for mouse click positioning
pub input_content_area: Option<ratatui::layout::Rect>,

// ========== Side Panel State ==========
pub show_side_panel: bool,
pub side_panel_focus: SidePanelSection,
Expand Down Expand Up @@ -389,6 +411,7 @@ impl AppState {
cache_generation: 0,
render_metrics: RenderMetrics::new(),
last_render_width: 0,
line_to_message_map: Vec::new(),
pending_pastes: Vec::new(),
mouse_capture_enabled: false, // Will be set based on terminal detection in event_loop
loading_manager: LoadingStateManager::new(),
Expand All @@ -411,6 +434,20 @@ impl AppState {
interactive_shell_message_id: None,
shell_interaction_occurred: false,

// Text selection initialization
selection: SelectionState::default(),
toast: None,
input_content_area: None,

// Message action popup initialization
show_message_action_popup: false,
message_action_popup_selected: 0,
message_action_popup_position: None,
message_action_target_message_id: None,
message_action_target_text: None,
message_area_y: 0,
hover_row: None,

// Profile switcher initialization
show_profile_switcher: false,
available_profiles: Vec::new(),
Expand Down
6 changes: 6 additions & 0 deletions tui/src/app/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,13 @@ pub enum InputEvent {
ToggleSidePanel,
SidePanelNextSection,
SidePanelToggleSection,

// Mouse events
MouseClick(u16, u16),
MouseDragStart(u16, u16),
MouseDrag(u16, u16),
MouseDragEnd(u16, u16),
MouseMove(u16, u16),

// Board tasks events
RefreshBoardTasks,
Expand Down
9 changes: 8 additions & 1 deletion tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,15 @@ pub fn map_crossterm_event_to_input_event(event: Event) -> Option<InputEvent> {
MouseEventKind::ScrollUp => Some(InputEvent::ScrollUp),
MouseEventKind::ScrollDown => Some(InputEvent::ScrollDown),
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
Some(InputEvent::MouseClick(me.column, me.row))
Some(InputEvent::MouseDragStart(me.column, me.row))
}
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
Some(InputEvent::MouseDrag(me.column, me.row))
}
MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
Some(InputEvent::MouseDragEnd(me.column, me.row))
}
MouseEventKind::Moved => Some(InputEvent::MouseMove(me.column, me.row)),
_ => None,
},
Event::Resize(w, h) => Some(InputEvent::Resized(w, h)),
Expand Down
40 changes: 19 additions & 21 deletions tui/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
//! Contains the main TUI event loop and related helper functions.

use crate::app::{AppState, AppStateOptions, InputEvent, OutputEvent};
use crate::services::detect_term::is_unsupported_terminal;
use crate::services::handlers::tool::{
clear_streaming_tool_results, handle_tool_result, update_session_tool_calls_queue,
};
Expand Down Expand Up @@ -56,25 +55,19 @@ pub async fn run_tui(

crossterm::terminal::enable_raw_mode()?;

// Detect terminal support for mouse capture
// Detect terminal for adaptive colors (but always enable mouse capture)
#[cfg(unix)]
let terminal_info = crate::services::detect_term::detect_terminal();
#[cfg(unix)]
let enable_mouse_capture = is_unsupported_terminal(&terminal_info.emulator);
#[allow(unused_variables)]
let _ = terminal_info; // Used for adaptive colors, kept for future use

execute!(
std::io::stdout(),
EnterAlternateScreen,
EnableBracketedPaste
EnableBracketedPaste,
EnableMouseCapture
)?;

#[cfg(unix)]
if enable_mouse_capture {
execute!(std::io::stdout(), EnableMouseCapture)?;
} else {
execute!(std::io::stdout(), DisableMouseCapture)?;
}

let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;

let term_size = terminal.size()?;
Expand All @@ -99,15 +92,20 @@ pub async fn run_tui(
board_agent_id,
});

// Set mouse_capture_enabled based on terminal detection (matches the execute logic above)
#[cfg(unix)]
{
state.mouse_capture_enabled = enable_mouse_capture;
}
#[cfg(not(unix))]
{
state.mouse_capture_enabled = false;
}
// Mouse capture is always enabled
state.mouse_capture_enabled = true;

// Set initial terminal size
state.terminal_size = ratatui::layout::Size {
width: term_size.width,
height: term_size.height,
};

// Pre-initialize the gitleaks config for secret redaction
// This compiles all regex patterns upfront so first paste is fast
tokio::spawn(async move {
stakpak_shared::secrets::initialize_gitleaks_config(privacy_mode);
});

// Set the current profile name and rulebook config
state.current_profile_name = current_profile_name;
Expand Down
64 changes: 61 additions & 3 deletions tui/src/services/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod misc;
mod navigation;
mod popup;
pub mod shell;
mod text_selection;
pub mod tool;

// Re-export find_image_file_by_name for use in clipboard_paste
Expand Down Expand Up @@ -62,6 +63,40 @@ pub fn update(

state.scroll = state.scroll.max(0);

// Intercept keys for Message Action Popup
if state.show_message_action_popup {
match event {
InputEvent::HandleEsc => {
popup::handle_message_action_popup_close(state);
return;
}
InputEvent::Up | InputEvent::ScrollUp => {
popup::handle_message_action_popup_navigate(state, -1);
return;
}
InputEvent::Down | InputEvent::ScrollDown => {
popup::handle_message_action_popup_navigate(state, 1);
return;
}
InputEvent::InputSubmitted => {
popup::handle_message_action_popup_execute(state);
return;
}
InputEvent::MouseClick(_, _)
| InputEvent::MouseDragStart(_, _)
| InputEvent::MouseDrag(_, _)
| InputEvent::MouseDragEnd(_, _) => {
// Close popup on any mouse click/interaction
popup::handle_message_action_popup_close(state);
return;
}
_ => {
// Consume other events to prevent side effects
return;
}
}
}

// Intercept keys for File Changes Popup
if state.show_file_changes_popup {
match event {
Expand Down Expand Up @@ -100,10 +135,14 @@ pub fn update(
popup::handle_file_changes_popup_backspace(state);
return;
}
InputEvent::MouseClick(col, row) => {
InputEvent::MouseClick(col, row) | InputEvent::MouseDragStart(col, row) => {
popup::handle_file_changes_popup_mouse_click(state, col, row);
return;
}
InputEvent::MouseDrag(_, _) | InputEvent::MouseDragEnd(_, _) => {
// Ignore drag events when file changes popup is open
return;
}
_ => {
// Consume other events to prevent side effects
return;
Expand Down Expand Up @@ -415,9 +454,17 @@ pub fn update(
}
InputEvent::ScrollUp => {
navigation::handle_up_navigation(state);
// Extend selection when scrolling during active selection
if state.selection.active {
text_selection::handle_scroll_during_selection(state, -1, message_area_height);
}
}
InputEvent::ScrollDown => {
navigation::handle_down_navigation(state, message_area_height, message_area_width);
// Extend selection when scrolling during active selection
if state.selection.active {
text_selection::handle_scroll_during_selection(state, 1, message_area_height);
}
}
InputEvent::PageUp => {
navigation::handle_page_up(state, message_area_height, message_area_width);
Expand Down Expand Up @@ -735,15 +782,26 @@ pub fn update(
// so we don't need to call it again to avoid double-counting file changes.
}
InputEvent::ApprovalPopupSubmit => {}
InputEvent::MouseClick(col, row) => {
InputEvent::MouseClick(col, row) | InputEvent::MouseDragStart(col, row) => {
// Check if click is on file changes popup first
if state.show_file_changes_popup {
popup::handle_file_changes_popup_mouse_click(state, col, row);
} else {
// Try side panel click first, then start text selection if in message area
popup::handle_side_panel_mouse_click(state, col, row);
text_selection::handle_drag_start(state, col, row, message_area_height);
}
}

InputEvent::MouseDrag(col, row) => {
text_selection::handle_drag(state, col, row, message_area_height);
}
InputEvent::MouseDragEnd(col, row) => {
text_selection::handle_drag_end(state, col, row, message_area_height);
}
InputEvent::MouseMove(_col, row) => {
// Track hover row for visual debugging
state.hover_row = Some(row);
}
// Board tasks events
InputEvent::RefreshBoardTasks => {
misc::handle_refresh_board_tasks(state, input_tx);
Expand Down
Loading
Loading