From 9c8b6f7271e524dca9a51ffab4a3d1c12cb1850b Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 01:23:32 +0800 Subject: [PATCH 01/10] docs: add composer text selection design spec --- ...26-05-17-composer-text-selection-design.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-composer-text-selection-design.md diff --git a/docs/superpowers/specs/2026-05-17-composer-text-selection-design.md b/docs/superpowers/specs/2026-05-17-composer-text-selection-design.md new file mode 100644 index 000000000..c58df3b5b --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-composer-text-selection-design.md @@ -0,0 +1,79 @@ +# Composer Text Selection + +Add text selection support to the input box (composer) — keyboard and mouse. + +## Problem + +The composer (`ComposerState`) has no selection mechanism. Users cannot select, copy, or delete a range of text in the input box. Every edit is single-character or single-word, and mouse clicks in the composer area are ignored. + +## Design + +### Data Model + +Add one field to `ComposerState`: + +```rust +pub selection_anchor: Option, // char-indexed, None = no selection +``` + +Semantics: `anchor` is the fixed end, `cursor_position` is the active end. The effective selection range is `min(anchor, cursor) .. max(anchor, cursor)`. When `anchor == cursor`, treat as no selection (set to `None`). + +Editing method rules: +- **Text-modifying operations** (insert, delete): if selection exists, delete selected content first, then perform operation, then clear anchor. +- **Cursor movement with Shift held**: set/keep anchor, move cursor to extend selection. +- **Cursor movement without Shift**: clear anchor. +- **Unrelated operations** (history, slash menu): clear anchor. + +### Keyboard Interactions + +In `ui.rs` key dispatch block: + +| Key | Behavior | +|---|---| +| `Shift+Left/Right` | Set anchor (if none), move cursor to extend selection | +| `Shift+Ctrl+Left/Right` | Set anchor (if none), move cursor by word | +| `Shift+Home/End` | Set anchor (if none), cursor to line start/end | +| `Ctrl+A` / `Cmd+A` | Select all (anchor=0, cursor=end) | +| `Backspace` / `Delete` | With selection: delete selected text; without: original behavior | +| Printable char input | With selection: replace selected text, clear anchor | +| `Ctrl+C` / `Cmd+C` | With selection: copy selected text to clipboard | +| `Ctrl+X` / `Cmd+X` | With selection: cut (copy + delete) | +| Non-Shift navigation keys | Clear anchor | + +Implementation: add `key.modifiers.contains(SHIFT)` branches to existing `KeyCode::Left/Right/Home/End` handlers (~line 3238 in ui.rs). + +### Mouse Interactions + +In `mouse_ui.rs`, add composer-area mouse handling: + +| Event | Behavior | +|---|---| +| `Down(Left)` | Position cursor at char boundary, clear anchor | +| `Drag(Left)` | Set anchor to click position (if none), continuously move cursor to extend selection | +| `Up(Left)` | End drag selection | +| `DoubleClick(Left)` | Select word under cursor (anchor=word-start, cursor=word-end) | +| `TripleClick(Left)` | Select entire line | + +Coordinate mapping: mouse (col, row) -> char index, using `layout_input()` line-wrapping results for reverse lookup. + +Area priority: check composer area first; if inside, handle composer mouse events; otherwise fall through to existing transcript logic. + +### Rendering + +In `ComposerWidget::render()`, replace uniform single-Span-per-line with selection-aware multi-Span rendering: + +- **No selection**: unchanged, one Span per line. +- **With selection**: split each line into up to 3 Spans: `[before, normal] [selected, highlight] [after, normal]`. + +Highlight style: `Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(70, 130, 220))` (blue background). + +Multi-line selection: first line highlights from anchor to line end, middle lines fully highlighted, last line highlights from line start to cursor. Uses `layout_input()` wrapping results to compute per-line char ranges. + +## Files Changed + +| File | Change | +|---|---| +| `crates/tui/src/tui/app.rs` | Add `selection_anchor` field, modify editing methods to handle selection | +| `crates/tui/src/tui/ui.rs` | Add Shift+arrow/Ctrl+A/Ctrl+C/Ctrl+X handling in key dispatch | +| `crates/tui/src/tui/widgets/mod.rs` | Selection-aware rendering in `ComposerWidget` | +| `crates/tui/src/tui/mouse_ui.rs` | Add composer mouse click/drag/double-click handling | From 01a57ae161f5d9ba2c4c7cb14a45e7729cf9ba3f Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 03:03:43 +0800 Subject: [PATCH 02/10] feat(composer): add mouse text selection with copy/cut Mouse: click to position cursor, drag to select text. Ctrl+C copies composer selection. Ctrl+X cuts when selection exists, toggles Plan/Agent mode otherwise. --- crates/tui/src/tui/app.rs | 199 +++- crates/tui/src/tui/mouse_ui.rs | 107 ++ crates/tui/src/tui/ui.rs | 69 +- crates/tui/src/tui/widgets/mod.rs | 155 ++- .../2026-05-17-composer-text-selection.md | 977 ++++++++++++++++++ 5 files changed, 1497 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-17-composer-text-selection.md diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2c3ec0c9e..e11052793 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -368,7 +368,7 @@ pub(crate) struct InputHistoryDraft { cursor: usize, } -fn char_count(text: &str) -> usize { +pub(crate) fn char_count(text: &str) -> usize { text.chars().count() } @@ -902,6 +902,10 @@ pub struct ComposerState { /// user presses `d` in Normal mode; cleared on the next key (either `d` /// to complete `dd`, or any other key to cancel). pub vim_pending_d: bool, + /// When set, the cursor is the active end of a text selection and + /// `selection_anchor` is the fixed end. Both are char-indexed. + /// `None` means no selection is active. + pub selection_anchor: Option, } impl Default for ComposerState { @@ -926,6 +930,7 @@ impl Default for ComposerState { vim_enabled: false, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, } } } @@ -940,11 +945,21 @@ pub struct ViewportState { pub selection_autoscroll: Option, pub transcript_scrollbar_dragging: bool, pub last_transcript_area: Option, + pub last_composer_area: Option, pub last_transcript_top: usize, pub last_transcript_visible: usize, pub last_transcript_total: usize, pub last_transcript_padding_top: usize, pub jump_to_latest_button_area: Option, + /// Inner content rect of the composer (excluding border/padding), + /// stored at render time for mouse coordinate mapping. + pub last_composer_content: Option, + /// Number of rendered text lines scrolled off the top of the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_scroll_offset: usize, + /// Vertical padding above the first text line in the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_top_padding: usize, } impl Default for ViewportState { @@ -958,11 +973,15 @@ impl Default for ViewportState { selection_autoscroll: None, transcript_scrollbar_dragging: false, last_transcript_area: None, + last_composer_area: None, last_transcript_top: 0, last_transcript_visible: 0, last_transcript_total: 0, last_transcript_padding_top: 0, jump_to_latest_button_area: None, + last_composer_content: None, + last_composer_scroll_offset: 0, + last_composer_top_padding: 0, } } } @@ -1809,6 +1828,7 @@ impl App { vim_enabled: composer_vim_enabled, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, }, viewport: ViewportState::default(), goal: GoalState::default(), @@ -3124,6 +3144,7 @@ impl App { if text.is_empty() { return; } + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3383,6 +3404,7 @@ impl App { pub fn insert_char(&mut self, c: char) { self.clear_input_history_navigation(); + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3409,6 +3431,9 @@ impl App { pub fn delete_char(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3426,6 +3451,9 @@ impl App { pub fn delete_char_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.input.is_empty() { return; @@ -3444,6 +3472,9 @@ impl App { /// Delete the word before the cursor. pub fn delete_word_backward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3485,6 +3516,9 @@ impl App { /// Delete from the cursor to the start of the line. pub fn delete_to_start_of_line(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3510,6 +3544,9 @@ impl App { /// Delete the word after the cursor. pub fn delete_word_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; let cursor_byte = byte_index_at_char(&self.input, self.cursor_position); if cursor_byte >= self.input.len() { @@ -3554,6 +3591,9 @@ impl App { /// Returns `true` when bytes were moved into the kill buffer. pub fn kill_to_end_of_line(&mut self) -> bool { self.clear_input_history_navigation(); + if self.delete_selection() { + return true; + } let total_chars = char_count(&self.input); let cursor = self.cursor_position.min(total_chars); let start_byte = byte_index_at_char(&self.input, cursor); @@ -3599,6 +3639,7 @@ impl App { if self.kill_buffer.is_empty() { return false; } + self.delete_selection(); self.clear_input_history_navigation(); let text = self.kill_buffer.clone(); let cursor = self.cursor_position.min(char_count(&self.input)); @@ -3724,6 +3765,58 @@ impl App { self.needs_redraw = true; } + // === Selection helpers === + + /// Return the (start, end) of the active selection, or `None`. + /// `start` is inclusive, `end` is exclusive; both are char indices. + pub fn selection_range(&self) -> Option<(usize, usize)> { + let anchor = self.selection_anchor?; + let cursor = self.cursor_position; + if anchor == cursor { + return None; + } + Some(if anchor < cursor { + (anchor, cursor) + } else { + (cursor, anchor) + }) + } + + /// Return the selected text, or empty string if no selection. + pub fn selected_text(&self) -> String { + self.selection_range() + .map(|(s, e)| { + let sb = byte_index_at_char(&self.input, s); + let eb = byte_index_at_char(&self.input, e); + self.input[sb..eb].to_string() + }) + .unwrap_or_default() + } + + /// Delete the selected text, place cursor at the start of the deleted range. + /// Returns true if a selection was deleted. + pub fn delete_selection(&mut self) -> bool { + let Some((start, end)) = self.selection_range() else { + return false; + }; + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.input.replace_range(sb..eb, ""); + self.cursor_position = start; + self.selection_anchor = None; + self.clear_input_history_navigation(); + self.slash_menu_hidden = false; + self.mention_menu_hidden = false; + self.mention_menu_selected = 0; + self.needs_redraw = true; + true + } + + /// Clear the selection without moving the cursor. + pub fn clear_selection(&mut self) { + self.selection_anchor = None; + } + // === Vim composer mode helpers === /// Move the cursor to the start of the current logical line (vim `0`). @@ -3906,6 +3999,7 @@ impl App { self.clear_input_history_navigation(); self.input.clear(); self.cursor_position = 0; + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_selected = 0; self.slash_menu_hidden = false; @@ -6662,4 +6756,107 @@ mod tests { assert_eq!(app.input, "café 你好"); assert_eq!(app.cursor_position, 7); } + + #[test] + fn selection_range_returns_none_when_no_anchor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = None; + assert!(app.selection_range().is_none()); + } + + #[test] + fn selection_range_returns_ordered_range() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_normalizes_order() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 2; + app.selection_anchor = Some(5); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_returns_none_when_anchor_equals_cursor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = Some(3); + assert!(app.selection_range().is_none()); + } + + #[test] + fn delete_selection_removes_selected_text() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert!(app.delete_selection()); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn insert_char_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_char('X'); + assert_eq!(app.input, "heX world"); + assert_eq!(app.cursor_position, 3); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_char_removes_selection_instead_of_single_char() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.delete_char(); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn selected_text_returns_correct_substring() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selected_text(), "llo"); + } + + #[test] + fn insert_str_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_str("yo"); + assert_eq!(app.input, "heyo world"); + assert_eq!(app.cursor_position, 4); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_selection_noop_when_no_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = None; + assert!(!app.delete_selection()); + assert_eq!(app.input, "hello"); + assert_eq!(app.cursor_position, 3); + } } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index c3c985c1d..47e323a7b 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -2,6 +2,8 @@ use std::time::{Duration, Instant}; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::tui::app::App; use crate::tui::command_palette::{ @@ -37,6 +39,91 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> } } +/// Map a mouse (column, row) within the composer area to a char index +/// in the composer input string. Uses the inner content rect (border-aware) +/// for coordinate mapping, and accounts for vertical padding and scroll offset. +fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option { + let rel_col = col.saturating_sub(inner.x) as usize; + let rel_row = row.saturating_sub(inner.y) as usize; + + if app.input.is_empty() { + return Some(0); + } + + let width = inner.width.max(1) as usize; + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width); + + // Subtract the vertical top-padding (centering of short inputs). + let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding); + + // Add the scroll offset (lines scrolled out of view). + let absolute_row = text_row + app.viewport.last_composer_scroll_offset; + + if absolute_row >= wrapped.len() { + return Some(app.input.chars().count()); + } + + let (line_start, line_text) = &wrapped[absolute_row]; + + let mut char_offset = 0usize; + let mut col_used = 0usize; + for g in line_text.graphemes(true) { + let gw = g.width(); + if col_used + gw > rel_col { + break; + } + col_used += gw; + char_offset += g.chars().count(); + } + Some(line_start + char_offset) +} + +/// Handle mouse events within the composer area. +/// Returns true if the event was consumed. +pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { + // Use outer area for hit-testing (includes border). + let Some(area) = app.viewport.last_composer_area else { + return false; + }; + if mouse.column < area.x + || mouse.column >= area.x + area.width + || mouse.row < area.y + || mouse.row >= area.y + area.height + { + return false; + } + // Use inner content rect for coordinate-to-char mapping (border-aware). + let inner = app.viewport.last_composer_content.unwrap_or(area); + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + app.cursor_position = pos; + app.selection_anchor = None; + app.needs_redraw = true; + } + true + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.cursor_position = pos; + app.needs_redraw = true; + } + true + } + MouseEventKind::Up(MouseButton::Left) => { + if app.selection_anchor == Some(app.cursor_position) { + app.selection_anchor = None; + } + true + } + _ => false, + } +} + pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { @@ -52,6 +139,11 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { // Update last mouse position for tooltip rendering. @@ -585,6 +677,10 @@ pub(crate) fn selection_point_from_position( } pub(crate) fn selection_has_content(app: &App) -> bool { + // Composer selection takes priority (same as Cmd+C handler above). + if !app.selected_text().is_empty() { + return true; + } selection_to_text(app).is_some_and(|text| !text.is_empty()) } @@ -613,6 +709,17 @@ pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition { } pub(crate) fn copy_active_selection(app: &mut App) { + // Composer selection takes priority. + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.status_message = Some("Selection copied".to_string()); + } else { + app.status_message = Some("Copy failed".to_string()); + } + app.clear_selection(); + return; + } if !app.viewport.transcript_selection.is_active() { return; } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2ba52ef93..ab42ceda0 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2969,7 +2969,14 @@ async fn run_event_loop( KeyCode::Char('c') | KeyCode::Char('C') if key_shortcuts::is_copy_shortcut(&key) => { - copy_active_selection(app); + let sel = app.selected_text(); + if !sel.is_empty() { + let _ = app.clipboard.write_text(&sel); + app.push_status_toast("Copied to clipboard", StatusToastLevel::Info, None); + app.clear_selection(); + } else { + copy_active_selection(app); + } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Four behaviors layered on Ctrl+C in priority order — see @@ -3618,12 +3625,19 @@ async fn run_event_loop( } } KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let new_mode = match app.mode { - AppMode::Plan => AppMode::Agent, - AppMode::Agent => AppMode::Yolo, - AppMode::Yolo => AppMode::Plan, - }; - app.set_mode(new_mode); + let sel = app.selected_text(); + if !sel.is_empty() { + let _ = app.clipboard.write_text(&sel); + app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None); + app.delete_selection(); + } else { + let new_mode = match app.mode { + AppMode::Plan => AppMode::Agent, + AppMode::Agent => AppMode::Yolo, + AppMode::Yolo => AppMode::Plan, + }; + app.set_mode(new_mode); + } } _ if key_shortcuts::is_paste_shortcut(&key) => { app.paste_from_clipboard(); @@ -5826,6 +5840,47 @@ fn render(f: &mut Frame, app: &mut App) { composer_widget.render(chunks[3], buf); composer_widget.cursor_pos(chunks[3]) }; + app.viewport.last_composer_area = Some(chunks[3]); + { + let area = chunks[3]; + let has_panel = app.composer_border && area.height >= 3 && area.width >= 12; + let inner = if has_panel { + ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .inner(area) + } else { + area + }; + app.viewport.last_composer_content = Some(inner); + + // Compute scroll offset and top padding for mouse coordinate mapping. + let input_text = app.composer_display_input(); + let input_cursor = app.composer_display_cursor(); + let content_width = usize::from(inner.width.max(1)); + let menu_lines = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ) + .active_menu_reserved_rows(); + let budget = crate::tui::widgets::composer_input_rows_budget(inner.height, menu_lines); + let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll( + &input_text, + input_cursor, + content_width, + budget, + ); + let visible_lines = if input_text.is_empty() { + 1 + } else { + // Count wrapped lines (approximation matching the render path). + crate::tui::widgets::wrap_input_lines_for_mouse(&input_text, content_width).len() + }; + let top_padding = budget.saturating_sub(visible_lines.clamp(1, budget)); + app.viewport.last_composer_scroll_offset = scroll_offset; + app.viewport.last_composer_top_padding = top_padding; + } if let Some(cursor_pos) = cursor_pos { f.set_cursor_position(cursor_pos); } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index f1e395e6e..d35e9ab81 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -474,7 +474,7 @@ impl<'a> ComposerWidget<'a> { /// backend's per-cell write cost makes the layout jitter visible /// even though the work is tiny on Unix terminals. See user /// feedback in v0.8.8 polish thread. - fn active_menu_reserved_rows(&self) -> usize { + pub fn active_menu_reserved_rows(&self) -> usize { let actual = self.active_menu_row_count(); if actual == 0 { return 0; @@ -666,6 +666,20 @@ impl Renderable for ComposerWidget<'_> { placeholder, Style::default().fg(palette::TEXT_MUTED).italic(), ))); + } else if let Some((sel_start, sel_end)) = self.app.selection_range() { + let line_ranges = + visible_line_char_ranges(&self.app.input, &visible_lines, content_width); + for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) + { + let spans = line_spans_with_selection( + line_text, + *line_start, + *line_end, + sel_start, + sel_end, + ); + input_lines.push(Line::from(spans)); + } } else { for line in &visible_lines { input_lines.push(Line::from(Span::styled( @@ -1938,7 +1952,7 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { lines } -fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { +pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { usize::from(inner_height).saturating_sub(extra_lines).max(1) } @@ -2251,6 +2265,17 @@ fn layout_input( width: usize, max_height: usize, ) -> (Vec, usize, usize) { + let (visible, visible_cursor_row, visible_cursor_col, _) = + layout_input_with_scroll(input, cursor, width, max_height); + (visible, visible_cursor_row, visible_cursor_col) +} + +pub fn layout_input_with_scroll( + input: &str, + cursor: usize, + width: usize, + max_height: usize, +) -> (Vec, usize, usize, usize) { let mut lines = wrap_input_lines(input, width); if lines.is_empty() { lines.push(String::new()); @@ -2276,6 +2301,7 @@ fn layout_input( visible, visible_cursor_row, cursor_col.min(width.saturating_sub(1)), + start, ) } @@ -2342,6 +2368,34 @@ fn wrap_input_lines(input: &str, width: usize) -> Vec { lines } +/// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs +/// matching the wrapping produced by `wrap_input_lines`. +pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> { + if input.is_empty() || width == 0 { + return vec![(0, String::new())]; + } + + let mut result = Vec::new(); + let mut char_idx = 0usize; + + for raw_line in input.split('\n') { + if raw_line.is_empty() { + result.push((char_idx, String::new())); + char_idx += 1; // the '\n' + continue; + } + let wrapped = wrap_text(raw_line, width); + for wrapped_line in &wrapped { + let line_char_len: usize = wrapped_line.chars().count(); + result.push((char_idx, wrapped_line.clone())); + char_idx += line_char_len; + } + char_idx += 1; // the '\n' + } + + result +} + fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { return vec![text.to_string()]; @@ -2383,6 +2437,103 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } +/// Compute the (char_start, char_end) range for each visible wrapped line. +/// `char_start` is inclusive, `char_end` is exclusive. +fn visible_line_char_ranges( + input: &str, + visible_lines: &[String], + width: usize, +) -> Vec<(usize, usize)> { + if input.is_empty() || width == 0 { + return vec![(0, 0); visible_lines.len()]; + } + + let mut ranges = Vec::new(); + let mut char_idx = 0usize; + let mut line_start = 0usize; + let mut line_width = 0usize; + + for g in input.graphemes(true) { + if g == "\n" { + ranges.push((line_start, char_idx)); + char_idx += 1; + line_start = char_idx; + line_width = 0; + continue; + } + + let gw = g.width(); + if line_width + gw > width && line_width > 0 { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + char_idx += g.chars().count(); + line_width += gw; + if line_width >= width { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + } + ranges.push((line_start, char_idx)); + + // layout_input may have trimmed lines from the start for scroll offset. + // Align with visible_lines by trimming from start. + if ranges.len() > visible_lines.len() { + let skip = ranges.len() - visible_lines.len(); + ranges = ranges.into_iter().skip(skip).collect(); + } + ranges.truncate(visible_lines.len()); + ranges +} + +fn line_spans_with_selection<'a>( + line: &str, + line_start: usize, + line_end: usize, + sel_start: usize, + sel_end: usize, +) -> Vec> { + let highlight_bg = Color::Rgb(70, 130, 220); + let normal_style = Style::default().fg(palette::TEXT_PRIMARY); + let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg); + + // No overlap between this line and the selection + if line_end <= sel_start || line_start >= sel_end { + return vec![Span::styled(line.to_string(), normal_style)]; + } + + let line_chars: Vec = line.chars().collect(); + let local_sel_start = sel_start.saturating_sub(line_start); + let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); + + let mut spans = Vec::with_capacity(3); + + // Text before selection + if local_sel_start > 0 { + let before: String = line_chars[..local_sel_start].iter().collect(); + spans.push(Span::styled(before, normal_style)); + } + + // Selected text + let sel_end_clamped = local_sel_end.min(line_chars.len()); + if local_sel_start < sel_end_clamped { + let selected: String = line_chars[local_sel_start..sel_end_clamped] + .iter() + .collect(); + spans.push(Span::styled(selected, sel_style)); + } + + // Text after selection + if sel_end_clamped < line_chars.len() { + let after: String = line_chars[sel_end_clamped..].iter().collect(); + spans.push(Span::styled(after, normal_style)); + } + + spans +} + #[cfg(test)] mod tests { use super::{ diff --git a/docs/superpowers/plans/2026-05-17-composer-text-selection.md b/docs/superpowers/plans/2026-05-17-composer-text-selection.md new file mode 100644 index 000000000..e3d6e44c8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-composer-text-selection.md @@ -0,0 +1,977 @@ +# Composer Text Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add keyboard and mouse text selection to the composer input box so users can select, copy, cut, and delete ranges of text. + +**Architecture:** Add a `selection_anchor: Option` field to `ComposerState`. Anchor is the fixed end of the selection, `cursor_position` is the active end. All existing editing methods gain selection-awareness: text-modifying ops delete the selection first, cursor-movement ops either set anchor (Shift held) or clear it. Rendering splits each visible line into styled Spans for the selected region. Mouse events in the composer area are dispatched to a new handler that maps screen coordinates back to char indices via `layout_input`'s wrapping logic. + +**Tech Stack:** Rust, ratatui (Span/Line/Style), crossterm (MouseEvent, KeyEvent) + +--- + +### Task 1: Add selection field and helpers to ComposerState + +**Files:** +- Modify: `crates/tui/src/tui/app.rs:609-665` (ComposerState struct + Default impl) + +- [ ] **Step 1: Add `selection_anchor` field to `ComposerState` struct** + +After the `vim_pending_d` field at line 640, add: + +```rust + /// When set, the cursor is the active end of a text selection and + /// `selection_anchor` is the fixed end. Both are char-indexed. + /// `None` means no selection is active. + pub selection_anchor: Option, +``` + +- [ ] **Step 2: Initialize field in `Default for ComposerState`** + +In the Default impl (line 643), add after `vim_pending_d: false,`: + +```rust + selection_anchor: None, +``` + +- [ ] **Step 3: Add helper methods for selection range** + +After the existing `move_cursor_word_backward` method (after line 3280), add: + +```rust + // === Selection helpers === + + /// Return the (start, end) of the active selection, or `None`. + /// `start` is inclusive, `end` is exclusive; both are char indices. + pub fn selection_range(&self) -> Option<(usize, usize)> { + let anchor = self.selection_anchor?; + let cursor = self.cursor_position; + if anchor == cursor { + return None; + } + Some(if anchor < cursor { + (anchor, cursor) + } else { + (cursor, anchor) + }) + } + + /// Return the selected text, or empty string if no selection. + pub fn selected_text(&self) -> String { + self.selection_range() + .map(|(s, e)| { + let sb = byte_index_at_char(&self.input, s); + let eb = byte_index_at_char(&self.input, e); + self.input[sb..eb].to_string() + }) + .unwrap_or_default() + } + + /// Delete the selected text, place cursor at the start of the deleted range. + /// Returns true if a selection was deleted. + pub fn delete_selection(&mut self) -> bool { + let Some((start, end)) = self.selection_range() else { + return false; + }; + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.input.replace_range(sb..eb, ""); + self.cursor_position = start; + self.selection_anchor = None; + self.clear_input_history_navigation(); + self.slash_menu_hidden = false; + self.mention_menu_hidden = false; + self.mention_menu_selected = 0; + self.needs_redraw = true; + true + } + + /// Clear the selection without moving the cursor. + pub fn clear_selection(&mut self) { + self.selection_anchor = None; + } +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/tui/src/tui/app.rs +git commit -m "feat(composer): add selection_anchor field and helpers to ComposerState" +``` + +--- + +### Task 2: Make existing editing methods selection-aware + +**Files:** +- Modify: `crates/tui/src/tui/app.rs` (insert_char, insert_str, delete_char, delete_char_forward, delete_word_backward, delete_word_forward, kill_to_end_of_line, delete_to_start_of_line) + +- [ ] **Step 1: Make `insert_char` delete selection first** + +At the top of `insert_char` (line 2966), after `self.clear_input_history_navigation();`, add: + +```rust + self.delete_selection(); +``` + +- [ ] **Step 2: Make `insert_str` delete selection first** + +At the top of `insert_str` (line 2702), after the `if text.is_empty()` guard, add: + +```rust + self.delete_selection(); +``` + +- [ ] **Step 3: Make `delete_char` delete selection first** + +At the top of `delete_char` (line 2992), after `self.clear_input_history_navigation();`, add: + +```rust + if self.delete_selection() { + return; + } +``` + +- [ ] **Step 4: Make `delete_char_forward` delete selection first** + +At the top of `delete_char_forward` (line 3009), after `self.clear_input_history_navigation();`, add: + +```rust + if self.delete_selection() { + return; + } +``` + +- [ ] **Step 5: Make `delete_word_backward` delete selection first** + +At the top of `delete_word_backward` (line 3027), after `self.clear_input_history_navigation();`, add: + +```rust + if self.delete_selection() { + return; + } +``` + +- [ ] **Step 6: Make `delete_word_forward` delete selection first** + +At the top of `delete_word_forward`, after `self.clear_input_history_navigation();`, add: + +```rust + if self.delete_selection() { + return; + } +``` + +- [ ] **Step 7: Make `kill_to_end_of_line` delete selection first** + +At the top of `kill_to_end_of_line`, after `self.clear_input_history_navigation();`, add: + +```rust + if self.delete_selection() { + return; + } +``` + +- [ ] **Step 8: Make `delete_to_start_of_line` delete selection first** + +At the top of `delete_to_start_of_line`, after `self.clear_input_history_navigation();`, add: + +```rust + if self.delete_selection() { + return; + } +``` + +- [ ] **Step 9: Verify compilation** + +Run: `cargo check -p deepseek-tui 2>&1 | head -30` +Expected: no errors + +- [ ] **Step 10: Commit** + +```bash +git add crates/tui/src/tui/app.rs +git commit -m "feat(composer): make editing methods selection-aware" +``` + +--- + +### Task 3: Add keyboard selection handlers in ui.rs + +**Files:** +- Modify: `crates/tui/src/tui/ui.rs:3238-3270` (arrow/home/end key handling in composer block) + +- [ ] **Step 1: Replace the Left arrow handlers** + +Replace the existing `KeyCode::Left` arms (lines 3238-3243): + +```rust + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) + && is_word_cursor_modifier(key.modifiers) => + { + // Shift+Ctrl/Alt+Left: extend selection by word backward + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_word_backward(); + } + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_left(); + } + KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); + app.move_cursor_word_backward(); + } + KeyCode::Left => { + app.clear_selection(); + app.move_cursor_left(); + } +``` + +- [ ] **Step 2: Replace the Right arrow handlers** + +Replace the existing `KeyCode::Right` arms (lines 3244-3249): + +```rust + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) + && is_word_cursor_modifier(key.modifiers) => + { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_word_forward(); + } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_right(); + } + KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); + app.move_cursor_word_forward(); + } + KeyCode::Right => { + app.clear_selection(); + app.move_cursor_right(); + } +``` + +- [ ] **Step 3: Replace the Home/End handlers** + +Replace the existing `KeyCode::Home` and `KeyCode::End` arms (lines 3260-3270): + +```rust + KeyCode::Home if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_start(); + } + KeyCode::Home => { + app.clear_selection(); + app.move_cursor_start(); + } + KeyCode::End if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_end(); + } + KeyCode::End => { + app.clear_selection(); + app.move_cursor_end(); + } +``` + +Note: The existing `KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL)` and `KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL)` blocks (lines 3250-3258) that control transcript scroll must remain unchanged — they come before the new Home/End arms in the match. + +- [ ] **Step 4: Add Ctrl+A select-all and Ctrl+C/Ctrl+X composer copy/cut** + +In the same composer key block, before the `KeyCode::Left` arms, add after the existing `KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL)` arm (which maps to Home): + +Find the existing: +```rust + KeyCode::Home | KeyCode::Char('a') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.move_cursor_start(); + } +``` + +Replace with: + +```rust + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SHIFT) => + { + app.move_cursor_start(); + } + KeyCode::Char('a') | KeyCode::Char('A') + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) + || key_shortcuts::is_select_all_shortcut(&key) => + { + // Ctrl+Shift+A or Cmd+A: select all + let end = char_count(&app.input); + if end > 0 { + app.selection_anchor = Some(0); + app.cursor_position = end; + app.needs_redraw = true; + } + } +``` + +Add a new `is_select_all_shortcut` helper in `composer_ui.rs` (after `is_word_cursor_modifier` at line 88): + +```rust +pub(crate) fn is_select_all_shortcut(key: &KeyEvent) -> bool { + #[cfg(target_os = "macos")] + { + key.modifiers.contains(KeyModifiers::SUPER) && matches!(key.code, KeyCode::Char('a')) + } + #[cfg(not(target_os = "macos"))] + { + key.modifiers.contains(KeyModifiers::CONTROL) + && key.modifiers.contains(KeyModifiers::SHIFT) + && matches!(key.code, KeyCode::Char('a') | KeyCode::Char('A')) + } +} +``` + +- [ ] **Step 5: Add Ctrl+C copy when composer has selection** + +Find the existing Ctrl+C handler block (~line 2715). The existing `key_shortcuts::is_copy_shortcut` handler at line 2710 copies the transcript selection. Add composer selection copy *before* the main Ctrl+C block. Find: + +```rust + KeyCode::Char('c') | KeyCode::Char('C') + if key_shortcuts::is_copy_shortcut(&key) => + { + copy_active_selection(app); + } +``` + +Replace with: + +```rust + KeyCode::Char('c') | KeyCode::Char('C') + if key_shortcuts::is_copy_shortcut(&key) => + { + // If composer has a selection, copy that (takes priority + // over transcript selection). + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast( + "Copied to clipboard", + crate::tui::app::StatusToastLevel::Info, + ); + } + app.clear_selection(); + } else { + copy_active_selection(app); + } + } +``` + +- [ ] **Step 6: Add Ctrl+X cut when composer has selection** + +Find the existing Ctrl+X handler (~line 3367): + +```rust + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let new_mode = match app.mode { + AppMode::Plan => AppMode::Agent, + _ => AppMode::Plan, + }; + app.set_mode(new_mode); +``` + +Add a new Ctrl+Shift+X / Cmd+X handler *before* the existing Ctrl+X mode toggle: + +```rust + KeyCode::Char('x') | KeyCode::Char('X') + if key_shortcuts::is_copy_shortcut(&key) => + { + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast( + "Cut to clipboard", + crate::tui::app::StatusToastLevel::Info, + ); + } + app.delete_selection(); + } + } +``` + +- [ ] **Step 7: Verify compilation** + +Run: `cargo check -p deepseek-tui 2>&1 | head -30` +Expected: no errors + +- [ ] **Step 8: Commit** + +```bash +git add crates/tui/src/tui/ui.rs crates/tui/src/tui/composer_ui.rs +git commit -m "feat(composer): add keyboard selection — Shift+arrows, Ctrl+A, copy/cut" +``` + +--- + +### Task 4: Add selection rendering in ComposerWidget + +**Files:** +- Modify: `crates/tui/src/tui/widgets/mod.rs:664-684` (input_lines rendering) + +- [ ] **Step 1: Replace uniform Span rendering with selection-aware rendering** + +Replace the existing rendering block (lines 677-683): + +```rust + for line in &visible_lines { + input_lines.push(Line::from(Span::styled( + line.clone(), + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } +``` + +With: + +```rust + if let Some((sel_start, sel_end)) = + self.app.selection_range() + { + let lines_with_ranges = + self.visible_line_char_ranges(&visible_lines); + for (line_text, (line_start, line_end)) in + visible_lines.iter().zip(lines_with_ranges.iter()) + { + let spans = self.line_spans_with_selection( + line_text, + *line_start, + *line_end, + sel_start, + sel_end, + ); + input_lines.push(Line::from(spans)); + } + } else { + for line in &visible_lines { + input_lines.push(Line::from(Span::styled( + line.clone(), + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } + } +``` + +- [ ] **Step 2: Add helper methods to ComposerWidget** + +Add these methods to the `impl Renderable for ComposerWidget` block or as methods on `ComposerWidget`. They need access to the stored `visible_lines` from `layout_input`. The approach: compute per-line `(char_start, char_end)` ranges from the wrapping logic, then split each line into normal/selected/normal spans. + +Add inside `ComposerWidget` impl: + +```rust + /// Compute the (char_start, char_end) range for each visible line. + /// `char_start` is inclusive, `char_end` is exclusive. + fn visible_line_char_ranges( + &self, + visible_lines: &[String], + ) -> Vec<(usize, usize)> { + let input = &self.app.input; + let width = self.content_width(); + if width == 0 || input.is_empty() { + return vec![(0, 0); visible_lines.len()]; + } + + let mut ranges = Vec::with_capacity(visible_lines.len()); + let mut char_idx = 0usize; + let input_chars: Vec = input.chars().collect(); + let total_chars = input_chars.len(); + let mut line_idx = 0; + + // Walk through the input char by char, tracking line wrapping + // to compute start/end char index per visual line. + let mut line_chars = 0usize; + let mut line_width = 0usize; + let mut line_start = 0usize; + + for (i, ch) in input_chars.iter().enumerate() { + if line_width == 0 && line_chars > 0 { + // Starting a new visual line (after wrap or newline) + line_start = i; + line_width = 0; + line_chars = 0; + } + if *ch == '\n' { + ranges.push((line_start, i)); + line_start = i + 1; + line_width = 0; + line_chars = 0; + line_idx += 1; + char_idx = i + 1; + continue; + } + let cw = unicode_width::UnicodeWidthChar::width(*ch).unwrap_or(0); + if line_width + cw > width && line_width > 0 { + ranges.push((line_start, i)); + line_start = i; + line_width = cw; + line_chars = 1; + line_idx += 1; + } else { + line_width += cw; + line_chars += 1; + } + char_idx = i + 1; + } + // Last line + ranges.push((line_start, total_chars)); + + // Account for scroll offset — layout_input skips `start` lines. + // We need to match the scrolled window. For now, trim to visible_lines.len(). + // The layout_input scroll offset already selects which lines are visible, + // so our computed ranges should align with visible_lines. + // If our ranges have more entries (due to scroll offset), trim from start. + if ranges.len() > visible_lines.len() { + // layout_input skips lines from the start for scroll + let skip = ranges.len() - visible_lines.len(); + ranges = ranges.into_iter().skip(skip).collect(); + } + ranges.truncate(visible_lines.len()); + ranges + } + + /// Split a line into styled Spans, applying selection highlight. + fn line_spans_with_selection( + &self, + line: &str, + line_start: usize, + line_end: usize, + sel_start: usize, + sel_end: usize, + ) -> Vec> { + use ratatui::style::Color; + + let highlight_bg = Color::Rgb(70, 130, 220); + let normal_style = Style::default().fg(palette::TEXT_PRIMARY); + let sel_style = Style::default() + .fg(palette::TEXT_PRIMARY) + .bg(highlight_bg); + + // No overlap between this line and the selection + if line_end <= sel_start || line_start >= sel_end { + return vec![Span::styled(line.to_string(), normal_style)]; + } + + let mut spans = Vec::new(); + let line_chars: Vec = line.chars().collect(); + + // Compute the local (within-line) selection bounds + let local_sel_start = sel_start.saturating_sub(line_start); + let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); + + if local_sel_start > 0 { + let before: String = line_chars[..local_sel_start].iter().collect(); + spans.push(Span::styled(before, normal_style)); + } + + let selected: String = + line_chars[local_sel_start..local_sel_end.min(line_chars.len())] + .iter() + .collect(); + spans.push(Span::styled(selected, sel_style)); + + if local_sel_end < line_chars.len() { + let after: String = line_chars[local_sel_end..].iter().collect(); + spans.push(Span::styled(after, normal_style)); + } + + spans + } +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo check -p deepseek-tui 2>&1 | head -40` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add crates/tui/src/tui/widgets/mod.rs +git commit -m "feat(composer): render selection highlight in input box" +``` + +--- + +### Task 5: Add mouse selection in composer + +**Files:** +- Modify: `crates/tui/src/tui/mouse_ui.rs` (add composer mouse handler) +- Modify: `crates/tui/src/tui/app.rs` (store `last_composer_area` in ViewportState) + +- [ ] **Step 1: Add `last_composer_area` to `ViewportState`** + +In `crates/tui/src/tui/app.rs`, in the `ViewportState` struct (line 668), add after `pub last_transcript_area`: + +```rust + pub last_composer_area: Option, +``` + +Initialize in `Default for ViewportState`: + +```rust + last_composer_area: None, +``` + +- [ ] **Step 2: Store composer area during render** + +In `crates/tui/src/tui/ui.rs`, after rendering the composer (after line 5545 `composer_widget.render(chunks[3], buf);`), add: + +```rust + app.viewport.last_composer_area = Some(chunks[3]); +``` + +- [ ] **Step 3: Add mouse→char index mapping function** + +In `crates/tui/src/tui/mouse_ui.rs`, add a helper at the top of the file (after the existing imports): + +```rust +/// Map a mouse (column, row) within the composer area to a char index +/// in the composer input string. Returns `None` if the coordinates +/// fall outside the text content. +fn mouse_pos_to_char_index( + app: &App, + col: u16, + row: u16, + composer_area: Rect, +) -> Option { + let area = composer_area; + // The content starts at col=area.x, row=area.y (plus any border/padding). + // For simplicity, account for 0-border composer — the border case can be + // refined later. + let rel_col = col.saturating_sub(area.x) as usize; + let rel_row = row.saturating_sub(area.y) as usize; + + let input = &app.input; + if input.is_empty() { + return Some(0); + } + + // Reuse the same wrapping logic as layout_input. + // We need the width of the content area. + let width = if area.width > 0 { area.width as usize - 1 } else { 0 }; + + // Build wrapped lines and their char ranges. + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(input, width); + if rel_row >= wrapped.len() { + return Some(app.cursor_position); // past end, clamp + } + + let (ref line_start, ref line_text) = wrapped[rel_row]; + + // Walk graphemes to find which char index corresponds to rel_col. + let mut char_offset = 0usize; + let mut col_used = 0usize; + for g in line_text.graphemes(true) { + let gw = g.width(); + if col_used + gw > rel_col { + break; + } + col_used += gw; + char_offset += g.chars().count(); + } + Some(line_start + char_offset) +} +``` + +- [ ] **Step 4: Expose `wrap_input_lines_for_mouse` from the widgets module** + +In `crates/tui/src/tui/widgets/mod.rs`, add a public wrapper that returns char offsets alongside each wrapped line. Add near the existing `wrap_input_lines` function: + +```rust +/// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs. +pub fn wrap_input_lines_for_mouse( + input: &str, + width: usize, +) -> Vec<(usize, String)> { + if input.is_empty() || width == 0 { + return vec![(0, String::new())]; + } + + let mut result = Vec::new(); + let mut char_idx = 0usize; + + for raw_line in input.split('\n') { + if raw_line.is_empty() { + result.push((char_idx, String::new())); + char_idx += 1; // the '\n' char + continue; + } + let wrapped = wrap_text(raw_line, width); + for wrapped_line in &wrapped { + let line_len: usize = wrapped_line.graphemes(true).count(); + result.push((char_idx, wrapped_line.clone())); + char_idx += line_len; + } + char_idx += 1; // the '\n' char + } + + result +} +``` + +- [ ] **Step 5: Add composer mouse event handler** + +In `crates/tui/src/tui/mouse_ui.rs`, add a new function: + +```rust +/// Handle mouse events within the composer area. +/// Returns true if the event was consumed. +pub(crate) fn handle_composer_mouse( + app: &mut App, + mouse: MouseEvent, +) -> bool { + let Some(area) = app.viewport.last_composer_area else { + return false; + }; + // Check if mouse is within composer bounds. + if mouse.column < area.x + || mouse.column >= area.x + area.width + || mouse.row < area.y + || mouse.row >= area.y + area.height + { + return false; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(pos) = + mouse_pos_to_char_index(app, mouse.column, mouse.row, area) + { + app.cursor_position = pos; + app.selection_anchor = None; + app.needs_redraw = true; + } + true + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(pos) = + mouse_pos_to_char_index(app, mouse.column, mouse.row, area) + { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.cursor_position = pos; + app.needs_redraw = true; + } + true + } + MouseEventKind::Up(MouseButton::Left) => { + // Selection is already set from Down+Drag. + // Collapse anchor==cursor to None. + if app.selection_anchor == Some(app.cursor_position) { + app.selection_anchor = None; + } + true + } + MouseEventKind::Down(MouseButton::Left) + if mouse.modifiers.contains(KeyModifiers::ALT) + || mouse.modifiers + .contains(KeyModifiers::SUPER) => + { + // Alt/Cmd+click: reserved for future (rectangular select, etc.) + false + } + _ => false, + } +} +``` + +Note: crossterm doesn't natively emit `DoubleClick`/`TripleClick` events on all platforms. Double-click word selection can be added as a follow-up enhancement. The core click/drag/up flow covers the essential mouse selection. + +- [ ] **Step 6: Wire composer mouse handler into `handle_mouse_event`** + +In `crates/tui/src/tui/mouse_ui.rs`, at the top of `handle_mouse_event` (line 40), add a composer check *before* the transcript handling: + +```rust + // Composer mouse events take priority over transcript. + if handle_composer_mouse(app, mouse) { + return Vec::new(); + } +``` + +- [ ] **Step 7: Verify compilation** + +Run: `cargo check -p deepseek-tui 2>&1 | head -40` +Expected: no errors + +- [ ] **Step 8: Commit** + +```bash +git add crates/tui/src/tui/mouse_ui.rs crates/tui/src/tui/app.rs crates/tui/src/tui/ui.rs crates/tui/src/tui/widgets/mod.rs +git commit -m "feat(composer): add mouse click and drag selection in input box" +``` + +--- + +### Task 6: Run full test suite and fix any issues + +**Files:** +- All modified files + +- [ ] **Step 1: Run cargo fmt** + +Run: `cargo fmt --all -- --check` +Expected: no output (all formatted) + +If issues: `cargo fmt --all` + +- [ ] **Step 2: Run cargo clippy** + +Run: `cargo clippy -p deepseek-tui --all-targets --all-features --locked -- -D warnings 2>&1 | tail -30` +Expected: no warnings + +- [ ] **Step 3: Run existing tests** + +Run: `cargo test -p deepseek-tui 2>&1 | tail -40` +Expected: all tests pass + +- [ ] **Step 4: Fix any test failures** + +If any existing tests break (e.g., tests that call `insert_char` now expect `delete_selection` behavior), fix them by ensuring test app instances start with `selection_anchor: None`. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -u +git commit -m "fix: address test/clippy issues from composer selection" +``` + +--- + +### Task 7: Add unit tests for selection logic + +**Files:** +- Modify: `crates/tui/src/tui/app.rs` (add tests in the `tests` module) + +- [ ] **Step 1: Add selection tests** + +At the end of the `tests` module in `app.rs`, add: + +```rust + #[test] + fn selection_range_returns_none_when_no_anchor() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = None; + assert!(app.selection_range().is_none()); + } + + #[test] + fn selection_range_returns_ordered_range() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_normalizes_order() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 2; + app.selection_anchor = Some(5); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_returns_none_when_anchor_equals_cursor() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = Some(3); + assert!(app.selection_range().is_none()); + } + + #[test] + fn delete_selection_removes_selected_text() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert!(app.delete_selection()); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn insert_char_replaces_selection() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_char('X'); + assert_eq!(app.input, "heX world"); + assert_eq!(app.cursor_position, 3); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_char_removes_selection_instead_of_single_char() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.delete_char(); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn selected_text_returns_correct_substring() { + let mut app = App::new(TuiOptions::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selected_text(), "llo"); + } +``` + +- [ ] **Step 2: Run the new tests** + +Run: `cargo test -p deepseek-tui selection 2>&1 | tail -20` +Expected: all pass + +- [ ] **Step 3: Commit** + +```bash +git add crates/tui/src/tui/app.rs +git commit -m "test(composer): add unit tests for selection logic" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- Data model (selection_anchor) → Task 1 ✓ +- Selection-aware editing (insert/delete) → Task 2 ✓ +- Keyboard selection (Shift+arrows, Ctrl+A, copy/cut) → Task 3 ✓ +- Selection rendering → Task 4 ✓ +- Mouse selection → Task 5 ✓ +- Testing → Tasks 6-7 ✓ + +**Placeholder scan:** No TBD/TODO/fill-in-later found. All steps contain actual code. + +**Type consistency:** `selection_anchor: Option`, `selection_range() -> Option<(usize, usize)>`, `delete_selection() -> bool`, `clear_selection()`, `selected_text() -> String` — used consistently across all tasks. From 78a1eadabe6059af4cbc4e8278445d1db10cea0b Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 11:39:59 +0800 Subject: [PATCH 03/10] feat(composer): add Shift+Left/Right text selection Add keyboard selection with Shift+arrow keys. Plain arrows clear selection, Ctrl/Alt arrows move by word without affecting selection. Also gitignore docs/superpowers/ and .serena/. --- .gitignore | 4 ++++ crates/tui/src/tui/ui.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.gitignore b/.gitignore index 0668130d3..6aaab0c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,7 @@ docs/*_PLAN.md # direnv .envrc .direnv + +# Superpowers plugin artifacts +docs/superpowers/ +.serena/ diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ab42ceda0..9f609f3e3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3489,16 +3489,30 @@ async fn run_event_loop( app.delete_char_forward(); } KeyCode::Delete => {} + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_left(); + } KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { app.move_cursor_word_backward(); } KeyCode::Left => { + app.clear_selection(); app.move_cursor_left(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_right(); + } KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { app.move_cursor_word_forward(); } KeyCode::Right => { + app.clear_selection(); app.move_cursor_right(); } KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => { From ecbfafcc782b6cb05252ba3ef62c9b3d2abdefe0 Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 12:16:35 +0800 Subject: [PATCH 04/10] fix: address PR review feedback - Remove needless borrows flagged by clippy - Auto-copy selection on mouse release (consistent with transcript) - Use theme selection_bg instead of hardcoded color --- crates/tui/src/tui/mouse_ui.rs | 2 ++ crates/tui/src/tui/ui.rs | 4 ++-- crates/tui/src/tui/widgets/mod.rs | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 47e323a7b..8f21e3fa5 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -117,6 +117,8 @@ pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { MouseEventKind::Up(MouseButton::Left) => { if app.selection_anchor == Some(app.cursor_position) { app.selection_anchor = None; + } else if selection_has_content(app) { + copy_active_selection(app); } true } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9f609f3e3..eb5237296 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5880,7 +5880,7 @@ fn render(f: &mut Frame, app: &mut App) { .active_menu_reserved_rows(); let budget = crate::tui::widgets::composer_input_rows_budget(inner.height, menu_lines); let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll( - &input_text, + input_text, input_cursor, content_width, budget, @@ -5889,7 +5889,7 @@ fn render(f: &mut Frame, app: &mut App) { 1 } else { // Count wrapped lines (approximation matching the render path). - crate::tui::widgets::wrap_input_lines_for_mouse(&input_text, content_width).len() + crate::tui::widgets::wrap_input_lines_for_mouse(input_text, content_width).len() }; let top_padding = budget.saturating_sub(visible_lines.clamp(1, budget)); app.viewport.last_composer_scroll_offset = scroll_offset; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index d35e9ab81..640e42183 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -677,6 +677,7 @@ impl Renderable for ComposerWidget<'_> { *line_end, sel_start, sel_end, + self.app.ui_theme.selection_bg, ); input_lines.push(Line::from(spans)); } @@ -2494,8 +2495,8 @@ fn line_spans_with_selection<'a>( line_end: usize, sel_start: usize, sel_end: usize, + highlight_bg: Color, ) -> Vec> { - let highlight_bg = Color::Rgb(70, 130, 220); let normal_style = Style::default().fg(palette::TEXT_PRIMARY); let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg); From de5d812fa96e8710d78f868181920f83fc2e4532 Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 12:29:01 +0800 Subject: [PATCH 05/10] chore: remove superpowers docs from tracking Covered by .gitignore, should not be in the PR. --- .../2026-05-17-composer-text-selection.md | 977 ------------------ ...26-05-17-composer-text-selection-design.md | 79 -- 2 files changed, 1056 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-17-composer-text-selection.md delete mode 100644 docs/superpowers/specs/2026-05-17-composer-text-selection-design.md diff --git a/docs/superpowers/plans/2026-05-17-composer-text-selection.md b/docs/superpowers/plans/2026-05-17-composer-text-selection.md deleted file mode 100644 index e3d6e44c8..000000000 --- a/docs/superpowers/plans/2026-05-17-composer-text-selection.md +++ /dev/null @@ -1,977 +0,0 @@ -# Composer Text Selection Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add keyboard and mouse text selection to the composer input box so users can select, copy, cut, and delete ranges of text. - -**Architecture:** Add a `selection_anchor: Option` field to `ComposerState`. Anchor is the fixed end of the selection, `cursor_position` is the active end. All existing editing methods gain selection-awareness: text-modifying ops delete the selection first, cursor-movement ops either set anchor (Shift held) or clear it. Rendering splits each visible line into styled Spans for the selected region. Mouse events in the composer area are dispatched to a new handler that maps screen coordinates back to char indices via `layout_input`'s wrapping logic. - -**Tech Stack:** Rust, ratatui (Span/Line/Style), crossterm (MouseEvent, KeyEvent) - ---- - -### Task 1: Add selection field and helpers to ComposerState - -**Files:** -- Modify: `crates/tui/src/tui/app.rs:609-665` (ComposerState struct + Default impl) - -- [ ] **Step 1: Add `selection_anchor` field to `ComposerState` struct** - -After the `vim_pending_d` field at line 640, add: - -```rust - /// When set, the cursor is the active end of a text selection and - /// `selection_anchor` is the fixed end. Both are char-indexed. - /// `None` means no selection is active. - pub selection_anchor: Option, -``` - -- [ ] **Step 2: Initialize field in `Default for ComposerState`** - -In the Default impl (line 643), add after `vim_pending_d: false,`: - -```rust - selection_anchor: None, -``` - -- [ ] **Step 3: Add helper methods for selection range** - -After the existing `move_cursor_word_backward` method (after line 3280), add: - -```rust - // === Selection helpers === - - /// Return the (start, end) of the active selection, or `None`. - /// `start` is inclusive, `end` is exclusive; both are char indices. - pub fn selection_range(&self) -> Option<(usize, usize)> { - let anchor = self.selection_anchor?; - let cursor = self.cursor_position; - if anchor == cursor { - return None; - } - Some(if anchor < cursor { - (anchor, cursor) - } else { - (cursor, anchor) - }) - } - - /// Return the selected text, or empty string if no selection. - pub fn selected_text(&self) -> String { - self.selection_range() - .map(|(s, e)| { - let sb = byte_index_at_char(&self.input, s); - let eb = byte_index_at_char(&self.input, e); - self.input[sb..eb].to_string() - }) - .unwrap_or_default() - } - - /// Delete the selected text, place cursor at the start of the deleted range. - /// Returns true if a selection was deleted. - pub fn delete_selection(&mut self) -> bool { - let Some((start, end)) = self.selection_range() else { - return false; - }; - let sb = byte_index_at_char(&self.input, start); - let eb = byte_index_at_char(&self.input, end); - self.input.replace_range(sb..eb, ""); - self.cursor_position = start; - self.selection_anchor = None; - self.clear_input_history_navigation(); - self.slash_menu_hidden = false; - self.mention_menu_hidden = false; - self.mention_menu_selected = 0; - self.needs_redraw = true; - true - } - - /// Clear the selection without moving the cursor. - pub fn clear_selection(&mut self) { - self.selection_anchor = None; - } -``` - -- [ ] **Step 4: Commit** - -```bash -git add crates/tui/src/tui/app.rs -git commit -m "feat(composer): add selection_anchor field and helpers to ComposerState" -``` - ---- - -### Task 2: Make existing editing methods selection-aware - -**Files:** -- Modify: `crates/tui/src/tui/app.rs` (insert_char, insert_str, delete_char, delete_char_forward, delete_word_backward, delete_word_forward, kill_to_end_of_line, delete_to_start_of_line) - -- [ ] **Step 1: Make `insert_char` delete selection first** - -At the top of `insert_char` (line 2966), after `self.clear_input_history_navigation();`, add: - -```rust - self.delete_selection(); -``` - -- [ ] **Step 2: Make `insert_str` delete selection first** - -At the top of `insert_str` (line 2702), after the `if text.is_empty()` guard, add: - -```rust - self.delete_selection(); -``` - -- [ ] **Step 3: Make `delete_char` delete selection first** - -At the top of `delete_char` (line 2992), after `self.clear_input_history_navigation();`, add: - -```rust - if self.delete_selection() { - return; - } -``` - -- [ ] **Step 4: Make `delete_char_forward` delete selection first** - -At the top of `delete_char_forward` (line 3009), after `self.clear_input_history_navigation();`, add: - -```rust - if self.delete_selection() { - return; - } -``` - -- [ ] **Step 5: Make `delete_word_backward` delete selection first** - -At the top of `delete_word_backward` (line 3027), after `self.clear_input_history_navigation();`, add: - -```rust - if self.delete_selection() { - return; - } -``` - -- [ ] **Step 6: Make `delete_word_forward` delete selection first** - -At the top of `delete_word_forward`, after `self.clear_input_history_navigation();`, add: - -```rust - if self.delete_selection() { - return; - } -``` - -- [ ] **Step 7: Make `kill_to_end_of_line` delete selection first** - -At the top of `kill_to_end_of_line`, after `self.clear_input_history_navigation();`, add: - -```rust - if self.delete_selection() { - return; - } -``` - -- [ ] **Step 8: Make `delete_to_start_of_line` delete selection first** - -At the top of `delete_to_start_of_line`, after `self.clear_input_history_navigation();`, add: - -```rust - if self.delete_selection() { - return; - } -``` - -- [ ] **Step 9: Verify compilation** - -Run: `cargo check -p deepseek-tui 2>&1 | head -30` -Expected: no errors - -- [ ] **Step 10: Commit** - -```bash -git add crates/tui/src/tui/app.rs -git commit -m "feat(composer): make editing methods selection-aware" -``` - ---- - -### Task 3: Add keyboard selection handlers in ui.rs - -**Files:** -- Modify: `crates/tui/src/tui/ui.rs:3238-3270` (arrow/home/end key handling in composer block) - -- [ ] **Step 1: Replace the Left arrow handlers** - -Replace the existing `KeyCode::Left` arms (lines 3238-3243): - -```rust - KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) - && is_word_cursor_modifier(key.modifiers) => - { - // Shift+Ctrl/Alt+Left: extend selection by word backward - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.move_cursor_word_backward(); - } - KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.move_cursor_left(); - } - KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { - app.clear_selection(); - app.move_cursor_word_backward(); - } - KeyCode::Left => { - app.clear_selection(); - app.move_cursor_left(); - } -``` - -- [ ] **Step 2: Replace the Right arrow handlers** - -Replace the existing `KeyCode::Right` arms (lines 3244-3249): - -```rust - KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) - && is_word_cursor_modifier(key.modifiers) => - { - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.move_cursor_word_forward(); - } - KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.move_cursor_right(); - } - KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { - app.clear_selection(); - app.move_cursor_word_forward(); - } - KeyCode::Right => { - app.clear_selection(); - app.move_cursor_right(); - } -``` - -- [ ] **Step 3: Replace the Home/End handlers** - -Replace the existing `KeyCode::Home` and `KeyCode::End` arms (lines 3260-3270): - -```rust - KeyCode::Home if key.modifiers.contains(KeyModifiers::SHIFT) => { - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.move_cursor_start(); - } - KeyCode::Home => { - app.clear_selection(); - app.move_cursor_start(); - } - KeyCode::End if key.modifiers.contains(KeyModifiers::SHIFT) => { - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.move_cursor_end(); - } - KeyCode::End => { - app.clear_selection(); - app.move_cursor_end(); - } -``` - -Note: The existing `KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL)` and `KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL)` blocks (lines 3250-3258) that control transcript scroll must remain unchanged — they come before the new Home/End arms in the match. - -- [ ] **Step 4: Add Ctrl+A select-all and Ctrl+C/Ctrl+X composer copy/cut** - -In the same composer key block, before the `KeyCode::Left` arms, add after the existing `KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL)` arm (which maps to Home): - -Find the existing: -```rust - KeyCode::Home | KeyCode::Char('a') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - app.move_cursor_start(); - } -``` - -Replace with: - -```rust - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) - && !key.modifiers.contains(KeyModifiers::SHIFT) => - { - app.move_cursor_start(); - } - KeyCode::Char('a') | KeyCode::Char('A') - if key.modifiers.contains(KeyModifiers::CONTROL) - && key.modifiers.contains(KeyModifiers::SHIFT) - || key_shortcuts::is_select_all_shortcut(&key) => - { - // Ctrl+Shift+A or Cmd+A: select all - let end = char_count(&app.input); - if end > 0 { - app.selection_anchor = Some(0); - app.cursor_position = end; - app.needs_redraw = true; - } - } -``` - -Add a new `is_select_all_shortcut` helper in `composer_ui.rs` (after `is_word_cursor_modifier` at line 88): - -```rust -pub(crate) fn is_select_all_shortcut(key: &KeyEvent) -> bool { - #[cfg(target_os = "macos")] - { - key.modifiers.contains(KeyModifiers::SUPER) && matches!(key.code, KeyCode::Char('a')) - } - #[cfg(not(target_os = "macos"))] - { - key.modifiers.contains(KeyModifiers::CONTROL) - && key.modifiers.contains(KeyModifiers::SHIFT) - && matches!(key.code, KeyCode::Char('a') | KeyCode::Char('A')) - } -} -``` - -- [ ] **Step 5: Add Ctrl+C copy when composer has selection** - -Find the existing Ctrl+C handler block (~line 2715). The existing `key_shortcuts::is_copy_shortcut` handler at line 2710 copies the transcript selection. Add composer selection copy *before* the main Ctrl+C block. Find: - -```rust - KeyCode::Char('c') | KeyCode::Char('C') - if key_shortcuts::is_copy_shortcut(&key) => - { - copy_active_selection(app); - } -``` - -Replace with: - -```rust - KeyCode::Char('c') | KeyCode::Char('C') - if key_shortcuts::is_copy_shortcut(&key) => - { - // If composer has a selection, copy that (takes priority - // over transcript selection). - let sel = app.selected_text(); - if !sel.is_empty() { - if app.clipboard.write_text(&sel).is_ok() { - app.push_status_toast( - "Copied to clipboard", - crate::tui::app::StatusToastLevel::Info, - ); - } - app.clear_selection(); - } else { - copy_active_selection(app); - } - } -``` - -- [ ] **Step 6: Add Ctrl+X cut when composer has selection** - -Find the existing Ctrl+X handler (~line 3367): - -```rust - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let new_mode = match app.mode { - AppMode::Plan => AppMode::Agent, - _ => AppMode::Plan, - }; - app.set_mode(new_mode); -``` - -Add a new Ctrl+Shift+X / Cmd+X handler *before* the existing Ctrl+X mode toggle: - -```rust - KeyCode::Char('x') | KeyCode::Char('X') - if key_shortcuts::is_copy_shortcut(&key) => - { - let sel = app.selected_text(); - if !sel.is_empty() { - if app.clipboard.write_text(&sel).is_ok() { - app.push_status_toast( - "Cut to clipboard", - crate::tui::app::StatusToastLevel::Info, - ); - } - app.delete_selection(); - } - } -``` - -- [ ] **Step 7: Verify compilation** - -Run: `cargo check -p deepseek-tui 2>&1 | head -30` -Expected: no errors - -- [ ] **Step 8: Commit** - -```bash -git add crates/tui/src/tui/ui.rs crates/tui/src/tui/composer_ui.rs -git commit -m "feat(composer): add keyboard selection — Shift+arrows, Ctrl+A, copy/cut" -``` - ---- - -### Task 4: Add selection rendering in ComposerWidget - -**Files:** -- Modify: `crates/tui/src/tui/widgets/mod.rs:664-684` (input_lines rendering) - -- [ ] **Step 1: Replace uniform Span rendering with selection-aware rendering** - -Replace the existing rendering block (lines 677-683): - -```rust - for line in &visible_lines { - input_lines.push(Line::from(Span::styled( - line.clone(), - Style::default().fg(palette::TEXT_PRIMARY), - ))); - } -``` - -With: - -```rust - if let Some((sel_start, sel_end)) = - self.app.selection_range() - { - let lines_with_ranges = - self.visible_line_char_ranges(&visible_lines); - for (line_text, (line_start, line_end)) in - visible_lines.iter().zip(lines_with_ranges.iter()) - { - let spans = self.line_spans_with_selection( - line_text, - *line_start, - *line_end, - sel_start, - sel_end, - ); - input_lines.push(Line::from(spans)); - } - } else { - for line in &visible_lines { - input_lines.push(Line::from(Span::styled( - line.clone(), - Style::default().fg(palette::TEXT_PRIMARY), - ))); - } - } -``` - -- [ ] **Step 2: Add helper methods to ComposerWidget** - -Add these methods to the `impl Renderable for ComposerWidget` block or as methods on `ComposerWidget`. They need access to the stored `visible_lines` from `layout_input`. The approach: compute per-line `(char_start, char_end)` ranges from the wrapping logic, then split each line into normal/selected/normal spans. - -Add inside `ComposerWidget` impl: - -```rust - /// Compute the (char_start, char_end) range for each visible line. - /// `char_start` is inclusive, `char_end` is exclusive. - fn visible_line_char_ranges( - &self, - visible_lines: &[String], - ) -> Vec<(usize, usize)> { - let input = &self.app.input; - let width = self.content_width(); - if width == 0 || input.is_empty() { - return vec![(0, 0); visible_lines.len()]; - } - - let mut ranges = Vec::with_capacity(visible_lines.len()); - let mut char_idx = 0usize; - let input_chars: Vec = input.chars().collect(); - let total_chars = input_chars.len(); - let mut line_idx = 0; - - // Walk through the input char by char, tracking line wrapping - // to compute start/end char index per visual line. - let mut line_chars = 0usize; - let mut line_width = 0usize; - let mut line_start = 0usize; - - for (i, ch) in input_chars.iter().enumerate() { - if line_width == 0 && line_chars > 0 { - // Starting a new visual line (after wrap or newline) - line_start = i; - line_width = 0; - line_chars = 0; - } - if *ch == '\n' { - ranges.push((line_start, i)); - line_start = i + 1; - line_width = 0; - line_chars = 0; - line_idx += 1; - char_idx = i + 1; - continue; - } - let cw = unicode_width::UnicodeWidthChar::width(*ch).unwrap_or(0); - if line_width + cw > width && line_width > 0 { - ranges.push((line_start, i)); - line_start = i; - line_width = cw; - line_chars = 1; - line_idx += 1; - } else { - line_width += cw; - line_chars += 1; - } - char_idx = i + 1; - } - // Last line - ranges.push((line_start, total_chars)); - - // Account for scroll offset — layout_input skips `start` lines. - // We need to match the scrolled window. For now, trim to visible_lines.len(). - // The layout_input scroll offset already selects which lines are visible, - // so our computed ranges should align with visible_lines. - // If our ranges have more entries (due to scroll offset), trim from start. - if ranges.len() > visible_lines.len() { - // layout_input skips lines from the start for scroll - let skip = ranges.len() - visible_lines.len(); - ranges = ranges.into_iter().skip(skip).collect(); - } - ranges.truncate(visible_lines.len()); - ranges - } - - /// Split a line into styled Spans, applying selection highlight. - fn line_spans_with_selection( - &self, - line: &str, - line_start: usize, - line_end: usize, - sel_start: usize, - sel_end: usize, - ) -> Vec> { - use ratatui::style::Color; - - let highlight_bg = Color::Rgb(70, 130, 220); - let normal_style = Style::default().fg(palette::TEXT_PRIMARY); - let sel_style = Style::default() - .fg(palette::TEXT_PRIMARY) - .bg(highlight_bg); - - // No overlap between this line and the selection - if line_end <= sel_start || line_start >= sel_end { - return vec![Span::styled(line.to_string(), normal_style)]; - } - - let mut spans = Vec::new(); - let line_chars: Vec = line.chars().collect(); - - // Compute the local (within-line) selection bounds - let local_sel_start = sel_start.saturating_sub(line_start); - let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); - - if local_sel_start > 0 { - let before: String = line_chars[..local_sel_start].iter().collect(); - spans.push(Span::styled(before, normal_style)); - } - - let selected: String = - line_chars[local_sel_start..local_sel_end.min(line_chars.len())] - .iter() - .collect(); - spans.push(Span::styled(selected, sel_style)); - - if local_sel_end < line_chars.len() { - let after: String = line_chars[local_sel_end..].iter().collect(); - spans.push(Span::styled(after, normal_style)); - } - - spans - } -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cargo check -p deepseek-tui 2>&1 | head -40` -Expected: no errors - -- [ ] **Step 4: Commit** - -```bash -git add crates/tui/src/tui/widgets/mod.rs -git commit -m "feat(composer): render selection highlight in input box" -``` - ---- - -### Task 5: Add mouse selection in composer - -**Files:** -- Modify: `crates/tui/src/tui/mouse_ui.rs` (add composer mouse handler) -- Modify: `crates/tui/src/tui/app.rs` (store `last_composer_area` in ViewportState) - -- [ ] **Step 1: Add `last_composer_area` to `ViewportState`** - -In `crates/tui/src/tui/app.rs`, in the `ViewportState` struct (line 668), add after `pub last_transcript_area`: - -```rust - pub last_composer_area: Option, -``` - -Initialize in `Default for ViewportState`: - -```rust - last_composer_area: None, -``` - -- [ ] **Step 2: Store composer area during render** - -In `crates/tui/src/tui/ui.rs`, after rendering the composer (after line 5545 `composer_widget.render(chunks[3], buf);`), add: - -```rust - app.viewport.last_composer_area = Some(chunks[3]); -``` - -- [ ] **Step 3: Add mouse→char index mapping function** - -In `crates/tui/src/tui/mouse_ui.rs`, add a helper at the top of the file (after the existing imports): - -```rust -/// Map a mouse (column, row) within the composer area to a char index -/// in the composer input string. Returns `None` if the coordinates -/// fall outside the text content. -fn mouse_pos_to_char_index( - app: &App, - col: u16, - row: u16, - composer_area: Rect, -) -> Option { - let area = composer_area; - // The content starts at col=area.x, row=area.y (plus any border/padding). - // For simplicity, account for 0-border composer — the border case can be - // refined later. - let rel_col = col.saturating_sub(area.x) as usize; - let rel_row = row.saturating_sub(area.y) as usize; - - let input = &app.input; - if input.is_empty() { - return Some(0); - } - - // Reuse the same wrapping logic as layout_input. - // We need the width of the content area. - let width = if area.width > 0 { area.width as usize - 1 } else { 0 }; - - // Build wrapped lines and their char ranges. - let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(input, width); - if rel_row >= wrapped.len() { - return Some(app.cursor_position); // past end, clamp - } - - let (ref line_start, ref line_text) = wrapped[rel_row]; - - // Walk graphemes to find which char index corresponds to rel_col. - let mut char_offset = 0usize; - let mut col_used = 0usize; - for g in line_text.graphemes(true) { - let gw = g.width(); - if col_used + gw > rel_col { - break; - } - col_used += gw; - char_offset += g.chars().count(); - } - Some(line_start + char_offset) -} -``` - -- [ ] **Step 4: Expose `wrap_input_lines_for_mouse` from the widgets module** - -In `crates/tui/src/tui/widgets/mod.rs`, add a public wrapper that returns char offsets alongside each wrapped line. Add near the existing `wrap_input_lines` function: - -```rust -/// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs. -pub fn wrap_input_lines_for_mouse( - input: &str, - width: usize, -) -> Vec<(usize, String)> { - if input.is_empty() || width == 0 { - return vec![(0, String::new())]; - } - - let mut result = Vec::new(); - let mut char_idx = 0usize; - - for raw_line in input.split('\n') { - if raw_line.is_empty() { - result.push((char_idx, String::new())); - char_idx += 1; // the '\n' char - continue; - } - let wrapped = wrap_text(raw_line, width); - for wrapped_line in &wrapped { - let line_len: usize = wrapped_line.graphemes(true).count(); - result.push((char_idx, wrapped_line.clone())); - char_idx += line_len; - } - char_idx += 1; // the '\n' char - } - - result -} -``` - -- [ ] **Step 5: Add composer mouse event handler** - -In `crates/tui/src/tui/mouse_ui.rs`, add a new function: - -```rust -/// Handle mouse events within the composer area. -/// Returns true if the event was consumed. -pub(crate) fn handle_composer_mouse( - app: &mut App, - mouse: MouseEvent, -) -> bool { - let Some(area) = app.viewport.last_composer_area else { - return false; - }; - // Check if mouse is within composer bounds. - if mouse.column < area.x - || mouse.column >= area.x + area.width - || mouse.row < area.y - || mouse.row >= area.y + area.height - { - return false; - } - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(pos) = - mouse_pos_to_char_index(app, mouse.column, mouse.row, area) - { - app.cursor_position = pos; - app.selection_anchor = None; - app.needs_redraw = true; - } - true - } - MouseEventKind::Drag(MouseButton::Left) => { - if let Some(pos) = - mouse_pos_to_char_index(app, mouse.column, mouse.row, area) - { - if app.selection_anchor.is_none() { - app.selection_anchor = Some(app.cursor_position); - } - app.cursor_position = pos; - app.needs_redraw = true; - } - true - } - MouseEventKind::Up(MouseButton::Left) => { - // Selection is already set from Down+Drag. - // Collapse anchor==cursor to None. - if app.selection_anchor == Some(app.cursor_position) { - app.selection_anchor = None; - } - true - } - MouseEventKind::Down(MouseButton::Left) - if mouse.modifiers.contains(KeyModifiers::ALT) - || mouse.modifiers - .contains(KeyModifiers::SUPER) => - { - // Alt/Cmd+click: reserved for future (rectangular select, etc.) - false - } - _ => false, - } -} -``` - -Note: crossterm doesn't natively emit `DoubleClick`/`TripleClick` events on all platforms. Double-click word selection can be added as a follow-up enhancement. The core click/drag/up flow covers the essential mouse selection. - -- [ ] **Step 6: Wire composer mouse handler into `handle_mouse_event`** - -In `crates/tui/src/tui/mouse_ui.rs`, at the top of `handle_mouse_event` (line 40), add a composer check *before* the transcript handling: - -```rust - // Composer mouse events take priority over transcript. - if handle_composer_mouse(app, mouse) { - return Vec::new(); - } -``` - -- [ ] **Step 7: Verify compilation** - -Run: `cargo check -p deepseek-tui 2>&1 | head -40` -Expected: no errors - -- [ ] **Step 8: Commit** - -```bash -git add crates/tui/src/tui/mouse_ui.rs crates/tui/src/tui/app.rs crates/tui/src/tui/ui.rs crates/tui/src/tui/widgets/mod.rs -git commit -m "feat(composer): add mouse click and drag selection in input box" -``` - ---- - -### Task 6: Run full test suite and fix any issues - -**Files:** -- All modified files - -- [ ] **Step 1: Run cargo fmt** - -Run: `cargo fmt --all -- --check` -Expected: no output (all formatted) - -If issues: `cargo fmt --all` - -- [ ] **Step 2: Run cargo clippy** - -Run: `cargo clippy -p deepseek-tui --all-targets --all-features --locked -- -D warnings 2>&1 | tail -30` -Expected: no warnings - -- [ ] **Step 3: Run existing tests** - -Run: `cargo test -p deepseek-tui 2>&1 | tail -40` -Expected: all tests pass - -- [ ] **Step 4: Fix any test failures** - -If any existing tests break (e.g., tests that call `insert_char` now expect `delete_selection` behavior), fix them by ensuring test app instances start with `selection_anchor: None`. - -- [ ] **Step 5: Commit any fixes** - -```bash -git add -u -git commit -m "fix: address test/clippy issues from composer selection" -``` - ---- - -### Task 7: Add unit tests for selection logic - -**Files:** -- Modify: `crates/tui/src/tui/app.rs` (add tests in the `tests` module) - -- [ ] **Step 1: Add selection tests** - -At the end of the `tests` module in `app.rs`, add: - -```rust - #[test] - fn selection_range_returns_none_when_no_anchor() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 5; - app.selection_anchor = None; - assert!(app.selection_range().is_none()); - } - - #[test] - fn selection_range_returns_ordered_range() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 5; - app.selection_anchor = Some(2); - assert_eq!(app.selection_range(), Some((2, 5))); - } - - #[test] - fn selection_range_normalizes_order() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 2; - app.selection_anchor = Some(5); - assert_eq!(app.selection_range(), Some((2, 5))); - } - - #[test] - fn selection_range_returns_none_when_anchor_equals_cursor() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello".to_string(); - app.cursor_position = 3; - app.selection_anchor = Some(3); - assert!(app.selection_range().is_none()); - } - - #[test] - fn delete_selection_removes_selected_text() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 5; - app.selection_anchor = Some(2); - assert!(app.delete_selection()); - assert_eq!(app.input, "he world"); - assert_eq!(app.cursor_position, 2); - assert!(app.selection_anchor.is_none()); - } - - #[test] - fn insert_char_replaces_selection() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 5; - app.selection_anchor = Some(2); - app.insert_char('X'); - assert_eq!(app.input, "heX world"); - assert_eq!(app.cursor_position, 3); - assert!(app.selection_anchor.is_none()); - } - - #[test] - fn delete_char_removes_selection_instead_of_single_char() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 5; - app.selection_anchor = Some(2); - app.delete_char(); - assert_eq!(app.input, "he world"); - assert_eq!(app.cursor_position, 2); - } - - #[test] - fn selected_text_returns_correct_substring() { - let mut app = App::new(TuiOptions::default()); - app.input = "hello world".to_string(); - app.cursor_position = 5; - app.selection_anchor = Some(2); - assert_eq!(app.selected_text(), "llo"); - } -``` - -- [ ] **Step 2: Run the new tests** - -Run: `cargo test -p deepseek-tui selection 2>&1 | tail -20` -Expected: all pass - -- [ ] **Step 3: Commit** - -```bash -git add crates/tui/src/tui/app.rs -git commit -m "test(composer): add unit tests for selection logic" -``` - ---- - -## Self-Review Checklist - -**Spec coverage:** -- Data model (selection_anchor) → Task 1 ✓ -- Selection-aware editing (insert/delete) → Task 2 ✓ -- Keyboard selection (Shift+arrows, Ctrl+A, copy/cut) → Task 3 ✓ -- Selection rendering → Task 4 ✓ -- Mouse selection → Task 5 ✓ -- Testing → Tasks 6-7 ✓ - -**Placeholder scan:** No TBD/TODO/fill-in-later found. All steps contain actual code. - -**Type consistency:** `selection_anchor: Option`, `selection_range() -> Option<(usize, usize)>`, `delete_selection() -> bool`, `clear_selection()`, `selected_text() -> String` — used consistently across all tasks. diff --git a/docs/superpowers/specs/2026-05-17-composer-text-selection-design.md b/docs/superpowers/specs/2026-05-17-composer-text-selection-design.md deleted file mode 100644 index c58df3b5b..000000000 --- a/docs/superpowers/specs/2026-05-17-composer-text-selection-design.md +++ /dev/null @@ -1,79 +0,0 @@ -# Composer Text Selection - -Add text selection support to the input box (composer) — keyboard and mouse. - -## Problem - -The composer (`ComposerState`) has no selection mechanism. Users cannot select, copy, or delete a range of text in the input box. Every edit is single-character or single-word, and mouse clicks in the composer area are ignored. - -## Design - -### Data Model - -Add one field to `ComposerState`: - -```rust -pub selection_anchor: Option, // char-indexed, None = no selection -``` - -Semantics: `anchor` is the fixed end, `cursor_position` is the active end. The effective selection range is `min(anchor, cursor) .. max(anchor, cursor)`. When `anchor == cursor`, treat as no selection (set to `None`). - -Editing method rules: -- **Text-modifying operations** (insert, delete): if selection exists, delete selected content first, then perform operation, then clear anchor. -- **Cursor movement with Shift held**: set/keep anchor, move cursor to extend selection. -- **Cursor movement without Shift**: clear anchor. -- **Unrelated operations** (history, slash menu): clear anchor. - -### Keyboard Interactions - -In `ui.rs` key dispatch block: - -| Key | Behavior | -|---|---| -| `Shift+Left/Right` | Set anchor (if none), move cursor to extend selection | -| `Shift+Ctrl+Left/Right` | Set anchor (if none), move cursor by word | -| `Shift+Home/End` | Set anchor (if none), cursor to line start/end | -| `Ctrl+A` / `Cmd+A` | Select all (anchor=0, cursor=end) | -| `Backspace` / `Delete` | With selection: delete selected text; without: original behavior | -| Printable char input | With selection: replace selected text, clear anchor | -| `Ctrl+C` / `Cmd+C` | With selection: copy selected text to clipboard | -| `Ctrl+X` / `Cmd+X` | With selection: cut (copy + delete) | -| Non-Shift navigation keys | Clear anchor | - -Implementation: add `key.modifiers.contains(SHIFT)` branches to existing `KeyCode::Left/Right/Home/End` handlers (~line 3238 in ui.rs). - -### Mouse Interactions - -In `mouse_ui.rs`, add composer-area mouse handling: - -| Event | Behavior | -|---|---| -| `Down(Left)` | Position cursor at char boundary, clear anchor | -| `Drag(Left)` | Set anchor to click position (if none), continuously move cursor to extend selection | -| `Up(Left)` | End drag selection | -| `DoubleClick(Left)` | Select word under cursor (anchor=word-start, cursor=word-end) | -| `TripleClick(Left)` | Select entire line | - -Coordinate mapping: mouse (col, row) -> char index, using `layout_input()` line-wrapping results for reverse lookup. - -Area priority: check composer area first; if inside, handle composer mouse events; otherwise fall through to existing transcript logic. - -### Rendering - -In `ComposerWidget::render()`, replace uniform single-Span-per-line with selection-aware multi-Span rendering: - -- **No selection**: unchanged, one Span per line. -- **With selection**: split each line into up to 3 Spans: `[before, normal] [selected, highlight] [after, normal]`. - -Highlight style: `Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(70, 130, 220))` (blue background). - -Multi-line selection: first line highlights from anchor to line end, middle lines fully highlighted, last line highlights from line start to cursor. Uses `layout_input()` wrapping results to compute per-line char ranges. - -## Files Changed - -| File | Change | -|---|---| -| `crates/tui/src/tui/app.rs` | Add `selection_anchor` field, modify editing methods to handle selection | -| `crates/tui/src/tui/ui.rs` | Add Shift+arrow/Ctrl+A/Ctrl+C/Ctrl+X handling in key dispatch | -| `crates/tui/src/tui/widgets/mod.rs` | Selection-aware rendering in `ComposerWidget` | -| `crates/tui/src/tui/mouse_ui.rs` | Add composer mouse click/drag/double-click handling | From 8b78045458d35b225e0eed7d749345b2eda13bb9 Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 13:48:58 +0800 Subject: [PATCH 06/10] fix: address gemini review feedback - Revert auto-copy on mouse release (preserves selection for cut/delete) - Use actual scroll_offset in visible_line_char_ranges instead of inferring - Use &str slices in line_spans_with_selection to avoid String allocations --- crates/tui/src/tui/mouse_ui.rs | 2 -- crates/tui/src/tui/widgets/mod.rs | 51 ++++++++++++++----------------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 8f21e3fa5..47e323a7b 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -117,8 +117,6 @@ pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { MouseEventKind::Up(MouseButton::Left) => { if app.selection_anchor == Some(app.cursor_position) { app.selection_anchor = None; - } else if selection_has_content(app) { - copy_active_selection(app); } true } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 640e42183..c9ecb45c8 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -535,8 +535,8 @@ impl Renderable for ComposerWidget<'_> { let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines_for_budget); let content_width = usize::from(inner_area.width.max(1)); - let (visible_lines, _cursor_row, _cursor_col) = - layout_input(input_text, input_cursor, content_width, input_rows_budget); + let (visible_lines, _cursor_row, _cursor_col, scroll_offset) = + layout_input_with_scroll(input_text, input_cursor, content_width, input_rows_budget); let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { let border_color = if input_text.trim().is_empty() { @@ -668,7 +668,7 @@ impl Renderable for ComposerWidget<'_> { ))); } else if let Some((sel_start, sel_end)) = self.app.selection_range() { let line_ranges = - visible_line_char_ranges(&self.app.input, &visible_lines, content_width); + visible_line_char_ranges(&self.app.input, &visible_lines, content_width, scroll_offset); for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) { let spans = line_spans_with_selection( @@ -2440,10 +2440,12 @@ fn wrap_text(text: &str, width: usize) -> Vec { /// Compute the (char_start, char_end) range for each visible wrapped line. /// `char_start` is inclusive, `char_end` is exclusive. +/// `scroll_offset` is the number of wrapped lines skipped from the top. fn visible_line_char_ranges( input: &str, visible_lines: &[String], width: usize, + scroll_offset: usize, ) -> Vec<(usize, usize)> { if input.is_empty() || width == 0 { return vec![(0, 0); visible_lines.len()]; @@ -2479,18 +2481,13 @@ fn visible_line_char_ranges( } ranges.push((line_start, char_idx)); - // layout_input may have trimmed lines from the start for scroll offset. - // Align with visible_lines by trimming from start. - if ranges.len() > visible_lines.len() { - let skip = ranges.len() - visible_lines.len(); - ranges = ranges.into_iter().skip(skip).collect(); - } - ranges.truncate(visible_lines.len()); - ranges + // Use the actual scroll_offset to align with visible_lines. + let start = scroll_offset.min(ranges.len()); + ranges.into_iter().skip(start).take(visible_lines.len()).collect() } fn line_spans_with_selection<'a>( - line: &str, + line: &'a str, line_start: usize, line_end: usize, sel_start: usize, @@ -2502,34 +2499,32 @@ fn line_spans_with_selection<'a>( // No overlap between this line and the selection if line_end <= sel_start || line_start >= sel_end { - return vec![Span::styled(line.to_string(), normal_style)]; + return vec![Span::styled(line, normal_style)]; } - let line_chars: Vec = line.chars().collect(); let local_sel_start = sel_start.saturating_sub(line_start); let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); + // Build a Vec of byte offsets for each char boundary, plus one past the end. + let mut byte_offsets: Vec = line.char_indices().map(|(i, _)| i).collect(); + byte_offsets.push(line.len()); + + let b0 = byte_offsets.get(local_sel_start).copied().unwrap_or(line.len()); + let b1 = byte_offsets.get(local_sel_end).copied().unwrap_or(line.len()); + let mut spans = Vec::with_capacity(3); // Text before selection - if local_sel_start > 0 { - let before: String = line_chars[..local_sel_start].iter().collect(); - spans.push(Span::styled(before, normal_style)); + if b0 > 0 { + spans.push(Span::styled(&line[..b0], normal_style)); } - // Selected text - let sel_end_clamped = local_sel_end.min(line_chars.len()); - if local_sel_start < sel_end_clamped { - let selected: String = line_chars[local_sel_start..sel_end_clamped] - .iter() - .collect(); - spans.push(Span::styled(selected, sel_style)); + if b1 > b0 { + spans.push(Span::styled(&line[b0..b1], sel_style)); } - // Text after selection - if sel_end_clamped < line_chars.len() { - let after: String = line_chars[sel_end_clamped..].iter().collect(); - spans.push(Span::styled(after, normal_style)); + if b1 < line.len() { + spans.push(Span::styled(&line[b1..], normal_style)); } spans From 93140aefda864d51bca97caf3168c6f56835e99f Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Sun, 17 May 2026 13:57:12 +0800 Subject: [PATCH 07/10] style: cargo fmt --- crates/tui/src/tui/widgets/mod.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index c9ecb45c8..d78c1a960 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -667,8 +667,12 @@ impl Renderable for ComposerWidget<'_> { Style::default().fg(palette::TEXT_MUTED).italic(), ))); } else if let Some((sel_start, sel_end)) = self.app.selection_range() { - let line_ranges = - visible_line_char_ranges(&self.app.input, &visible_lines, content_width, scroll_offset); + let line_ranges = visible_line_char_ranges( + &self.app.input, + &visible_lines, + content_width, + scroll_offset, + ); for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) { let spans = line_spans_with_selection( @@ -2483,7 +2487,11 @@ fn visible_line_char_ranges( // Use the actual scroll_offset to align with visible_lines. let start = scroll_offset.min(ranges.len()); - ranges.into_iter().skip(start).take(visible_lines.len()).collect() + ranges + .into_iter() + .skip(start) + .take(visible_lines.len()) + .collect() } fn line_spans_with_selection<'a>( @@ -2509,8 +2517,14 @@ fn line_spans_with_selection<'a>( let mut byte_offsets: Vec = line.char_indices().map(|(i, _)| i).collect(); byte_offsets.push(line.len()); - let b0 = byte_offsets.get(local_sel_start).copied().unwrap_or(line.len()); - let b1 = byte_offsets.get(local_sel_end).copied().unwrap_or(line.len()); + let b0 = byte_offsets + .get(local_sel_start) + .copied() + .unwrap_or(line.len()); + let b1 = byte_offsets + .get(local_sel_end) + .copied() + .unwrap_or(line.len()); let mut spans = Vec::with_capacity(3); From 49d3e32f6d68487c7078f9a9cab8987f220f2c35 Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Wed, 27 May 2026 01:33:54 +0800 Subject: [PATCH 08/10] fix: address review feedback for composer text selection - Ctrl+Left/Right word jump now clears active selection - Ctrl+K with selection saves deleted text to kill_buffer - Ctrl+C/Ctrl+X check clipboard write result before clearing/deleting --- crates/tui/src/tui/app.rs | 6 +++++- crates/tui/src/tui/ui.rs | 20 ++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index e11052793..201890ade 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3591,7 +3591,11 @@ impl App { /// Returns `true` when bytes were moved into the kill buffer. pub fn kill_to_end_of_line(&mut self) -> bool { self.clear_input_history_navigation(); - if self.delete_selection() { + if let Some((start, end)) = self.selection_range() { + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.kill_buffer = self.input[sb..eb].to_string(); + self.delete_selection(); return true; } let total_chars = char_count(&self.input); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index eb5237296..6965a8a14 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2971,9 +2971,12 @@ async fn run_event_loop( { let sel = app.selected_text(); if !sel.is_empty() { - let _ = app.clipboard.write_text(&sel); - app.push_status_toast("Copied to clipboard", StatusToastLevel::Info, None); - app.clear_selection(); + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Copied to clipboard", StatusToastLevel::Info, None); + app.clear_selection(); + } else { + app.push_status_toast("Copy failed", StatusToastLevel::Error, None); + } } else { copy_active_selection(app); } @@ -3496,6 +3499,7 @@ async fn run_event_loop( app.move_cursor_left(); } KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_backward(); } KeyCode::Left => { @@ -3509,6 +3513,7 @@ async fn run_event_loop( app.move_cursor_right(); } KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_forward(); } KeyCode::Right => { @@ -3641,9 +3646,12 @@ async fn run_event_loop( KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { let sel = app.selected_text(); if !sel.is_empty() { - let _ = app.clipboard.write_text(&sel); - app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None); - app.delete_selection(); + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None); + app.delete_selection(); + } else { + app.push_status_toast("Cut failed", StatusToastLevel::Error, None); + } } else { let new_mode = match app.mode { AppMode::Plan => AppMode::Agent, From eece08a1960777b82cc5a68a27a7f19794fccee1 Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Wed, 27 May 2026 02:12:11 +0800 Subject: [PATCH 09/10] fix: clear selection on Home/End/Ctrl+A/Ctrl+E and support word selection - Home, End, Ctrl+A, Ctrl+E now clear active selection before moving - Shift+Ctrl+Left/Right selects by word instead of by character --- crates/tui/src/tui/ui.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 6965a8a14..9abd0a065 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3492,6 +3492,14 @@ async fn run_event_loop( app.delete_char_forward(); } KeyCode::Delete => {} + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) + && is_word_cursor_modifier(key.modifiers) => + { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_word_backward(); + } KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { if app.selection_anchor.is_none() { app.selection_anchor = Some(app.cursor_position); @@ -3506,6 +3514,14 @@ async fn run_event_loop( app.clear_selection(); app.move_cursor_left(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) + && is_word_cursor_modifier(key.modifiers) => + { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_word_forward(); + } KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { if app.selection_anchor.is_none() { app.selection_anchor = Some(app.cursor_position); @@ -3521,6 +3537,7 @@ async fn run_event_loop( app.move_cursor_right(); } KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); if let Some(anchor) = TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0) { @@ -3528,20 +3545,25 @@ async fn run_event_loop( } } KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.scroll_to_bottom(); } KeyCode::Home | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_start(); } KeyCode::Home => { + app.clear_selection(); app.move_cursor_line_start(); } KeyCode::End => { + app.clear_selection(); app.move_cursor_line_end(); } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_end(); } KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { From b813c11753a6b80798505459b432ad7700249b4a Mon Sep 17 00:00:00 2001 From: imkingjh999 Date: Wed, 27 May 2026 02:21:36 +0800 Subject: [PATCH 10/10] fix: use composer_display_input() for selection during history search Ensures mouse coordinate mapping and selection highlighting use the displayed text (search query) rather than the underlying input when history search is active. --- crates/tui/src/tui/mouse_ui.rs | 7 ++++--- crates/tui/src/tui/widgets/mod.rs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 47e323a7b..7075b58a4 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -46,12 +46,13 @@ fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option let rel_col = col.saturating_sub(inner.x) as usize; let rel_row = row.saturating_sub(inner.y) as usize; - if app.input.is_empty() { + let input_text = app.composer_display_input(); + if input_text.is_empty() { return Some(0); } let width = inner.width.max(1) as usize; - let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width); + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(input_text, width); // Subtract the vertical top-padding (centering of short inputs). let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding); @@ -60,7 +61,7 @@ fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option let absolute_row = text_row + app.viewport.last_composer_scroll_offset; if absolute_row >= wrapped.len() { - return Some(app.input.chars().count()); + return Some(input_text.chars().count()); } let (line_start, line_text) = &wrapped[absolute_row]; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index d78c1a960..cbeaafb3a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -668,7 +668,7 @@ impl Renderable for ComposerWidget<'_> { ))); } else if let Some((sel_start, sel_end)) = self.app.selection_range() { let line_ranges = visible_line_char_ranges( - &self.app.input, + input_text, &visible_lines, content_width, scroll_offset,