From 7125172f678dc25f3cb37ca7eab61249ef6906e7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 2 May 2026 21:05:15 -0500 Subject: [PATCH] fix(tui): tighten selection and live task panels --- config.example.toml | 2 +- crates/tui/src/main.rs | 23 ++ crates/tui/src/tui/app.rs | 2 +- crates/tui/src/tui/sidebar.rs | 14 +- crates/tui/src/tui/ui.rs | 200 +++++++++--------- crates/tui/src/tui/ui/tests.rs | 52 ++++- crates/tui/src/tui/widgets/mod.rs | 32 ++- .../src/tui/widgets/pending_input_preview.rs | 16 +- docs/CONFIGURATION.md | 2 +- docs/MODES.md | 2 +- 10 files changed, 224 insertions(+), 121 deletions(-) diff --git a/config.example.toml b/config.example.toml index f8c454b9..fc3993c6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -165,7 +165,7 @@ max_subagents = 5 # optional (1-20) # ───────────────────────────────────────────────────────────────────────────────── [tui] alternate_screen = "auto" # auto | always | never -mouse_capture = true # true keeps wheel scrolling inside the TUI; false allows terminal-native drag selection/copy +mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy # ───────────────────────────────────────────────────────────────────────────────── # Feature Flags diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 840b6b5e..553f204f 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3376,6 +3376,29 @@ mod terminal_mode_tests { assert!(!should_use_mouse_capture(&cli, &config, true)); } + #[test] + fn mouse_capture_flag_enables_mouse_capture() { + let cli = parse_cli(&["deepseek", "--mouse-capture"]); + let config = Config::default(); + + assert!(should_use_mouse_capture(&cli, &config, true)); + } + + #[test] + fn config_can_enable_mouse_capture() { + let cli = parse_cli(&["deepseek"]); + let config = Config { + tui: Some(crate::config::TuiConfig { + alternate_screen: None, + mouse_capture: Some(true), + status_items: None, + }), + ..Config::default() + }; + + assert!(should_use_mouse_capture(&cli, &config, true)); + } + #[test] fn mouse_capture_is_off_without_alternate_screen() { let cli = parse_cli(&["deepseek", "--mouse-capture"]); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index ab292b1d..7dbc408c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2727,7 +2727,7 @@ impl App { } /// Pop the most-recently queued message back into the composer for editing - /// (issue #85 — Alt+↑ affordance). The popped message is parked in + /// (issue #85 — ↑ affordance). The popped message is parked in /// [`Self::queued_draft`] so the next Enter re-queues it carrying its /// original skill instruction. No-op if the composer already has typed /// content or a draft is already being edited — surfacing the affordance diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index c8bf86c0..a49d9269 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -280,7 +280,7 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { if app.task_panel.is_empty() { lines.push(Line::from(Span::styled( - "No tasks", + "No active tasks", Style::default().fg(palette::TEXT_MUTED), ))); } else { @@ -291,11 +291,19 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { .count(); lines.push(Line::from(vec![ Span::styled( - format!("{running} running"), + if running == app.task_panel.len() { + format!("{running} running") + } else { + format!("{} active", app.task_panel.len()) + }, Style::default().fg(palette::DEEPSEEK_SKY).bold(), ), Span::styled( - format!(" / {}", app.task_panel.len()), + if running == app.task_panel.len() { + String::new() + } else { + format!(" ({running} running)") + }, Style::default().fg(palette::TEXT_MUTED), ), ])); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 15d0cafa..12b8ec7b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -47,7 +47,9 @@ use crate::session_manager::{ OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager, create_saved_session_with_mode, update_session, }; -use crate::task_manager::{NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig}; +use crate::task_manager::{ + NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, +}; use crate::tools::spec::RuntimeToolServices; use crate::tools::subagent::SubAgentStatus; use crate::tui::command_palette::{ @@ -286,12 +288,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { active_task_id: None, active_thread_id: None, }; - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); + refresh_active_task_panel(&mut app, &task_manager).await; let engine_config = build_engine_config(&app, config); @@ -399,6 +396,33 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { } } +async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManager) { + let tasks = task_manager.list_tasks(None).await; + let mut entries: Vec = tasks + .into_iter() + .filter(|task| matches!(task.status, TaskStatus::Queued | TaskStatus::Running)) + .map(task_summary_to_panel_entry) + .collect(); + + if let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref() + && let Ok(mut mgr) = shell_mgr.lock() + { + for job in mgr.list_jobs() { + if !matches!(job.status, crate::tools::shell::ShellStatus::Running) { + continue; + } + entries.push(TaskPanelEntry { + id: job.id, + status: "running".to_string(), + prompt_summary: format!("shell: {}", job.command), + duration_ms: Some(job.elapsed_ms), + }); + } + } + + app.task_panel = entries; +} + #[allow(clippy::too_many_lines)] async fn run_event_loop( terminal: &mut Terminal>, @@ -433,32 +457,7 @@ async fn run_event_loop( } if last_task_refresh.elapsed() >= Duration::from_millis(2500) { - let tasks = task_manager.list_tasks(Some(10)).await; - let mut entries: Vec = - tasks.into_iter().map(task_summary_to_panel_entry).collect(); - - // #373: merge live shell jobs into the Tasks panel. - if let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref() - && let Ok(mut mgr) = shell_mgr.lock() - { - for job in mgr.list_jobs() { - let status = match job.status { - crate::tools::shell::ShellStatus::Running => "running", - crate::tools::shell::ShellStatus::Completed => "completed", - crate::tools::shell::ShellStatus::Failed => "failed", - crate::tools::shell::ShellStatus::Killed => "canceled", - crate::tools::shell::ShellStatus::TimedOut => "failed", - }; - entries.push(TaskPanelEntry { - id: job.id, - status: status.to_string(), - prompt_summary: format!("shell: {}", job.command), - duration_ms: Some(job.elapsed_ms), - }); - } - } - - app.task_panel = entries; + refresh_active_task_panel(app, &task_manager).await; last_task_refresh = Instant::now(); app.needs_redraw = true; } @@ -656,29 +655,7 @@ async fn run_event_loop( | "task_shell_start" | "exec_shell" ) { - let tasks = task_manager.list_tasks(Some(10)).await; - let mut entries: Vec = - tasks.into_iter().map(task_summary_to_panel_entry).collect(); - if let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref() - && let Ok(mut mgr) = shell_mgr.lock() - { - for job in mgr.list_jobs() { - let status = match job.status { - crate::tools::shell::ShellStatus::Running => "running", - crate::tools::shell::ShellStatus::Completed => "completed", - crate::tools::shell::ShellStatus::Failed => "failed", - crate::tools::shell::ShellStatus::Killed => "canceled", - crate::tools::shell::ShellStatus::TimedOut => "failed", - }; - entries.push(TaskPanelEntry { - id: job.id, - status: status.to_string(), - prompt_summary: format!("shell: {}", job.command), - duration_ms: Some(job.elapsed_ms), - }); - } - } - app.task_panel = entries; + refresh_active_task_panel(app, &task_manager).await; last_task_refresh = Instant::now(); } if matches!( @@ -1953,18 +1930,8 @@ async fn run_event_loop( } } }, - // #85: Alt+↑ pops the most-recent queued message back into the - // composer for editing when the preview's affordance is visible - // (queue non-empty, composer idle). Splits the binding into two - // arms so the legacy scroll fallback is unambiguous on the same - // chord. - KeyCode::Up - if key.modifiers.contains(KeyModifiers::ALT) - && app.input.is_empty() - && app.queued_draft.is_none() - && !app.queued_messages.is_empty() => - { - let _ = app.pop_last_queued_into_draft(); + KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => { + app.scroll_up(app.viewport.last_transcript_visible.max(3)); } KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_up(3); @@ -1999,6 +1966,23 @@ async fn run_event_loop( let _ = app.select_previous_composer_attachment(); continue; } + // #85: ↑ edits the most-recent queued message when the composer + // is idle and the pending-input preview is showing queued work. + KeyCode::Up + if key.modifiers.is_empty() + && app.input.is_empty() + && app.cursor_position == 0 + && app.queued_draft.is_none() + && !app.queued_messages.is_empty() + && !mention_menu_open + && !slash_menu_open + && app.selected_composer_attachment_index().is_none() => + { + let _ = app.pop_last_queued_into_draft(); + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::SUPER) => { + app.scroll_down(app.viewport.last_transcript_visible.max(3)); + } KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_down(3); } @@ -3574,20 +3558,11 @@ async fn apply_command_result( }); } } - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); + refresh_active_task_panel(app, task_manager).await; } AppAction::TaskList => { let tasks = task_manager.list_tasks(Some(30)).await; - app.task_panel = tasks - .iter() - .cloned() - .map(task_summary_to_panel_entry) - .collect(); + refresh_active_task_panel(app, task_manager).await; app.add_message(HistoryCell::System { content: format_task_list(&tasks), }); @@ -3613,12 +3588,7 @@ async fn apply_command_result( }); } } - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); + refresh_active_task_panel(app, task_manager).await; } AppAction::ShellJob(action) => { handle_shell_job_action(app, action); @@ -6450,10 +6420,7 @@ fn selection_point_from_position( } fn selection_has_content(app: &App) -> bool { - match app.viewport.transcript_selection.ordered_endpoints() { - Some((start, end)) => start != end, - None => false, - } + selection_to_text(app).is_some_and(|text| !text.is_empty()) } fn copy_active_selection(app: &mut App) { @@ -6481,25 +6448,52 @@ fn selection_to_text(app: &App) -> Option { let end_index = end.line_index.min(lines.len().saturating_sub(1)); let start_index = start.line_index.min(end_index); - let mut out = String::new(); + let mut selected_lines = Vec::new(); #[allow(clippy::needless_range_loop)] for line_index in start_index..=end_index { - let line_text = line_to_plain(&lines[line_index]); - let slice = if start_index == end_index { - slice_text(&line_text, start.column, end.column) - } else if line_index == start_index { - slice_text(&line_text, start.column, text_display_width(&line_text)) - } else if line_index == end_index { - slice_text(&line_text, 0, end.column) - } else { - line_text + let Some(body_start) = llm_io_selection_body_start(app, line_index) else { + continue; }; - out.push_str(&slice); - if line_index != end_index { - out.push('\n'); + let line_text = line_to_plain(&lines[line_index]); + let line_width = text_display_width(&line_text); + let (col_start, col_end) = if start_index == end_index { + (start.column, end.column) + } else if line_index == start_index { + (start.column, line_width) + } else if line_index == end_index { + (0, end.column) + } else { + (0, line_width) + }; + + if col_end <= body_start { + continue; } + let slice = slice_text(&line_text, col_start.max(body_start), col_end); + selected_lines.push(slice); + } + Some(selected_lines.join("\n")) +} + +fn llm_io_selection_body_start(app: &App, line_index: usize) -> Option { + const MESSAGE_BODY_START_COLUMN: usize = 2; + + let (filtered_cell_index, _) = app + .viewport + .transcript_cache + .line_meta() + .get(line_index)? + .cell_line()?; + let cell_index = app + .collapsed_cell_map + .get(filtered_cell_index) + .copied() + .unwrap_or(filtered_cell_index); + + match app.cell_at_virtual_index(cell_index)? { + HistoryCell::User { .. } | HistoryCell::Assistant { .. } => Some(MESSAGE_BODY_START_COLUMN), + _ => None, } - Some(out) } fn open_pager_for_selection(app: &mut App) -> bool { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index e7bf45d9..ce2ac4e3 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -94,7 +94,57 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() { column: 6, }); - assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\n gam")); + assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam")); +} + +#[test] +fn selection_to_text_only_copies_user_and_assistant_bodies() { + let mut app = create_test_app(); + app.history = vec![ + HistoryCell::System { + content: "skip system".to_string(), + }, + HistoryCell::User { + content: "copy user".to_string(), + }, + HistoryCell::Thinking { + content: "skip thinking".to_string(), + streaming: false, + duration_secs: Some(1.0), + }, + HistoryCell::Assistant { + content: "copy assistant".to_string(), + streaming: false, + }, + ]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 80, + app.transcript_render_options(), + ); + + app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint { + line_index: 0, + column: 0, + }); + app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint { + line_index: app + .viewport + .transcript_cache + .total_lines() + .saturating_sub(1), + column: 80, + }); + + let selected = selection_to_text(&app).expect("selection text"); + assert!(selected.contains("copy user"), "{selected:?}"); + assert!(selected.contains("copy assistant"), "{selected:?}"); + assert!(!selected.contains("skip system"), "{selected:?}"); + assert!(!selected.contains("skip thinking"), "{selected:?}"); + assert!(!selected.contains('▎'), "{selected:?}"); + assert!(!selected.contains('●'), "{selected:?}"); } #[test] diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 10905c73..5a5b09f6 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -46,6 +46,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const SEND_FLASH_DURATION: Duration = Duration::from_millis(500); const COMPOSER_PANEL_HEIGHT: u16 = 2; +const MESSAGE_SELECTION_BODY_START_COLUMN: usize = 2; pub struct ChatWidget { content_area: Rect, @@ -1301,8 +1302,11 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) { if line_index < start.line_index || line_index > end.line_index { continue; } + let Some(body_start) = llm_io_selection_body_start(app, line_index) else { + continue; + }; - let (col_start, col_end) = if start.line_index == end.line_index { + let (mut col_start, col_end) = if start.line_index == end.line_index { (start.column, end.column) } else if line_index == start.line_index { (start.column, usize::MAX) @@ -1312,6 +1316,11 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) { (0, usize::MAX) }; + if col_end <= body_start { + continue; + } + col_start = col_start.max(body_start); + if col_start == 0 && col_end == usize::MAX { for span in &mut line.spans { span.style = span.style.patch(selection_style); @@ -1323,6 +1332,27 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) { } } +fn llm_io_selection_body_start(app: &App, line_index: usize) -> Option { + let (filtered_cell_index, _) = app + .viewport + .transcript_cache + .line_meta() + .get(line_index)? + .cell_line()?; + let cell_index = app + .collapsed_cell_map + .get(filtered_cell_index) + .copied() + .unwrap_or(filtered_cell_index); + + match app.cell_at_virtual_index(cell_index)? { + HistoryCell::User { .. } | HistoryCell::Assistant { .. } => { + Some(MESSAGE_SELECTION_BODY_START_COLUMN) + } + _ => None, + } +} + fn apply_detail_target_highlight( lines: &mut [Line<'static>], top: usize, diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index 665a1949..cc3829db 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -26,16 +26,14 @@ use crate::tui::widgets::Renderable; const PREVIEW_LINE_LIMIT: usize = 3; /// Description of the keybinding the hint line at the bottom should advertise -/// for the "edit last queued message" action. The default `Alt+↑` matches -/// the chord we already wire up; callers can override for terminals where -/// Alt+ chords are eaten by the shell. +/// for the "edit last queued message" action. #[derive(Debug, Clone)] pub struct EditBinding { pub label: &'static str, } impl EditBinding { - pub const ALT_UP: EditBinding = EditBinding { label: "Alt+↑" }; + pub const UP: EditBinding = EditBinding { label: "↑" }; } /// Widget showing pending input while a turn is in progress. @@ -68,7 +66,7 @@ impl PendingInputPreview { pending_steers: Vec::new(), rejected_steers: Vec::new(), queued_messages: Vec::new(), - edit_binding: EditBinding::ALT_UP, + edit_binding: EditBinding::UP, } } @@ -389,7 +387,7 @@ mod tests { } #[test] - fn pending_steer_renders_without_esc_or_alt_up_hint() { + fn pending_steer_renders_without_queue_edit_hint() { let mut preview = PendingInputPreview::new(); preview.pending_steers.push("Please continue.".to_string()); let rows = render_to_string(&preview, 80); @@ -402,8 +400,8 @@ mod tests { "unexpected Esc hint: {rows:?}" ); assert!( - !rows.iter().any(|r| r.contains("Alt+↑")), - "unexpected Alt+↑ hint in pending-steer-only view: {rows:?}" + !rows.iter().any(|r| r.contains("edit last queued message")), + "unexpected edit hint in pending-steer-only view: {rows:?}" ); } @@ -422,7 +420,7 @@ mod tests { assert!(rows.iter().any(|r| r.contains("steer"))); assert!(rows.iter().any(|r| r.contains("rejected"))); assert!(rows.iter().any(|r| r.contains("queued"))); - assert!(rows.iter().any(|r| r.contains("Alt+↑"))); + assert!(rows.iter().any(|r| r.contains("↑"))); } #[test] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ddb97c40..bbe717a7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -271,7 +271,7 @@ If you are upgrading from older releases: - `[capacity].deepseek_v4_flash_prior` (float, default `4.2`) - `[capacity].fallback_default_prior` (float, default `3.8`) - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode. Set `never` or run with `--no-alt-screen` when you want real terminal scrollback. -- `tui.mouse_capture` (bool, optional, default `true` when the alternate screen is active): enable internal mouse scrolling, transcript selection, and right-click context actions. Set this to `false` or run with `--no-mouse-capture` for terminal-native drag selection and highlight-to-copy. +- `tui.mouse_capture` (bool, optional, default `true` when the alternate screen is active): enable internal mouse scrolling, transcript selection, and right-click context actions. TUI-owned drag selection copies only user/assistant transcript text. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection. - `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`). - `features.*` (optional): feature flag overrides (see below). diff --git a/docs/MODES.md b/docs/MODES.md index e89ccb49..2864640e 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -84,7 +84,7 @@ Run `deepseek --help` for the canonical list. Common flags: - `-c, --continue`: resume the most recent session - `--max-subagents `: clamp to `1..=20` - `--no-alt-screen`: run inline without the alternate screen buffer -- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, and right-click context actions. Mouse capture is enabled by default when the alternate screen is active; use `--no-mouse-capture` when you need terminal-native drag selection. +- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, and right-click context actions. Mouse capture is enabled by default so drag selection copies only user/assistant transcript text; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. - `--profile `: select config profile - `--config `: config file path - `-v, --verbose`: verbose logging