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 docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits.

### Fixed
- Copy-mode arrow, Page Up/Down, Home, and End keys now repeat when held in terminals that report explicit key-repeat events.
- Configs containing the retired Herdr-written `ui.agent_panel_scope` setting no longer report it as an unknown key after upgrades. (#2292)
- Claude Code confirmation prompts using `Enter to confirm · Esc to cancel` now report `blocked` instead of `idle`. (#2268)
- Sidebar agent lists keep scrolling when differently sized clients are attached to the same session. (#2255, thanks @aiworkflowpro)
Expand Down
32 changes: 32 additions & 0 deletions src/app/input/copy_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ impl App {
}

impl AppState {
pub(crate) fn allows_copy_navigation_repeat(&self, key: &TerminalKey) -> bool {
self.mode == Mode::Copy && !self.is_prefix_key(key) && is_repeatable_navigation_key(key)
}

pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) {
let Some(ws_idx) = self.active else {
return;
Expand Down Expand Up @@ -955,6 +959,20 @@ fn copy_mode_page_lines(height: u16, half_page: bool) -> usize {
}
}

fn is_repeatable_navigation_key(key: &TerminalKey) -> bool {
matches!(
key.code,
KeyCode::Left
| KeyCode::Down
| KeyCode::Up
| KeyCode::Right
| KeyCode::PageUp
| KeyCode::PageDown
| KeyCode::Home
| KeyCode::End
)
}

fn copy_mode_command_char(key: TerminalKey) -> Option<char> {
if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
return None;
Expand Down Expand Up @@ -1166,6 +1184,20 @@ mod tests {
);
}

#[tokio::test]
async fn copy_mode_repeats_ghostty_enhanced_arrow_navigation() {
let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n");
app.state.enter_copy_mode(&app.terminal_runtimes);
app.state.copy_mode.as_mut().expect("copy mode").cursor_col = 3;

app.route_client_input(b"\x1b[1;1:1D\x1b[1;1:2D\x1b[1;1:2D\x1b[1;1:3D".to_vec());

assert_eq!(
app.state.copy_mode.as_ref().expect("copy mode").cursor_col,
0
);
}

#[tokio::test]
async fn copy_mode_ctrl_b_uses_page_up() {
let bytes = numbered_lines_bytes(64);
Expand Down
14 changes: 13 additions & 1 deletion src/app/input/lease.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub(crate) struct ForwardedInputLease {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ConsumedInputLease {
ReprocessRepeats(TerminalInputContext),
RepeatCopyNavigation,
SuppressRepeats,
}

Expand Down Expand Up @@ -129,7 +130,9 @@ impl InputLeaseTable {
self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats);
return RepeatPlan::Ignore;
}
Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => {
Some(InputLease::Consumed(
ConsumedInputLease::RepeatCopyNavigation | ConsumedInputLease::SuppressRepeats,
)) => {
return RepeatPlan::Ignore;
}
None => {}
Expand Down Expand Up @@ -170,6 +173,15 @@ impl InputLeaseTable {
self.leases.contains_key(key)
}

pub(crate) fn repeats_copy_navigation(&self, key: &InputLeaseKey) -> bool {
matches!(
self.leases.get(key),
Some(InputLease::Consumed(
ConsumedInputLease::RepeatCopyNavigation
))
)
}

pub(crate) fn insert_forwarded(
&mut self,
key: InputLeaseKey,
Expand Down
21 changes: 21 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1683,6 +1683,8 @@ impl App {
let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
let repeat_copy_navigation =
self.state.allows_copy_navigation_repeat(&key);
let initial_context = self.terminal_input_context();
let target = if initial_context.is_some() {
self.handle_terminal_key_headless_from(source_id, key.clone())
Expand All @@ -1698,8 +1700,27 @@ impl App {
resulting_context.as_ref(),
target,
);
if repeat_copy_navigation && self.state.mode == Mode::Copy {
self.input_leases.insert_consumed(
lease_key,
input::ConsumedInputLease::RepeatCopyNavigation,
);
}
self.execute_repeat_plan_headless(source_id, lease_key, key, plan);
}
crossterm::event::KeyEventKind::Repeat
if self.state.allows_copy_navigation_repeat(&key)
&& self.input_leases.repeats_copy_navigation(&lease_key) =>
{
let repetitions = key.repeat_count;
let key = key.with_repeat_count(1);
for _ in 0..repetitions {
if self.state.mode != Mode::Copy {
break;
}
self.handle_non_terminal_key_headless(key.clone());
}
}
crossterm::event::KeyEventKind::Repeat => {
let current_context = self.terminal_input_context();
let plan = self.input_leases.plan_repeat(
Expand Down
21 changes: 21 additions & 0 deletions src/app/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ impl App {
let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
let repeat_copy_navigation = self.state.allows_copy_navigation_repeat(&key);
let initial_context = self.terminal_input_context();
let target = self.handle_key(key.clone()).await;
let resulting_context = self.terminal_input_context();
Expand All @@ -191,9 +192,29 @@ impl App {
resulting_context.as_ref(),
target,
);
if repeat_copy_navigation && self.state.mode == crate::app::Mode::Copy {
self.input_leases.insert_consumed(
lease_key,
super::input::ConsumedInputLease::RepeatCopyNavigation,
);
}
self.execute_repeat_plan(lease_key, key, plan).await;
true
}
crossterm::event::KeyEventKind::Repeat
if self.state.allows_copy_navigation_repeat(&key)
&& self.input_leases.repeats_copy_navigation(&lease_key) =>
{
let repetitions = key.repeat_count;
let key = key.with_repeat_count(1);
for _ in 0..repetitions {
if self.state.mode != crate::app::Mode::Copy {
break;
}
self.handle_key(key.clone()).await;
}
true
}
crossterm::event::KeyEventKind::Repeat => {
let current_context = self.terminal_input_context();
let plan = self.input_leases.plan_repeat(
Expand Down
Loading