Skip to content

feat(composer): mouse + keyboard text selection with copy/cut#2228

Closed
imkingjh999 wants to merge 10 commits into
Hmbown:mainfrom
imkingjh999:feature/composer-text-selection
Closed

feat(composer): mouse + keyboard text selection with copy/cut#2228
imkingjh999 wants to merge 10 commits into
Hmbown:mainfrom
imkingjh999:feature/composer-text-selection

Conversation

@imkingjh999
Copy link
Copy Markdown
Contributor

@imkingjh999 imkingjh999 commented May 26, 2026

Summary

Rebased and reopened per @Hmbown's request (#1726).

  • Add mouse drag selection in the composer input box
  • Add Shift+Left/Right keyboard text selection
  • Ctrl+C copies selected text; Ctrl+X cuts (or toggles mode if no selection)
  • Ctrl/Cmd+Alt+Left/Right jumps by word (on macOS: Ctrl+Cmd+Left/Right)
  • Selected text highlighted with theme selection_bg color
  • Mouse coordinate mapping accounts for wrapping, scroll offset, and padding

Review feedback addressed

  • HIGH: Scroll offset alignment now uses actual scroll_offset from ViewportState instead of inferring from length difference
  • HIGH: Removed copy-on-release for composer (selection stays active for cut/delete operations)
  • MEDIUM: Selection color sourced from active theme (app.ui_theme.selection_bg)
  • MEDIUM: line_spans_with_selection uses byte offsets to return &str slices (no String/Vec allocations)

Test plan

  • Mouse drag selects text in composer
  • Shift+Left/Right extends selection
  • Ctrl+C copies selection to clipboard
  • Ctrl+X cuts selection (copies + deletes)
  • Ctrl+X without selection toggles Plan/Agent/Yolo mode (unchanged)
  • Ctrl/Cmd+Alt+Left/Right moves by word without affecting selection
  • On macOS: Ctrl+Cmd+Left/Right for word jump (Ctrl+Left intercepted by Mission Control)
  • Plain Left/Right clears selection and moves cursor
  • Selection survives text wrapping and scrolling

Greptile Summary

This PR adds mouse drag and Shift+Arrow keyboard text selection to the composer input box, along with Ctrl+C copy and Ctrl+X cut support. Previously-flagged issues (clipboard-failure guards on cut/copy, kill-buffer population in kill_to_end_of_line, stale selection on Home/End/Ctrl+A/Ctrl+E) are all correctly addressed.

  • Introduces selection_anchor on ComposerState and a set of selection helpers (selection_range, selected_text, delete_selection, clear_selection) with good unit-test coverage; all edit operations (insert, delete, yank) properly call delete_selection first.
  • Adds handle_composer_mouse for click/drag coordinate mapping using a new wrap_input_lines_for_mouse utility; the wrapping logic mirrors wrap_text exactly, keeping rendered lines and char-range indices consistent.
  • handle_composer_mouse does not guard against active history-search mode, where composer_display_input() returns the search query instead of app.input; a mouse click during Ctrl+R search writes a query-relative char index into cursor_position, which refers to app.input once the search is dismissed.

Confidence Score: 3/5

Safe to merge with the history-search mouse guard resolved; without it a mouse click during Ctrl+R can silently corrupt cursor position and trigger an unintended delete when the search is dismissed.

The core selection machinery in app.rs and the keyboard paths in ui.rs look correct and well-tested. The one concrete defect is in mouse_ui.rs: handle_composer_mouse uses composer_display_input() (which returns the search query string during history search) to derive a char index, then writes that index into cursor_position and selection_anchor, which are coordinates relative to app.input. When the user dismisses the history search, those stale indices persist and can cause delete_selection to erase an unintended range on the next keystroke.

crates/tui/src/tui/mouse_ui.rs — the handle_composer_mouse function needs an is_history_search_active guard before processing any mouse event.

Important Files Changed

Filename Overview
crates/tui/src/tui/mouse_ui.rs Adds handle_composer_mouse and mouse_pos_to_char_index for click/drag selection; doesn't guard against history-search mode, so mouse events corrupt cursor_position during an active Ctrl+R search.
crates/tui/src/tui/app.rs Adds selection_anchor to ComposerState, viewport geometry fields, and selection helper methods (selection_range, selected_text, delete_selection, clear_selection); kill_to_end_of_line now correctly fills the kill buffer before deleting; comprehensive unit tests added.
crates/tui/src/tui/ui.rs Adds Shift+Arrow keyboard selection extension, clears selection on Home/End/Ctrl+A/Ctrl+E, handles Ctrl+C/Ctrl+X for composer selection with correct clipboard-failure guards, and stores composer viewport geometry after each render.
crates/tui/src/tui/widgets/mod.rs Adds layout_input_with_scroll (scroll-offset now returned), wrap_input_lines_for_mouse (char-start tracking for mouse mapping), visible_line_char_ranges, and line_spans_with_selection; wrapping logic mirrors wrap_text correctly so char ranges and rendered lines stay aligned.
.gitignore Adds ignores for Superpowers plugin artifacts (docs/superpowers/) and Serena tool directory (.serena/); no functional impact.

Sequence Diagram

sequenceDiagram
    participant User
    participant handle_mouse_event
    participant handle_composer_mouse
    participant mouse_pos_to_char_index
    participant App

    User->>handle_mouse_event: MouseDown in composer area
    handle_mouse_event->>handle_composer_mouse: mouse event
    handle_composer_mouse->>App: viewport.last_composer_area / last_composer_content
    handle_composer_mouse->>mouse_pos_to_char_index: col, row, inner rect
    mouse_pos_to_char_index->>App: composer_display_input()
    note over mouse_pos_to_char_index: wraps text via wrap_input_lines_for_mouse
    mouse_pos_to_char_index-->>handle_composer_mouse: char index
    handle_composer_mouse->>App: "cursor_position = char_index"
    handle_composer_mouse->>App: "selection_anchor = None / Some(old_pos)"

    User->>handle_mouse_event: MouseDrag in composer area
    handle_mouse_event->>handle_composer_mouse: drag event
    handle_composer_mouse->>mouse_pos_to_char_index: new col, row
    mouse_pos_to_char_index-->>handle_composer_mouse: new char index
    handle_composer_mouse->>App: "selection_anchor = Some(old cursor) if None"
    handle_composer_mouse->>App: "cursor_position = new char index"

    User->>handle_mouse_event: Ctrl+C / Ctrl+X
    handle_mouse_event->>App: selected_text() → app.input[anchor..cursor]
    App-->>handle_mouse_event: selected string
    handle_mouse_event->>App: clipboard.write_text(sel)
    alt write ok
        handle_mouse_event->>App: push_status_toast(Copied/Cut)
        handle_mouse_event->>App: clear_selection() / delete_selection()
    else write failed
        handle_mouse_event->>App: push_status_toast(Copy/Cut failed)
    end
Loading

Comments Outside Diff (4)

  1. crates/tui/src/tui/mouse_ui.rs, line 481-492 (link)

    P2 copy_active_selection composer branch is now unreachable from the Ctrl+C handler

    The Ctrl+C path in ui.rs already handles a non-empty composer selection before calling copy_active_selection, so app.selected_text() will always be empty here when this function is reached from that code path. The composer block inside copy_active_selection is only reachable from other future callers. When it does fire, it uses app.status_message rather than push_status_toast, producing a different UX than the copy key-shortcut path. If this function is meant to be a shared utility, it should either use the same notification mechanism or the two paths should be consolidated.

    Fix in Codex Fix in Claude Code Fix in Cursor

  2. crates/tui/src/tui/ui.rs, line 3533-3546 (link)

    P1 Home / End / Ctrl+A / Ctrl+E leave stale selection active

    Left and Right both call app.clear_selection() before moving the cursor, but the four analogous cursor-jump keys do not. After a user selects text and presses Home, End, Ctrl+A, or Ctrl+E, selection_anchor remains set and the selection silently re-spans from the new cursor position to the old anchor. A subsequent keystroke (typing, Backspace, Delete, paste) then calls delete_selection() and erases the unintended range — a data-loss path the user never chose to trigger.

    Fix in Codex Fix in Claude Code Fix in Cursor

  3. crates/tui/src/tui/ui.rs, line 3610-3617 (link)

    P1 Up/Down navigation leaves stale selection anchor

    handle_composer_history_arrow routes both KeyCode::Up and KeyCode::Down to vim_move_up / vim_move_down / history_up / history_down — none of which call clear_selection(). When a selection is active and the user presses Up or Down, selection_anchor survives into the repositioned cursor state. The next edit (insert_char, delete_char, etc.) then calls delete_selection() and silently erases the range between the stale anchor and the new cursor position — a range that was never intentionally selected. The history navigation path is especially dangerous: history_up() replaces the entire input buffer but leaves selection_anchor pointing into the old input's coordinate space.

    Fix in Codex Fix in Claude Code Fix in Cursor

  4. crates/tui/src/tui/mouse_ui.rs, line 413-455 (link)

    P1 Mouse handler fires during history-search mode using wrong coordinate space

    handle_composer_mouse calls mouse_pos_to_char_index, which maps pixel position to a char index using app.composer_display_input(). During an active history search, that function returns the search-query string, not app.input. The resulting index is then written into app.cursor_position (and potentially app.selection_anchor), which are coordinates in app.input's address space. When the user subsequently dismisses the history search (Enter or Escape), those stale, search-query-relative indices are applied against the original, differently-sized input — silently landing the cursor at the wrong position, triggering a phantom selection, or causing delete_selection to erase unintended text. Adding if app.is_history_search_active() { return false; } at the top of the function is the minimal guard.

    Fix in Codex Fix in Claude Code Fix in Cursor

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (4): Last reviewed commit: "fix: use composer_display_input() for se..." | Re-trigger Greptile

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.
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/.
- Remove needless borrows flagged by clippy
- Auto-copy selection on mouse release (consistent with transcript)
- Use theme selection_bg instead of hardcoded color
Covered by .gitignore, should not be in the PR.
- 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
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces text selection and mouse coordinate mapping support in the TUI composer, allowing users to select, copy, cut, and delete text ranges. Feedback on the changes highlights issues with selection clearing and word-by-word selection when using cursor movement keys (such as Ctrl+Left/Right with or without Shift), as well as a bug where killing a selection with Ctrl+K (kill_to_end_of_line) fails to copy the deleted text to the kill buffer.

Comment thread crates/tui/src/tui/ui.rs
Comment on lines +3492 to 3517
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();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When cursor movement keys like Ctrl+Left or Ctrl+Right are pressed without holding Shift, the active text selection is not cleared. This causes the selection to unexpectedly expand or distort when jumping the cursor, rather than clearing it as standard text editors do.

Additionally, if Shift is held along with a word modifier (e.g., Shift+Ctrl+Left), the cursor only moves by a single character because the Shift modifier arm matches first and overrides the word-movement logic.

We should update these match arms to correctly handle selection clearing for non-Shift movements, and support word-by-word selection when Shift is combined with the word modifier. (Note: You should also apply app.clear_selection() to other non-selecting cursor movements like Home, End, Ctrl+A, and Ctrl+E in their respective match arms).

                KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => {
                    if app.selection_anchor.is_none() {
                        app.selection_anchor = Some(app.cursor_position);
                    }
                    if is_word_cursor_modifier(key.modifiers) {
                        app.move_cursor_word_backward();
                    } else {
                        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();
                }
                KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => {
                    if app.selection_anchor.is_none() {
                        app.selection_anchor = Some(app.cursor_position);
                    }
                    if is_word_cursor_modifier(key.modifiers) {
                        app.move_cursor_word_forward();
                    } else {
                        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();
                }

Comment thread crates/tui/src/tui/app.rs Outdated
Comment on lines +3594 to +3596
if self.delete_selection() {
return true;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When kill_to_end_of_line (Ctrl+K) is triggered with an active selection, it deletes the selection but does not copy the deleted text to the kill_buffer. This breaks the standard Emacs/readline behavior where killing a region/selection cuts it to the kill ring so it can be yanked (pasted) later.

We should copy the selected text to self.kill_buffer before deleting the selection.

Suggested change
if self.delete_selection() {
return true;
}
if self.selection_range().is_some() {
self.kill_buffer = self.selected_text();
self.delete_selection();
return true;
}

Comment thread crates/tui/src/tui/app.rs Outdated
Comment thread crates/tui/src/tui/ui.rs Outdated
Comment thread crates/tui/src/tui/ui.rs
- 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
@imkingjh999
Copy link
Copy Markdown
Contributor Author

imkingjh999 commented May 26, 2026

@gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces text selection capabilities to the TUI composer, supporting mouse-based dragging, Shift+Arrow keys, and standard clipboard operations (copy, cut, paste, and delete). The review feedback highlights critical issues with cursor navigation: cursor-jump keys (like Home, End, Ctrl+A, and Ctrl+E) fail to clear the active selection, which can lead to accidental data loss on subsequent edits. Additionally, suggestions are provided to support word-based selection when Shift is combined with Control or Alt modifiers.

Comment thread crates/tui/src/tui/ui.rs
app.move_cursor_word_forward();
}
KeyCode::Right => {
app.clear_selection();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

While Left and Right (and their word-jump variants) correctly call app.clear_selection() to clear any active selection when moving the cursor, other cursor-jump keys like Home, End, Ctrl+A, and Ctrl+E do not.

If a user selects some text and then presses Home, End, Ctrl+A, or Ctrl+E, the selection_anchor remains set, causing the selection to silently re-span from the new cursor position to the old anchor. Any subsequent edit operation (such as typing, backspace, or pasting) will then trigger delete_selection() and erase the unintended range, leading to accidental data loss.

Please ensure that app.clear_selection() is also called in the match arms for KeyCode::Home, KeyCode::End, KeyCode::Home | KeyCode::Char('a'), and KeyCode::Char('e').

Comment thread crates/tui/src/tui/ui.rs
Comment on lines +3495 to +3500
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();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When SHIFT is combined with CONTROL or ALT (e.g., Ctrl+Shift+Left), the current match arm for SHIFT is matched first, but it only moves the cursor by a single character (app.move_cursor_left()) instead of a whole word. We can easily support word-based selection by checking is_word_cursor_modifier(key.modifiers) inside the SHIFT match arm.

                KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => {
                    if app.selection_anchor.is_none() {
                        app.selection_anchor = Some(app.cursor_position);
                    }
                    if is_word_cursor_modifier(key.modifiers) {
                        app.move_cursor_word_backward();
                    } else {
                        app.move_cursor_left();
                    }
                }

Comment thread crates/tui/src/tui/ui.rs
Comment on lines +3509 to +3514
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();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When SHIFT is combined with CONTROL or ALT (e.g., Ctrl+Shift+Right), the current match arm for SHIFT is matched first, but it only moves the cursor by a single character (app.move_cursor_right()) instead of a whole word. We can easily support word-based selection by checking is_word_cursor_modifier(key.modifiers) inside the SHIFT match arm.

                KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => {
                    if app.selection_anchor.is_none() {
                        app.selection_anchor = Some(app.cursor_position);
                    }
                    if is_word_cursor_modifier(key.modifiers) {
                        app.move_cursor_word_forward();
                    } else {
                        app.move_cursor_right();
                    }
                }

…tion

- Home, End, Ctrl+A, Ctrl+E now clear active selection before moving
- Shift+Ctrl+Left/Right selects by word instead of by character
@imkingjh999
Copy link
Copy Markdown
Contributor Author

@gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements text selection, copying, cutting, and mouse-drag selection capabilities within the TUI composer. It updates key bindings (such as Shift+Left/Right, Ctrl+C, Ctrl+X, and deletion keys) and mouse event handling to support selection anchors and ranges. The review feedback highlights two important issues where the raw app.input is used instead of the displayed search query (input_text or app.composer_display_input()) when history search is active, which can lead to mismatched character ranges and incorrect cursor/selection mapping.

Comment on lines +670 to +675
let line_ranges = visible_line_char_ranges(
&self.app.input,
&visible_lines,
content_width,
scroll_offset,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When history search is active, input_text represents the displayed search query, which can differ from self.app.input. Passing &self.app.input to visible_line_char_ranges while zipping with visible_lines (which are wrapped from input_text) will result in mismatched character ranges and incorrect selection highlighting. We should pass input_text instead.

Suggested change
let line_ranges = visible_line_char_ranges(
&self.app.input,
&visible_lines,
content_width,
scroll_offset,
);
let line_ranges = visible_line_char_ranges(
input_text,
&visible_lines,
content_width,
scroll_offset,
);

Comment thread crates/tui/src/tui/mouse_ui.rs Outdated
Comment on lines +49 to +64
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());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In mouse_pos_to_char_index, using app.input directly can cause incorrect cursor positioning and selection mapping when history search is active (since the displayed text is the search query returned by app.composer_display_input(), not the underlying app.input). We should use app.composer_display_input() consistently.

Suggested change
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 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(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);
// 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(input_text.chars().count());
}

Ensures mouse coordinate mapping and selection highlighting use the
displayed text (search query) rather than the underlying input when
history search is active.
@imkingjh999
Copy link
Copy Markdown
Contributor Author

@gemini review

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 26, 2026

Harvested in build/v0.8.47 (commits 8446371 + a554aa9). Changes landed:

  • Mouse + Shift+Arrow text selection ✓
  • Copy/cut support ✓
  • Selection highlighting ✓
  • Added clear_selection() for Home/End/Ctrl+A/Ctrl+E (the missing calls flagged in review)

Thank you @imkingjh999! Added to CHANGELOG under Added + Community credits.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces text selection support to the TUI composer, enabling both mouse-based (click and drag) and keyboard-based (Shift + Arrow keys) selections. It integrates selection handling with copy, cut, and delete operations, updates the rendering logic to highlight active selections, and adds comprehensive unit tests. The reviewer suggested a performance optimization in crates/tui/src/tui/widgets/mod.rs to avoid allocating a temporary Vec for byte_offsets on every rendered line by using .char_indices().nth() directly.

Comment on lines +2516 to +2527
// Build a Vec of byte offsets for each char boundary, plus one past the end.
let mut byte_offsets: Vec<usize> = 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());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can avoid allocating a temporary Vec for byte_offsets on every rendered line by using .char_indices().nth() directly to find the byte offsets of local_sel_start and local_sel_end. This improves performance and reduces memory churn during rendering.

Suggested change
// Build a Vec of byte offsets for each char boundary, plus one past the end.
let mut byte_offsets: Vec<usize> = 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 = if local_sel_start == 0 {
0
} else {
line.char_indices()
.nth(local_sel_start)
.map(|(i, _)| i)
.unwrap_or(line.len())
};
let b1 = if local_sel_end == 0 {
0
} else {
line.char_indices()
.nth(local_sel_end)
.map(|(i, _)| i)
.unwrap_or(line.len())
};

@github-actions
Copy link
Copy Markdown

Thanks @imkingjh999 — your contribution landed in 84463711b493 on main:

feat(composer): mouse + keyboard text selection with copy/cut

Closing this PR now that the code is on main. Credit lives in the commit message and (where applicable) the CHANGELOG.md entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31.

If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the CONTRIBUTING.md doc has a short note on what makes a contribution mergeable as-is.

@github-actions github-actions Bot closed this May 26, 2026
pull Bot pushed a commit to TheTechOddBug/DeepSeek-TUI that referenced this pull request May 26, 2026
Add mouse drag selection and Shift+Arrow text selection in the composer
input box. Ctrl+C copies selected text; Ctrl+X cuts (or toggles mode if
no selection). Selection highlighting uses the theme's selection_bg color.
Mouse coordinate mapping accounts for wrapping, scroll offset, and padding.

Also fix Home, End, Ctrl+A, and Ctrl+E to clear the selection anchor
before jumping, matching the existing Left/Right behavior. Without these
calls a stale anchor silently reforms a selection and can cause unintended
deletions on the next keystroke.

Harvested from Hmbown#2228.

Co-authored-by: imkingjh999 <imkingjh999@users.noreply.github.com>
pull Bot pushed a commit to TheTechOddBug/DeepSeek-TUI that referenced this pull request May 26, 2026
Add entries for deadlock fix (Hmbown#1856), composer text selection (Hmbown#2228),
and project context loading tracing (Hmbown#2227). Add community credits for
@Fire-dtx and @imkingjh999.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants