fix(tui): tighten selection and live task panels

This commit is contained in:
Hunter Bown
2026-05-02 21:05:15 -05:00
parent 5bfc1feb62
commit 7125172f67
10 changed files with 224 additions and 121 deletions
+1 -1
View File
@@ -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
+23
View File
@@ -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"]);
+1 -1
View File
@@ -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
+11 -3
View File
@@ -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),
),
]));
+97 -103
View File
@@ -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<TaskPanelEntry> = 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<CrosstermBackend<Stdout>>,
@@ -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<TaskPanelEntry> =
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<TaskPanelEntry> =
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<String> {
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<usize> {
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 {
+51 -1
View File
@@ -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]
+31 -1
View File
@@ -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<usize> {
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,
@@ -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]
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -84,7 +84,7 @@ Run `deepseek --help` for the canonical list. Common flags:
- `-c, --continue`: resume the most recent session
- `--max-subagents <N>`: 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 <NAME>`: select config profile
- `--config <PATH>`: config file path
- `-v, --verbose`: verbose logging