From 27547d771995bf464adbb3ef5d2b72bfda107b42 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Wed, 21 Jan 2026 17:49:20 +0200 Subject: [PATCH 1/9] feat: Implement automatic scrolling to the last message's start for tool calls and filter completed todos from the side panel. --- tui/src/app.rs | 9 ++++ tui/src/event_loop.rs | 33 ++++++++++++- tui/src/services/approval_bar.rs | 31 ++++-------- tui/src/services/bash_block.rs | 3 +- tui/src/services/handlers/dialog.rs | 17 ++++--- tui/src/services/handlers/navigation.rs | 64 +++++++++++++++++++++---- tui/src/services/handlers/tool.rs | 6 +++ tui/src/services/side_panel.rs | 29 ++++++++--- 8 files changed, 142 insertions(+), 50 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 440ff381..352fdf7e 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -55,7 +55,13 @@ pub struct AppState { pub messages: Vec, pub scroll: usize, pub scroll_to_bottom: bool, + pub scroll_to_last_message_start: bool, pub stay_at_bottom: bool, + /// Counter to block stay_at_bottom for N frames (used when scroll_to_last_message_start needs to persist) + pub block_stay_at_bottom_frames: u8, + /// When scroll is locked, this stores how many lines from the end we want to show at top of viewport + /// This allows us to maintain relative position even as total_lines changes + pub scroll_lines_from_end: Option, pub content_changed_while_scrolled_up: bool, pub message_lines_cache: Option, pub collapsed_message_lines_cache: Option, @@ -297,7 +303,10 @@ impl AppState { messages: Vec::new(), // Will be populated after state is created scroll: 0, scroll_to_bottom: false, + scroll_to_last_message_start: false, stay_at_bottom: true, + block_stay_at_bottom_frames: 0, + scroll_lines_from_end: None, content_changed_while_scrolled_up: false, helpers: helpers.clone(), show_helper_dropdown: false, diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index 4b468ffa..31a4d41d 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -181,7 +181,38 @@ pub async fn run_tui( continue; } if let InputEvent::RunToolCall(tool_call) = &event { - crate::services::update::update(&mut state, InputEvent::ShowConfirmationDialog(tool_call.clone()), 10, 40, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size); + // Calculate actual message area dimensions (same as below) + let main_area_width = if state.show_side_panel { + term_size.width.saturating_sub(32 + 1) + } else { + term_size.width + }; + let term_rect = ratatui::layout::Rect::new(0, 0, main_area_width, term_size.height); + let input_height: u16 = 3; + let margin_height: u16 = 2; + let dropdown_showing = state.show_helper_dropdown + && ((!state.filtered_helpers.is_empty() && state.input().starts_with('/')) + || !state.filtered_files.is_empty()); + let dropdown_height = if dropdown_showing { + state.filtered_helpers.len() as u16 + } else { + 0 + }; + let hint_height = if dropdown_showing { 0 } else { margin_height }; + let outer_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Min(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(input_height), + ratatui::layout::Constraint::Length(dropdown_height), + ratatui::layout::Constraint::Length(hint_height), + ]) + .split(term_rect); + let message_area_width = outer_chunks[0].width.saturating_sub(2) as usize; + let message_area_height = outer_chunks[0].height as usize; + + crate::services::update::update(&mut state, InputEvent::ShowConfirmationDialog(tool_call.clone()), message_area_height, message_area_width, &internal_tx, &output_tx, cancel_tx.clone(), &shell_event_tx, term_size); state.poll_file_search_results(); terminal.draw(|f| view(f, &mut state))?; continue; diff --git a/tui/src/services/approval_bar.rs b/tui/src/services/approval_bar.rs index 892efa12..fee3669e 100644 --- a/tui/src/services/approval_bar.rs +++ b/tui/src/services/approval_bar.rs @@ -245,15 +245,14 @@ impl ApprovalBar { } /// Calculate the height needed for rendering - /// Returns: top border (1) + content lines (with spacing) + empty line (1) + footer (1) + bottom border (1) + /// Returns: top border (1) + content lines (with spacing) + footer (1) + bottom border (1) pub fn calculate_height(&self) -> u16 { if !self.is_visible() { return 0; } - // For now, estimate max height needed - // Top border (1) + up to 3 button rows with spacing (5) + empty line (1) + footer (1) + bottom border (1) = 9 + // Top border (1) + up to 3 button rows with spacing (5) + footer (1) + bottom border (1) = 8 // But cap at reasonable height - 8 + 7 } /// Render the approval bar with wrapping support @@ -379,8 +378,8 @@ impl ApprovalBar { // Render content lines (tabs/buttons) with spacing between rows let mut current_y = area.y + 1; for (line_idx, tab_spans) in lines.iter().enumerate() { - if current_y >= area.y + area.height.saturating_sub(3) { - break; // Leave room for empty line, footer and bottom border + if current_y >= area.y + area.height.saturating_sub(2) { + break; // Leave room for footer and bottom border } // Add empty line before each button row (except the first) @@ -396,7 +395,7 @@ impl ApprovalBar { ); current_y += 1; - if current_y >= area.y + area.height.saturating_sub(3) { + if current_y >= area.y + area.height.saturating_sub(2) { break; } } @@ -421,22 +420,8 @@ impl ApprovalBar { current_y += 1; } - // Empty line between buttons and footer - let empty_line_y = current_y; - if empty_line_y < area.y + area.height.saturating_sub(2) { - let empty_line = Line::from(vec![ - Span::styled("│", Style::default().fg(border_color)), - Span::raw(" ".repeat(inner_width)), - Span::styled("│", Style::default().fg(border_color)), - ]); - f.render_widget( - Paragraph::new(empty_line), - Rect::new(area.x, empty_line_y, area.width, 1), - ); - } - - // Footer line with controls - let footer_y = empty_line_y + 1; + // Footer line with controls (directly after buttons, no empty line) + let footer_y = current_y; if footer_y < area.y + area.height.saturating_sub(1) { // Build footer controls with same style as approval popup let footer_controls = vec![ diff --git a/tui/src/services/bash_block.rs b/tui/src/services/bash_block.rs index 16284c5a..33a7c496 100644 --- a/tui/src/services/bash_block.rs +++ b/tui/src/services/bash_block.rs @@ -1916,8 +1916,9 @@ pub fn render_run_command_block( let inner_width = content_width; let horizontal_line = "─".repeat(inner_width + 2); - // Border color: DarkGray for error/cancelled/rejected/skipped states, Gray otherwise + // Border color: Cyan for pending (preview), DarkGray for error/cancelled/rejected/skipped, Gray otherwise let border_color = match state { + RunCommandState::Pending => Color::Cyan, RunCommandState::Error | RunCommandState::Cancelled | RunCommandState::Rejected diff --git a/tui/src/services/handlers/dialog.rs b/tui/src/services/handlers/dialog.rs index 3a0c31a3..cc8420a4 100644 --- a/tui/src/services/handlers/dialog.rs +++ b/tui/src/services/handlers/dialog.rs @@ -467,16 +467,15 @@ pub fn handle_show_confirmation_dialog( if !tool_calls.is_empty() && state.toggle_approved_message { let was_empty = state.approval_bar.actions().is_empty(); - // Only add tools that aren't already in the bar + // Add tools to the bar (add_action handles duplicate prevention internally) for tc in tool_calls { - let already_in_bar = state - .approval_bar - .actions() - .iter() - .any(|a| a.tool_call.id == tc.id); - if !already_in_bar { - state.approval_bar.add_action(tc); - } + state.approval_bar.add_action(tc); + } + + // If this is the first time showing the approval bar, scroll to show the tool call + if was_empty && !state.approval_bar.actions().is_empty() { + state.scroll_to_last_message_start = true; + state.stay_at_bottom = false; } // If we just added tools to an empty bar, the first one's pending block diff --git a/tui/src/services/handlers/navigation.rs b/tui/src/services/handlers/navigation.rs index bd4c39c5..8216dada 100644 --- a/tui/src/services/handlers/navigation.rs +++ b/tui/src/services/handlers/navigation.rs @@ -397,17 +397,63 @@ pub fn handle_page_down( /// Adjust scroll position based on state pub fn adjust_scroll(state: &mut AppState, message_area_height: usize, message_area_width: usize) { - // Use cached line count instead of recalculating every adjustment - let total_lines = if let Some((_, _, cached_lines)) = &state.message_lines_cache { - cached_lines.len() - } else { - // Fallback: calculate once and cache - let all_lines = get_wrapped_message_lines_cached(state, message_area_width); - all_lines.len() - }; + // Always use get_wrapped_message_lines_cached for consistent total_lines calculation + // This ensures we use the same cache as the per_message_cache used for last_message_lines + let all_lines = get_wrapped_message_lines_cached(state, message_area_width); + let total_lines = all_lines.len(); let max_scroll = total_lines.saturating_sub(message_area_height); - if state.stay_at_bottom { + + // Decrement block counter if active + if state.block_stay_at_bottom_frames > 0 { + state.block_stay_at_bottom_frames -= 1; + // Clear the lines_from_end when block expires + if state.block_stay_at_bottom_frames == 0 { + state.scroll_lines_from_end = None; + } + } + + // scroll_to_last_message_start takes priority - user explicitly navigating tool calls + if state.scroll_to_last_message_start { + // Get the last message's rendered line count from cache + let last_message_lines = state + .messages + .last() + .and_then(|msg| state.per_message_cache.get(&msg.id)) + .map(|cache| cache.rendered_lines.len()) + .unwrap_or(0); + + // If last message isn't cached yet, wait for next frame + if last_message_lines == 0 { + // Keep the flag, don't change scroll + } else { + // Store how many lines from the end we want at the top of viewport + // We want: last_message_lines + 3 lines of context above + let lines_from_end = last_message_lines + 3; + state.scroll_lines_from_end = Some(lines_from_end); + + // Calculate scroll position based on lines from end + let scroll_target = total_lines.saturating_sub(lines_from_end); + state.scroll = scroll_target.min(max_scroll); + state.scroll_to_last_message_start = false; + // Block stay_at_bottom for a few frames to prevent override + state.block_stay_at_bottom_frames = 10; + } + // Disable stay_at_bottom so it doesn't override on next frame + state.stay_at_bottom = false; + } else if state.block_stay_at_bottom_frames > 0 { + // Recalculate scroll based on lines_from_end to maintain relative position + // even as total_lines changes + if let Some(lines_from_end) = state.scroll_lines_from_end { + let scroll_target = total_lines.saturating_sub(lines_from_end); + state.scroll = scroll_target.min(max_scroll); + } else { + // Fallback: just cap to max_scroll + if state.scroll > max_scroll { + state.scroll = max_scroll; + } + } + } else if state.stay_at_bottom { state.scroll = max_scroll; } else if state.scroll_to_bottom { state.scroll = max_scroll; diff --git a/tui/src/services/handlers/tool.rs b/tui/src/services/handlers/tool.rs index 9e37a36b..69112bbd 100644 --- a/tui/src/services/handlers/tool.rs +++ b/tui/src/services/handlers/tool.rs @@ -266,11 +266,17 @@ pub fn handle_toggle_approval_status(state: &mut AppState) { /// Handle approval bar next tab event pub fn handle_approval_popup_next_tab(state: &mut AppState) { state.approval_bar.select_next(); + // Scroll to show the beginning of the tool call block + state.scroll_to_last_message_start = true; + state.stay_at_bottom = false; } /// Handle approval bar prev tab event pub fn handle_approval_popup_prev_tab(state: &mut AppState) { state.approval_bar.select_prev(); + // Scroll to show the beginning of the tool call block + state.scroll_to_last_message_start = true; + state.stay_at_bottom = false; } /// Handle approval bar toggle approval event diff --git a/tui/src/services/side_panel.rs b/tui/src/services/side_panel.rs index 0d523613..ee5ef4b7 100644 --- a/tui/src/services/side_panel.rs +++ b/tui/src/services/side_panel.rs @@ -85,14 +85,21 @@ pub fn render_side_panel(f: &mut Frame, state: &mut AppState, area: Rect) { // Calculate todo content width for wrapping let todo_content_width = padded_area.width.saturating_sub(10) as usize; // Accounts for LEFT_PADDING + symbol + spacing + // Filter out completed todos - only show pending and in-progress + let visible_todos: Vec<_> = state + .todos + .iter() + .filter(|t| t.status != TodoStatus::Done) + .collect(); + let todos_height = if todos_collapsed { collapsed_height - } else if state.todos.is_empty() { + } else if visible_todos.is_empty() { 3 // Header + "No tasks" + blank line } else { // Calculate total lines needed including wrapped lines let mut total_lines = 1; // Header - for todo in &state.todos { + for todo in &visible_todos { let wrapped_lines = wrap_text(&todo.text, todo_content_width); total_lines += wrapped_lines.len().max(1); } @@ -318,11 +325,19 @@ fn render_todos_section(f: &mut Frame, state: &AppState, area: Rect, collapsed: let focused = state.side_panel_focus == SidePanelSection::Todos; let header_style = section_header_style(focused); + // Filter out completed todos - only show pending and in-progress + let visible_todos: Vec<_> = state + .todos + .iter() + .filter(|t| t.status != TodoStatus::Done) + .collect(); + let collapse_indicator = if collapsed { "▸" } else { "▾" }; - let count = if state.todos.is_empty() { + // Show count of remaining (non-completed) todos + let count = if visible_todos.is_empty() { String::new() } else { - format!(" ({})", state.todos.len()) + format!(" ({})", visible_todos.len()) }; let header = Line::from(Span::styled( @@ -338,7 +353,7 @@ fn render_todos_section(f: &mut Frame, state: &AppState, area: Rect, collapsed: let mut lines = vec![header]; - if state.todos.is_empty() { + if visible_todos.is_empty() { lines.push(Line::from(Span::styled( format!("{} No tasks", LEFT_PADDING), Style::default() @@ -350,9 +365,9 @@ fn render_todos_section(f: &mut Frame, state: &AppState, area: Rect, collapsed: let prefix_width = LEFT_PADDING.len() + 6; // " [x] " = 6 chars let content_width = (area.width as usize).saturating_sub(prefix_width + 2); - for todo in &state.todos { + for todo in &visible_todos { let (symbol, symbol_color, text_color) = match todo.status { - TodoStatus::Done => ("[x]", Color::Green, Color::Reset), + TodoStatus::Done => ("[x]", Color::Green, Color::Reset), // Won't be shown but keep for completeness TodoStatus::InProgress => ("[/]", Color::Yellow, Color::Reset), TodoStatus::Pending => ("[ ]", Color::DarkGray, Color::DarkGray), }; From f2d3bd49309e7e24b42a7dcc091422eb8bb86eab Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Wed, 21 Jan 2026 20:58:25 +0200 Subject: [PATCH 2/9] add clear/redraw for interactive command with shell mode retry --- tui/src/app.rs | 3 +++ tui/src/event_loop.rs | 5 +++++ tui/src/services/handlers/shell.rs | 2 ++ 3 files changed, 10 insertions(+) diff --git a/tui/src/app.rs b/tui/src/app.rs index 352fdf7e..4831287e 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -94,6 +94,8 @@ pub struct AppState { pub shell_popup_visible: bool, pub shell_popup_expanded: bool, pub shell_popup_scroll: usize, + /// Flag to request a terminal clear and redraw (e.g., after shell popup closes) + pub needs_terminal_clear: bool, pub shell_cursor_visible: bool, pub shell_cursor_blink_timer: u8, pub active_shell_command: Option, @@ -331,6 +333,7 @@ impl AppState { shell_popup_visible: false, shell_popup_expanded: false, shell_popup_scroll: 0, + needs_terminal_clear: false, shell_cursor_visible: true, shell_cursor_blink_timer: 0, active_shell_command: None, diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index 31a4d41d..cfe2df61 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -574,6 +574,11 @@ pub async fn run_tui( if should_quit { break; } + // Check if terminal clear was requested (e.g., after shell popup closes) + if state.needs_terminal_clear { + state.needs_terminal_clear = false; + emergency_clear_and_redraw(&mut terminal, &mut state)?; + } state.poll_file_search_results(); state.update_session_empty_status(); terminal.draw(|f| view(f, &mut state))?; diff --git a/tui/src/services/handlers/shell.rs b/tui/src/services/handlers/shell.rs index c93d200a..e91bd350 100644 --- a/tui/src/services/handlers/shell.rs +++ b/tui/src/services/handlers/shell.rs @@ -825,6 +825,8 @@ pub fn handle_shell_completed( // Hide shell popup on completion state.shell_popup_visible = false; state.shell_popup_expanded = false; + // Request terminal clear to remove any leaked output (e.g., sudo password prompts) + state.needs_terminal_clear = true; // Invalidate cache to restore normal message display invalidate_message_lines_cache(state); state.show_shell_mode = false; From ba0a16913917176a9b88597d571bc0b8680e3b27 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Thu, 22 Jan 2026 15:13:08 +0200 Subject: [PATCH 3/9] feat: Clear changeset and todos on new session or reset, and refine UI scrolling and side panel width calculation. --- tui/src/services/commands.rs | 8 ++++++++ tui/src/services/handlers/input.rs | 5 +++++ tui/src/services/handlers/navigation.rs | 17 ++++++++++++----- tui/src/services/handlers/popup.rs | 5 +++++ tui/src/services/side_panel.rs | 4 ++-- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/tui/src/services/commands.rs b/tui/src/services/commands.rs index 5d97b4e4..28759242 100644 --- a/tui/src/services/commands.rs +++ b/tui/src/services/commands.rs @@ -550,6 +550,10 @@ pub fn resume_session(state: &mut AppState, output_tx: &Sender) { state.scroll_to_bottom = true; state.stay_at_bottom = true; + // Clear changeset and todos from previous session + state.changeset = crate::services::changeset::Changeset::default(); + state.todos.clear(); + // Invalidate caches crate::services::message::invalidate_message_lines_cache(state); @@ -590,6 +594,10 @@ pub fn new_session(state: &mut AppState, output_tx: &Sender) { state.scroll_to_bottom = true; state.stay_at_bottom = true; + // Clear changeset and todos from previous session + state.changeset = crate::services::changeset::Changeset::default(); + state.todos.clear(); + // Invalidate caches crate::services::message::invalidate_message_lines_cache(state); diff --git a/tui/src/services/handlers/input.rs b/tui/src/services/handlers/input.rs index 1fa87541..82f4846a 100644 --- a/tui/src/services/handlers/input.rs +++ b/tui/src/services/handlers/input.rs @@ -105,6 +105,11 @@ pub fn handle_input_submitted_event( state.scroll = 0; state.scroll_to_bottom = true; state.stay_at_bottom = true; + + // Clear changeset and todos from previous session + state.changeset = crate::services::changeset::Changeset::default(); + state.todos.clear(); + crate::services::message::invalidate_message_lines_cache(state); // Reset usage diff --git a/tui/src/services/handlers/navigation.rs b/tui/src/services/handlers/navigation.rs index 8216dada..4e47ce5d 100644 --- a/tui/src/services/handlers/navigation.rs +++ b/tui/src/services/handlers/navigation.rs @@ -427,13 +427,20 @@ pub fn adjust_scroll(state: &mut AppState, message_area_height: usize, message_a if last_message_lines == 0 { // Keep the flag, don't change scroll } else { - // Store how many lines from the end we want at the top of viewport - // We want: last_message_lines + 3 lines of context above - let lines_from_end = last_message_lines + 3; + // Calculate where the last message starts + let last_msg_start_line = total_lines.saturating_sub(last_message_lines); + + // We want to show the START of the tool call block, with some context above if possible + // If the tool call is taller than viewport, show its start + // If it fits, show it with ~2 lines of context above + let context_lines = 2; + let scroll_target = last_msg_start_line.saturating_sub(context_lines); + + // Store the target line (from start of content, not from end) + // We'll use lines_from_end to maintain position as content changes + let lines_from_end = total_lines.saturating_sub(scroll_target); state.scroll_lines_from_end = Some(lines_from_end); - // Calculate scroll position based on lines from end - let scroll_target = total_lines.saturating_sub(lines_from_end); state.scroll = scroll_target.min(max_scroll); state.scroll_to_last_message_start = false; // Block stay_at_bottom for a few frames to prevent override diff --git a/tui/src/services/handlers/popup.rs b/tui/src/services/handlers/popup.rs index 49134a76..0e42fa76 100644 --- a/tui/src/services/handlers/popup.rs +++ b/tui/src/services/handlers/popup.rs @@ -3,6 +3,7 @@ //! Handles all popup-related events including profile switcher, rulebook switcher, command palette, shortcuts, collapsed messages, and context popup. use crate::app::{AppState, OutputEvent}; +use crate::services::changeset::Changeset; use crate::services::detect_term::AdaptiveColors; use crate::services::helper_block::{push_error_message, push_styled_message, welcome_messages}; use crate::services::message::{ @@ -167,6 +168,10 @@ pub fn handle_profile_switch_complete(state: &mut AppState, profile: String) { state.last_user_message_for_retry = None; state.is_retrying = false; + // Clear changeset and todos from previous session + state.changeset = Changeset::default(); + state.todos.clear(); + // CRITICAL: Close profile switcher to prevent stray selects state.show_profile_switcher = false; state.profile_switcher_selected = 0; diff --git a/tui/src/services/side_panel.rs b/tui/src/services/side_panel.rs index ee5ef4b7..c6aadbc3 100644 --- a/tui/src/services/side_panel.rs +++ b/tui/src/services/side_panel.rs @@ -527,8 +527,8 @@ fn render_changeset_section(f: &mut Frame, state: &AppState, area: Rect, collaps } }; - // Calculate available width - let available_width = area.width as usize; + // Calculate available width (subtract 1 for right padding/border alignment) + let available_width = area.width.saturating_sub(1) as usize; // Format: [PREFIX] [STATE_LABEL] [NAME] ... [STATS] // We want the state label to be next to the name From 5ae033caba1b69f04040105426a38b871575a531 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Thu, 22 Jan 2026 16:50:55 +0200 Subject: [PATCH 4/9] fix: Correct scroll position when approval bar appears - Calculate message area dimensions accounting for approval bar height - Invalidate message cache when pending tool call message is added - Ensures tool call preview is visible when approval bar first shows --- tui/src/event_loop.rs | 25 +++++++++++++------------ tui/src/services/handlers/dialog.rs | 3 +++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index cfe2df61..c81ef90c 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -181,32 +181,33 @@ pub async fn run_tui( continue; } if let InputEvent::RunToolCall(tool_call) = &event { - // Calculate actual message area dimensions (same as below) + // Calculate actual message area dimensions (same as view.rs) let main_area_width = if state.show_side_panel { term_size.width.saturating_sub(32 + 1) } else { term_size.width }; let term_rect = ratatui::layout::Rect::new(0, 0, main_area_width, term_size.height); - let input_height: u16 = 3; let margin_height: u16 = 2; let dropdown_showing = state.show_helper_dropdown && ((!state.filtered_helpers.is_empty() && state.input().starts_with('/')) || !state.filtered_files.is_empty()); - let dropdown_height = if dropdown_showing { - state.filtered_helpers.len() as u16 - } else { - 0 - }; let hint_height = if dropdown_showing { 0 } else { margin_height }; + + // Account for approval bar height (will be shown after this tool call) + // The approval bar will be visible, so input and dropdown are hidden + let approval_bar_height = state.approval_bar.calculate_height().max(7); // Use expected height + let outer_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ - ratatui::layout::Constraint::Min(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(dropdown_height), - ratatui::layout::Constraint::Length(hint_height), + ratatui::layout::Constraint::Min(1), // messages + ratatui::layout::Constraint::Length(1), // loading + ratatui::layout::Constraint::Length(0), // shell popup + ratatui::layout::Constraint::Length(approval_bar_height), // approval bar + ratatui::layout::Constraint::Length(0), // input (hidden when approval bar visible) + ratatui::layout::Constraint::Length(0), // dropdown (hidden when approval bar visible) + ratatui::layout::Constraint::Length(hint_height), // hint ]) .split(term_rect); let message_area_width = outer_chunks[0].width.saturating_sub(2) as usize; diff --git a/tui/src/services/handlers/dialog.rs b/tui/src/services/handlers/dialog.rs index cc8420a4..c120aed2 100644 --- a/tui/src/services/handlers/dialog.rs +++ b/tui/src/services/handlers/dialog.rs @@ -372,6 +372,9 @@ pub fn handle_show_confirmation_dialog( } state.pending_bash_message_id = Some(message_id); + // Invalidate cache so the new message gets rendered + invalidate_message_lines_cache(state); + state.dialog_command = Some(tool_call.clone()); // Only set is_dialog_open if NOT using the new approval bar flow // When toggle_approved_message is true, we use the approval bar instead From 475f69cb5739f2bed2e98ead274b092e3ec67c3c Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Fri, 23 Jan 2026 01:38:23 +0200 Subject: [PATCH 5/9] fix: Show correct line numbers in diff view - Extract starting line number from diff result (@@ -XX +XX @@) when available - Fall back to reading file to find old_str position for preview case - Use render_full_content_message for str_replace/create in fullscreen popup to preserve the result data needed for line number extraction --- tui/src/event_loop.rs | 4 +- tui/src/services/bash_block.rs | 15 ++- tui/src/services/file_diff.rs | 56 +++++++++- tui/src/services/message.rs | 192 ++++++++++++++++++++------------- 4 files changed, 181 insertions(+), 86 deletions(-) diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index c81ef90c..eb0f28a7 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -249,7 +249,9 @@ pub async fn run_tui( // TUI: Show diff result block with yellow border (is_collapsed: None) state.messages.push(Message::render_result_border_block(tool_call_result.clone())); // Full screen popup: Show diff-only view without border (is_collapsed: Some(true)) - state.messages.push(Message::render_collapsed_message(tool_call_result.call.clone())); + // Use render_full_content_message which stores the full ToolCallResult including the result + // (needed for extracting line numbers from the diff output) + state.messages.push(Message::render_full_content_message(tool_call_result.clone())); } "run_command_task" => { // TUI: bordered result block (is_collapsed: None) diff --git a/tui/src/services/bash_block.rs b/tui/src/services/bash_block.rs index 33a7c496..d93c0a89 100644 --- a/tui/src/services/bash_block.rs +++ b/tui/src/services/bash_block.rs @@ -889,15 +889,17 @@ pub fn render_styled_header_and_borders( /// Render file diff for full screen popup - shows diff lines with context /// Uses the same diff-only approach as the TUI view for consistency /// Returns None if there's no diff to show (e.g., old_str not found) +/// The `result` parameter can be provided to extract the starting line number from the diff output. pub fn render_file_diff_full( tool_call: &ToolCall, terminal_width: usize, do_show: Option, + result: Option<&str>, ) -> Option>> { // Get diff lines - use the truncated version which starts from first change // but we'll show all diff lines without truncation for the full screen view let (_truncated_diff_lines, full_diff_lines) = - render_file_diff_block_from_args(tool_call, terminal_width); + render_file_diff_block_from_args(tool_call, terminal_width, result); let title: String = get_command_type_name(tool_call); @@ -1029,9 +1031,14 @@ pub fn render_markdown_block( /// Render str_replace/create results - clean diff view without borders /// Uses the same approach as fullscreen popup for consistency /// Returns None if there's no diff (fallback to standard result rendering) -pub fn render_diff_result_block(tool_call: &ToolCall, width: usize) -> Option>> { +/// The `result` parameter can be provided to extract the starting line number from the diff output. +pub fn render_diff_result_block( + tool_call: &ToolCall, + width: usize, + result: Option<&str>, +) -> Option>> { // Use the same clean diff rendering as the fullscreen popup - render_file_diff_full(tool_call, width, Some(true)) + render_file_diff_full(tool_call, width, Some(true), result) } pub fn render_result_block(tool_call_result: &ToolCallResult, width: usize) -> Vec> { @@ -1078,7 +1085,7 @@ pub fn render_result_block(tool_call_result: &ToolCallResult, width: usize) -> V ); } - if let Some(diff_lines) = render_diff_result_block(&tool_call, width) { + if let Some(diff_lines) = render_diff_result_block(&tool_call, width, Some(&result)) { return diff_lines; } // Fall through to standard result rendering if no diff diff --git a/tui/src/services/file_diff.rs b/tui/src/services/file_diff.rs index d7387585..1a3f93a5 100644 --- a/tui/src/services/file_diff.rs +++ b/tui/src/services/file_diff.rs @@ -1,25 +1,62 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use regex::Regex; use similar::TextDiff; use stakpak_shared::models::integrations::openai::ToolCall; use crate::services::detect_term::AdaptiveColors; +/// Extract the starting line number from a diff result string. +/// Parses the hunk header like "@@ -21 +21 @@" or "@@ -21,3 +21,3 @@" and returns the old line number. +pub fn extract_starting_line_from_diff(diff_result: &str) -> Option { + // Match patterns like "@@ -21 +21 @@" or "@@ -21,3 +21,3 @@" + let re = Regex::new(r"@@\s*-(\d+)").ok()?; + if let Some(captures) = re.captures(diff_result) + && let Some(line_match) = captures.get(1) + { + return line_match.as_str().parse::().ok(); + } + None +} + +/// Find the starting line number of `old_str` within a file. +/// Returns the 1-based line number where old_str starts, or None if not found. +pub fn find_starting_line_in_file(file_path: &str, old_str: &str) -> Option { + // Don't try to find line number for empty old_str (new file creation) + if old_str.is_empty() { + return Some(1); + } + + // Try to read the file + let file_content = std::fs::read_to_string(file_path).ok()?; + + // Find the position of old_str in the file content + let pos = file_content.find(old_str)?; + + // Count newlines before this position to get the line number (1-based) + let line_number = file_content[..pos].matches('\n').count() + 1; + + Some(line_number) +} + pub fn render_file_diff_block( tool_call: &ToolCall, terminal_width: usize, ) -> (Vec>, Vec>) { // Use the same diff-only approach as render_file_diff_block_from_args // This shows only the actual changes (old_str vs new_str), not the whole file - render_file_diff_block_from_args(tool_call, terminal_width) + render_file_diff_block_from_args(tool_call, terminal_width, None) } /// Generate a diff directly from old_str and new_str without reading from file. /// This is used as a fallback when the file has already been modified (e.g., on session resume). +/// The `starting_line` parameter allows specifying the starting line number offset +/// (e.g., if the old_str starts at line 21 in the actual file, pass Some(21)). pub fn preview_diff_from_strings( old_str: &str, new_str: &str, terminal_width: usize, + starting_line: Option, ) -> (Vec>, usize, usize, usize, usize) { // Create a line-by-line diff directly from the strings let diff = TextDiff::from_lines(old_str, new_str); @@ -30,8 +67,10 @@ pub fn preview_diff_from_strings( let mut first_change_index = None; let mut last_change_index = 0usize; - let mut old_line_num = 0; - let mut new_line_num = 0; + // Use starting_line offset if provided (subtract 1 because we'll increment before display) + let line_offset = starting_line.unwrap_or(1).saturating_sub(1); + let mut old_line_num = line_offset; + let mut new_line_num = line_offset; // Helper function to wrap content while maintaining proper indentation fn wrap_content(content: &str, terminal_width: usize, prefix_width: usize) -> Vec { @@ -438,9 +477,11 @@ pub fn preview_diff_from_strings( /// Render a diff block directly from tool call arguments (old_str and new_str). /// This function SKIPS file-based diff entirely and is used for fullscreen popup /// when the file has already been modified (e.g., on session resume). +/// The `result` parameter can be provided to extract the starting line number from the diff output. pub fn render_file_diff_block_from_args( tool_call: &ToolCall, terminal_width: usize, + result: Option<&str>, ) -> (Vec>, Vec>) { let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments) .unwrap_or_else(|_| serde_json::json!({})); @@ -458,9 +499,16 @@ pub fn render_file_diff_block_from_args( return (vec![], vec![]); } + // Try to get the starting line number: + // 1. First, try to extract from the result (if provided) - this is the most accurate + // 2. If no result, try to find old_str in the file to determine the line number + let starting_line = result + .and_then(extract_starting_line_from_diff) + .or_else(|| find_starting_line_in_file(path, old_str)); + // Generate diff directly from the strings let (diff_lines, deletions, insertions, first_change_index, last_change_index) = - preview_diff_from_strings(old_str, new_str, terminal_width); + preview_diff_from_strings(old_str, new_str, terminal_width, starting_line); if deletions == 0 && insertions == 0 { return (vec![], vec![]); diff --git a/tui/src/services/message.rs b/tui/src/services/message.rs index 43f8462f..c250ae62 100644 --- a/tui/src/services/message.rs +++ b/tui/src/services/message.rs @@ -1189,7 +1189,7 @@ fn render_single_message_internal(msg: &Message, width: usize) -> Vec<(Line<'sta MessageContent::RenderCollapsedMessage(tool_call) => { let tool_name = crate::utils::strip_tool_name(&tool_call.function.name); if (tool_name == "str_replace" || tool_name == "create") - && let Some(rendered) = render_file_diff_full(tool_call, width, Some(true)) + && let Some(rendered) = render_file_diff_full(tool_call, width, Some(true), None) && !rendered.is_empty() { let borrowed = get_wrapped_styled_block_lines(&rendered, width); @@ -1220,48 +1220,67 @@ fn render_single_message_internal(msg: &Message, width: usize) -> Vec<(Line<'sta lines.extend(convert_to_owned_lines(borrowed)); } MessageContent::RenderFullContentMessage(tool_call_result) => { - let title = get_command_type_name(&tool_call_result.call); - let command_args = extract_truncated_command_arguments(&tool_call_result.call, None); - let result = &tool_call_result.result; + let tool_name = crate::utils::strip_tool_name(&tool_call_result.call.function.name); - let spacing_marker = Line::from(vec![Span::from("SPACING_MARKER")]); - lines.push((spacing_marker.clone(), Style::default())); - - let dot_color = if tool_call_result.status == ToolCallResultStatus::Success { - Color::LightGreen + // For str_replace/create, use the diff view with proper line numbers + if (tool_name == "str_replace" || tool_name == "create") + && let Some(rendered) = render_file_diff_full( + &tool_call_result.call, + width, + Some(true), + Some(&tool_call_result.result), + ) + && !rendered.is_empty() + { + let borrowed = get_wrapped_styled_block_lines(&rendered, width); + lines.extend(convert_to_owned_lines(borrowed)); } else { - Color::Red - }; + // For other tools, show the raw result + let title = get_command_type_name(&tool_call_result.call); + let command_args = + extract_truncated_command_arguments(&tool_call_result.call, None); + let result = &tool_call_result.result; - let message_color = if tool_call_result.status == ToolCallResultStatus::Success { - AdaptiveColors::text() - } else { - Color::Red - }; + let spacing_marker = Line::from(vec![Span::from("SPACING_MARKER")]); + lines.push((spacing_marker.clone(), Style::default())); - let header_lines = crate::services::bash_block::render_styled_header_with_dot_public( - &title, - &command_args, - Some(crate::services::bash_block::LinesColors { - dot: dot_color, - title: Color::White, - command: AdaptiveColors::text(), - message: message_color, - }), - Some(width), - ); - for line in header_lines { - lines.push((convert_line_to_owned(line), Style::default())); - } + let dot_color = if tool_call_result.status == ToolCallResultStatus::Success { + Color::LightGreen + } else { + Color::Red + }; - lines.push((spacing_marker.clone(), Style::default())); + let message_color = if tool_call_result.status == ToolCallResultStatus::Success { + AdaptiveColors::text() + } else { + Color::Red + }; - let content_lines = format_text_content(result, width); - for line in content_lines { - lines.push((line, Style::default())); - } + let header_lines = + crate::services::bash_block::render_styled_header_with_dot_public( + &title, + &command_args, + Some(crate::services::bash_block::LinesColors { + dot: dot_color, + title: Color::White, + command: AdaptiveColors::text(), + message: message_color, + }), + Some(width), + ); + for line in header_lines { + lines.push((convert_line_to_owned(line), Style::default())); + } + + lines.push((spacing_marker.clone(), Style::default())); - lines.push((spacing_marker, Style::default())); + let content_lines = format_text_content(result, width); + for line in content_lines { + lines.push((line, Style::default())); + } + + lines.push((spacing_marker, Style::default())); + } } MessageContent::RenderEscapedTextBlock(content) => { let rendered = format_text_content(content, width); @@ -1782,7 +1801,7 @@ fn get_wrapped_message_lines_internal( let tool_name = crate::utils::strip_tool_name(&tool_call.function.name); if (tool_name == "str_replace" || tool_name == "create") && let Some(rendered_lines) = - render_file_diff_full(tool_call, width, Some(true)) + render_file_diff_full(tool_call, width, Some(true), None) && !rendered_lines.is_empty() { let borrowed_lines = get_wrapped_styled_block_lines(&rendered_lines, width); @@ -1821,53 +1840,72 @@ fn get_wrapped_message_lines_internal( all_lines.extend(owned_lines); } MessageContent::RenderFullContentMessage(tool_call_result) => { - // Full content view for popup - shows complete result without truncation - let title = crate::services::message::get_command_type_name(&tool_call_result.call); - let command_args = - extract_truncated_command_arguments(&tool_call_result.call, None); - let result = &tool_call_result.result; + let tool_name = crate::utils::strip_tool_name(&tool_call_result.call.function.name); - // Render header with dot - let spacing_marker = Line::from(vec![Span::from("SPACING_MARKER")]); - all_lines.push((spacing_marker.clone(), Style::default())); - - let dot_color = if tool_call_result.status == ToolCallResultStatus::Success { - Color::LightGreen + // For str_replace/create, use the diff view with proper line numbers + if (tool_name == "str_replace" || tool_name == "create") + && let Some(rendered_lines) = render_file_diff_full( + &tool_call_result.call, + width, + Some(true), + Some(&tool_call_result.result), + ) + && !rendered_lines.is_empty() + { + let borrowed_lines = get_wrapped_styled_block_lines(&rendered_lines, width); + let owned_lines = convert_to_owned_lines(borrowed_lines); + all_lines.extend(owned_lines); } else { - Color::Red - }; + // Full content view for popup - shows complete result without truncation + let title = + crate::services::message::get_command_type_name(&tool_call_result.call); + let command_args = + extract_truncated_command_arguments(&tool_call_result.call, None); + let result = &tool_call_result.result; + + // Render header with dot + let spacing_marker = Line::from(vec![Span::from("SPACING_MARKER")]); + all_lines.push((spacing_marker.clone(), Style::default())); + + let dot_color = if tool_call_result.status == ToolCallResultStatus::Success { + Color::LightGreen + } else { + Color::Red + }; - let message_color = if tool_call_result.status == ToolCallResultStatus::Success { - crate::services::detect_term::AdaptiveColors::text() - } else { - Color::Red - }; + let message_color = if tool_call_result.status == ToolCallResultStatus::Success + { + crate::services::detect_term::AdaptiveColors::text() + } else { + Color::Red + }; + + let header_lines = + crate::services::bash_block::render_styled_header_with_dot_public( + &title, + &command_args, + Some(crate::services::bash_block::LinesColors { + dot: dot_color, + title: Color::White, + command: crate::services::detect_term::AdaptiveColors::text(), + message: message_color, + }), + Some(width), + ); + for line in header_lines { + all_lines.push((convert_line_to_owned(line), Style::default())); + } - let header_lines = - crate::services::bash_block::render_styled_header_with_dot_public( - &title, - &command_args, - Some(crate::services::bash_block::LinesColors { - dot: dot_color, - title: Color::White, - command: crate::services::detect_term::AdaptiveColors::text(), - message: message_color, - }), - Some(width), - ); - for line in header_lines { - all_lines.push((convert_line_to_owned(line), Style::default())); - } + all_lines.push((spacing_marker.clone(), Style::default())); - all_lines.push((spacing_marker.clone(), Style::default())); + // Render full content + let content_lines = format_text_content(result, width); + for line in content_lines { + all_lines.push((line, Style::default())); + } - // Render full content - let content_lines = format_text_content(result, width); - for line in content_lines { - all_lines.push((line, Style::default())); + all_lines.push((spacing_marker, Style::default())); } - - all_lines.push((spacing_marker, Style::default())); } MessageContent::RenderEscapedTextBlock(content) => { let rendered_lines = format_text_content(content, width); From 4c2d76564420d167250bf8bbcc31bb23721d662a Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Sun, 25 Jan 2026 19:44:53 +0200 Subject: [PATCH 6/9] refactor: Replace `SPACING_MARKER` with an empty string when removing markdown closing tags. --- tui/src/services/markdown_renderer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/src/services/markdown_renderer.rs b/tui/src/services/markdown_renderer.rs index 1598f62d..49d55c41 100644 --- a/tui/src/services/markdown_renderer.rs +++ b/tui/src/services/markdown_renderer.rs @@ -1704,7 +1704,7 @@ fn xml_tags_to_markdown_headers(input: &str) -> String { if tag_name == "checkpoint_id" { caps[0].to_string() // Return the original closing tag unchanged } else { - "SPACING_MARKER".to_string() // Remove other closing tags + String::new() // Just remove other closing tags } }) .to_string(); From 364367f73753dc2ce2b5a8ed4ffc86c236164e44 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Sun, 25 Jan 2026 23:42:16 +0200 Subject: [PATCH 7/9] feat: add support for parsing multi-line markdown table rows. --- tui/src/services/markdown_renderer.rs | 41 ++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/tui/src/services/markdown_renderer.rs b/tui/src/services/markdown_renderer.rs index 49d55c41..5650e66c 100644 --- a/tui/src/services/markdown_renderer.rs +++ b/tui/src/services/markdown_renderer.rs @@ -916,7 +916,7 @@ impl MarkdownRenderer { } } else if c == '*' && chars.peek() == Some(&'*') { chars.next(); // consume second * - // Include content until next ** + // Include content until next ** while let Some(&next) = chars.peek() { if next == '*' { chars.next(); @@ -997,12 +997,45 @@ impl MarkdownRenderer { } // Parse table rows (even if there's no separator - for streaming compatibility) + // Handle wrapped/broken rows: if a line starts with | but doesn't end with |, + // concatenate subsequent lines until we find one ending with | while j < all_lines.len() && rows.len() < max_table_rows { let stripped_row_line = self.strip_line_number(all_lines[j]); - let row_line = stripped_row_line.trim(); + let mut row_line = stripped_row_line.trim().to_string(); - // Check if this is still a table row - if !row_line.starts_with('|') || !row_line.ends_with('|') { + // Check if this looks like a table row start + if !row_line.starts_with('|') { + break; + } + + // If line starts with | but doesn't end with |, it might be a wrapped row + // Try to reassemble it by concatenating subsequent lines + let mut lookahead = j + 1; + while !row_line.ends_with('|') && lookahead < all_lines.len() { + let next_stripped = self.strip_line_number(all_lines[lookahead]); + let next_line = next_stripped.trim(); + + // If next line starts with |, this is a new row, not a continuation + if next_line.starts_with('|') { + break; + } + + // Append continuation line (preserving space) + row_line.push_str(next_line); + lookahead += 1; + + // Safety limit to prevent infinite loops + if lookahead - j > 10 { + break; + } + } + + // Update j to skip any continuation lines we consumed + j = lookahead - 1; + + // Now check if we have a valid complete row + if !row_line.ends_with('|') { + // Still not a valid row after reassembly, stop parsing break; } From 0ea0fdbf52cd92c2ce8da87bfd43f6e8e0483044 Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Sun, 25 Jan 2026 23:52:29 +0200 Subject: [PATCH 8/9] fmt --- tui/src/services/markdown_renderer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/src/services/markdown_renderer.rs b/tui/src/services/markdown_renderer.rs index 5650e66c..7450128a 100644 --- a/tui/src/services/markdown_renderer.rs +++ b/tui/src/services/markdown_renderer.rs @@ -916,7 +916,7 @@ impl MarkdownRenderer { } } else if c == '*' && chars.peek() == Some(&'*') { chars.next(); // consume second * - // Include content until next ** + // Include content until next ** while let Some(&next) = chars.peek() { if next == '*' { chars.next(); From 888dcb02a1c0096402d57c98a2907f9ff8197d1c Mon Sep 17 00:00:00 2001 From: Mostafa Ashraf Date: Fri, 30 Jan 2026 14:48:48 +0200 Subject: [PATCH 9/9] feat: Add `cancel_requested` flag to prevent late streaming events after user cancellation and ensure proper UI state. --- tui/src/app.rs | 4 ++++ tui/src/event_loop.rs | 3 +++ tui/src/services/handlers/dialog.rs | 20 +++++++++++++++++--- tui/src/services/handlers/message.rs | 7 +++++++ tui/src/services/handlers/misc.rs | 10 ++++++++++ tui/src/services/handlers/tool.rs | 5 +++++ 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/tui/src/app.rs b/tui/src/app.rs index 3bc0890f..a8590c47 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -125,6 +125,9 @@ pub struct AppState { pub streaming_tool_result_id: Option, pub completed_tool_calls: std::collections::HashSet, pub is_streaming: bool, + /// When true, cancellation has been requested (ESC pressed) but the final ToolResult + /// hasn't arrived yet. Late StreamToolResult/StreamAssistantMessage events should be ignored. + pub cancel_requested: bool, pub latest_tool_call: Option, pub retry_attempts: usize, pub max_retry_attempts: usize, @@ -376,6 +379,7 @@ impl AppState { file_search_tx: Some(file_search_tx), file_search_rx: Some(result_rx), is_streaming: false, + cancel_requested: false, interactive_commands: crate::constants::INTERACTIVE_COMMANDS .iter() .map(|s| s.to_string()) diff --git a/tui/src/event_loop.rs b/tui/src/event_loop.rs index d932a17d..7e163afb 100644 --- a/tui/src/event_loop.rs +++ b/tui/src/event_loop.rs @@ -231,6 +231,9 @@ pub async fn run_tui( if let InputEvent::ToolResult(ref tool_call_result) = event { clear_streaming_tool_results(&mut state); + // Clear cancel_requested now that the final result has arrived + state.cancel_requested = false; + // For run_command, also remove any message that matches the tool call ID // (handles case where streaming message uses tool_call_id directly) // The tool call ID is a String, but message IDs are Uuid diff --git a/tui/src/services/handlers/dialog.rs b/tui/src/services/handlers/dialog.rs index c120aed2..9d59f537 100644 --- a/tui/src/services/handlers/dialog.rs +++ b/tui/src/services/handlers/dialog.rs @@ -157,6 +157,9 @@ pub fn handle_esc( let _ = cancel_tx.send(()); } + let was_streaming = state.is_streaming; + let was_dialog_open = state.is_dialog_open; + let was_shell_mode = state.show_shell_mode; state.is_streaming = false; if state.show_collapsed_messages { state.show_collapsed_messages = false; @@ -218,9 +221,6 @@ pub fn handle_esc( is_collapsed: None, }); } - // Invalidate cache and scroll to bottom to show the updated block - crate::services::message::invalidate_message_lines_cache(state); - state.stay_at_bottom = true; } state.is_dialog_open = false; state.dialog_command = None; @@ -279,6 +279,12 @@ pub fn handle_esc( super::shell::background_shell_session(state); } } else { + // No dialog, no shell — if streaming was active, this is a cancellation. + // Mark cancel_requested so late streaming events that are already queued + // in the channel get dropped instead of re-creating content. + if was_streaming { + state.cancel_requested = true; + } state.text_area.set_text(""); } @@ -286,6 +292,14 @@ pub fn handle_esc( m.id != state.streaming_tool_result_id.unwrap_or_default() && m.id != state.pending_bash_message_id.unwrap_or_default() }); + + // Invalidate cache and scroll to bottom when something was actually + // cancelled/rejected (dialog open, shell resolved, or streaming interrupted). + // Skip for idle ESC (just clearing text or closing a popup/dropdown). + if was_streaming || was_dialog_open || was_shell_mode { + crate::services::message::invalidate_message_lines_cache(state); + state.stay_at_bottom = true; + } } /// Handle show confirmation dialog event diff --git a/tui/src/services/handlers/message.rs b/tui/src/services/handlers/message.rs index 36d61d52..a578b746 100644 --- a/tui/src/services/handlers/message.rs +++ b/tui/src/services/handlers/message.rs @@ -20,6 +20,11 @@ pub fn handle_stream_message( s: String, message_area_height: usize, ) { + // Ignore late streaming events after cancellation was requested + if state.cancel_requested { + return; + } + if let Some(message) = state.messages.iter_mut().find(|m| m.id == id) { state.is_streaming = true; if !state.loading { @@ -112,6 +117,8 @@ pub fn handle_has_user_message(state: &mut AppState) { state.message_tool_calls = None; state.tool_call_execution_order.clear(); state.is_dialog_open = false; + // Clear any pending cancellation from a previous interaction + state.cancel_requested = false; } /// Handle stream usage event diff --git a/tui/src/services/handlers/misc.rs b/tui/src/services/handlers/misc.rs index c3e05622..ec982964 100644 --- a/tui/src/services/handlers/misc.rs +++ b/tui/src/services/handlers/misc.rs @@ -29,6 +29,10 @@ pub fn handle_error(state: &mut AppState, err: String) { return; } if err == "STREAM_CANCELLED" { + // Clear cancellation flag since we're now handling it + state.cancel_requested = false; + state.is_streaming = false; + let rendered_lines = render_bash_block_rejected("Interrupted by user", "System", None, None); state.messages.push(Message { @@ -36,6 +40,10 @@ pub fn handle_error(state: &mut AppState, err: String) { content: crate::services::message::MessageContent::StyledBlock(rendered_lines), is_collapsed: None, }); + + // Invalidate cache and scroll to bottom so the cancelled message is visible + crate::services::message::invalidate_message_lines_cache(state); + state.stay_at_bottom = true; return; } let mut error_message = handle_errors(err); @@ -306,6 +314,8 @@ pub fn handle_end_loading_operation(state: &mut AppState, operation: crate::app: /// Handle assistant message event pub fn handle_assistant_message(state: &mut AppState, msg: String) { + // Clear any pending cancellation since a new assistant message arrived + state.cancel_requested = false; state.messages.push(Message::assistant(None, msg, None)); // Invalidate cache since messages changed diff --git a/tui/src/services/handlers/tool.rs b/tui/src/services/handlers/tool.rs index 69112bbd..a92c4660 100644 --- a/tui/src/services/handlers/tool.rs +++ b/tui/src/services/handlers/tool.rs @@ -26,6 +26,11 @@ pub fn handle_stream_tool_result( return None; } + // Ignore late streaming events after cancellation was requested + if state.cancel_requested { + return None; + } + // Check for interactive stall notification const INTERACTIVE_STALL_MARKER: &str = "__INTERACTIVE_STALL__"; if progress.message.contains(INTERACTIVE_STALL_MARKER) {