Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed
- input mode (normal/insert/visual/search) is now tracked per tab; switching tabs restores each tab's own mode
- Ctrl+C now copies only when there is an active selection, otherwise it sends SIGINT


## [0.9.0] - 2026-07-05
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ Navigate freely to position the cursor, press `v` to set the selection anchor, t
| `g` / `G` | Top / bottom of viewport |
| `v` | Set selection anchor at cursor (starts highlighting) |
| `o` | Swap anchor and cursor |
| `y` / `Ctrl+C` | Copy selection and exit |
| `y` / `Ctrl+C` | Copy selection and exit (`Ctrl+C` copies only with an active selection; otherwise it sends SIGINT) |
| `Y` | Yank (copy) the entire line at cursor |
| `q` / `Escape` | Exit to Insert mode |

Expand Down
2 changes: 1 addition & 1 deletion doc/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ actions shift the anchor coordinates so the selected content stays stable.
| `g` / `G` | Top / bottom of viewport |
| `v` | Set selection anchor at cursor (activates highlight) |
| `o` | Swap anchor and cursor (extend from either end) |
| `y` / `Ctrl+C` | Copy selection to clipboard, return to Insert mode |
| `y` / `Ctrl+C` | Copy selection to clipboard, return to Insert mode (`Ctrl+C` copies only with an active selection; otherwise it sends SIGINT) |
| `Y` | Yank (copy) the entire line at the cursor, return to Insert mode |
| `q` / `Escape` | Exit to Insert mode |

Expand Down
14 changes: 12 additions & 2 deletions src/input/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,16 @@ fn shift_scroll_action(key: &Key, grid_rows: usize) -> Option<Action> {
}
}

/// Ctrl+C copies only when there is a real (anchored) selection; in every
/// other mode (Insert, Normal, un-anchored Visual) it sends SIGINT (0x03).
fn ctrl_c_action(mode: &InputMode) -> Action {
if matches!(mode, InputMode::Visual { anchored: true, .. }) {
Action::Copy
} else {
Action::SendToPty(vec![3])
}
}

fn handle_ctrl_only(key: &Key, alt: bool, mode: &InputMode) -> Option<Action> {
if let Key::Character(s) = key {
if s.eq_ignore_ascii_case("w") {
Expand All @@ -254,8 +264,8 @@ fn handle_ctrl_only(key: &Key, alt: bool, mode: &InputMode) -> Option<Action> {
if let Some(a) = ctrl_special_char_action(s, mode) {
return Some(a);
}
if s.eq_ignore_ascii_case("c") && matches!(mode, InputMode::Visual { .. }) {
return Some(Action::Copy);
if s.eq_ignore_ascii_case("c") {
return Some(ctrl_c_action(mode));
}
}
ctrl_char_action(key, alt)
Expand Down
49 changes: 47 additions & 2 deletions src/input/keybindings_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,19 +328,64 @@ fn ctrl_shift_end_scrolls_to_bottom() {
assert!(matches!(a, Action::ScrollToBottom));
}

fn visual_anchored() -> InputMode {
InputMode::Visual {
start_col: 0,
start_row: 0,
cur_col: 0,
cur_row: 0,
anchored: true,
}
}

#[test]
fn ctrl_c_in_visual_copies() {
let a = handle_key_inner(&char_key("c"), true, false, false, &visual(), 80, 24, false);
fn ctrl_c_in_anchored_visual_copies() {
let a = handle_key_inner(
&char_key("c"),
true,
false,
false,
&visual_anchored(),
80,
24,
false,
);
assert!(matches!(a, Action::Copy));
}

#[test]
fn ctrl_c_in_unanchored_visual_sends_sigint() {
// Un-anchored Visual has no highlighted selection; ctrl+c must send SIGINT,
// not a no-op Copy.
let a = handle_key_inner(&char_key("c"), true, false, false, &visual(), 80, 24, false);
assert!(matches!(a, Action::SendToPty(ref v) if v == &[3]));
}

#[test]
fn ctrl_c_in_insert_does_not_copy() {
// In insert mode ctrl+c is sent as byte 0x03 to the PTY
let a = handle_key_inner(&char_key("c"), true, false, false, &insert(), 80, 24, false);
assert!(matches!(a, Action::SendToPty(ref v) if v == &[3]));
}

#[test]
fn ctrl_c_in_normal_sends_sigint() {
let a = handle_key_inner(&char_key("c"), true, false, false, &normal(), 80, 24, false);
assert!(matches!(a, Action::SendToPty(ref v) if v == &[3]));
}

#[test]
fn ctrl_shift_c_in_insert_does_not_copy() {
let a = handle_key_inner(&char_key("c"), true, true, false, &insert(), 80, 24, false);
assert!(!matches!(a, Action::Copy));
}

#[test]
fn ctrl_alt_c_in_insert_does_not_copy() {
let a = handle_key_inner(&char_key("c"), true, false, true, &insert(), 80, 24, false);
assert!(!matches!(a, Action::Copy));
}

#[test]
fn ctrl_q_quits() {
let a = handle_key_inner(&char_key("q"), true, false, false, &insert(), 80, 24, false);
Expand Down