Skip to content
Open
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
16 changes: 16 additions & 0 deletions tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ pub struct AppState {
pub messages: Vec<Message>,
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<usize>,
pub content_changed_while_scrolled_up: bool,
pub message_lines_cache: Option<MessageLinesCache>,
pub collapsed_message_lines_cache: Option<MessageLinesCache>,
Expand Down Expand Up @@ -89,6 +95,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<ShellCommand>,
Expand Down Expand Up @@ -117,6 +125,9 @@ pub struct AppState {
pub streaming_tool_result_id: Option<Uuid>,
pub completed_tool_calls: std::collections::HashSet<Uuid>,
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<ToolCall>,
pub retry_attempts: usize,
pub max_retry_attempts: usize,
Expand Down Expand Up @@ -306,7 +317,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,
Expand All @@ -331,6 +345,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,
Expand Down Expand Up @@ -364,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())
Expand Down
46 changes: 44 additions & 2 deletions tui/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,49 @@ 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 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 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 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), // 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;
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;
}
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
Expand Down Expand Up @@ -227,7 +262,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)
Expand Down Expand Up @@ -557,6 +594,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))?;
Expand Down
31 changes: 8 additions & 23 deletions tui/src/services/approval_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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;
}
}
Expand All @@ -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![
Expand Down
18 changes: 13 additions & 5 deletions tui/src/services/bash_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
result: Option<&str>,
) -> Option<Vec<Line<'static>>> {
// 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);

Expand Down Expand Up @@ -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<Vec<Line<'static>>> {
/// 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<Vec<Line<'static>>> {
// 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<Line<'static>> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1916,8 +1923,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
Expand Down
8 changes: 8 additions & 0 deletions tui/src/services/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,10 @@ pub fn resume_session(state: &mut AppState, output_tx: &Sender<OutputEvent>) {
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);

Expand Down Expand Up @@ -590,6 +594,10 @@ pub fn new_session(state: &mut AppState, output_tx: &Sender<OutputEvent>) {
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);

Expand Down
56 changes: 52 additions & 4 deletions tui/src/services/file_diff.rs
Original file line number Diff line number Diff line change
@@ -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<usize> {
// 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::<usize>().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<usize> {
// 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<Line<'static>>, Vec<Line<'static>>) {
// 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<usize>,
) -> (Vec<Line<'static>>, usize, usize, usize, usize) {
// Create a line-by-line diff directly from the strings
let diff = TextDiff::from_lines(old_str, new_str);
Expand All @@ -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<String> {
Expand Down Expand Up @@ -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<Line<'static>>, Vec<Line<'static>>) {
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
Expand All @@ -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![]);
Expand Down
Loading