aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 21:57:09 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 21:57:09 +0300
commit10b17a6de2bd067a0f6fe1a465a9c9827939aa00 (patch)
tree90ca22614690c612fe8712e9e5ecb6fbd4ab0bff /src
parentecf8b08595580e8a07fc4cf85e4800b1c1213cf3 (diff)
fix: scope repeat events to terminal mode
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs142
1 files changed, 137 insertions, 5 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 1ef52e1f..a6f18177 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -8,6 +8,7 @@ mod actions;
mod input;
pub mod state;
+use std::collections::HashSet;
use std::future::pending;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -52,6 +53,7 @@ pub struct App {
next_animation_tick: Option<Instant>,
next_auto_update_check: Option<Instant>,
last_render_at: Option<Instant>,
+ suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
}
@@ -81,6 +83,12 @@ async fn sleep_until_or_pending(deadline: Option<Instant>) {
}
}
+fn repeat_key_identity(
+ key: &crate::input::TerminalKey,
+) -> (crossterm::event::KeyCode, crossterm::event::KeyModifiers) {
+ (key.code, key.modifiers)
+}
+
fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
use crate::api::schema::Method;
@@ -282,6 +290,7 @@ impl App {
next_auto_update_check: (!no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
last_render_at: None,
+ suppressed_repeat_keys: HashSet::new(),
api_rx,
event_hub,
last_focus,
@@ -448,11 +457,31 @@ impl App {
async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) -> bool {
match event {
crate::raw_input::RawInputEvent::Key(key) => {
- if key.kind == crossterm::event::KeyEventKind::Press {
- self.handle_key(key).await;
- true
- } else {
- false
+ let key_id = repeat_key_identity(&key);
+ match key.kind {
+ crossterm::event::KeyEventKind::Press => {
+ if self.state.mode == Mode::Terminal {
+ self.suppressed_repeat_keys.remove(&key_id);
+ } else {
+ self.suppressed_repeat_keys.insert(key_id);
+ }
+ self.handle_key(key).await;
+ true
+ }
+ crossterm::event::KeyEventKind::Repeat => {
+ if self.state.mode == Mode::Terminal
+ && !self.suppressed_repeat_keys.contains(&key_id)
+ {
+ self.handle_key(key).await;
+ true
+ } else {
+ false
+ }
+ }
+ crossterm::event::KeyEventKind::Release => {
+ self.suppressed_repeat_keys.remove(&key_id);
+ false
+ }
}
}
crate::raw_input::RawInputEvent::Paste(text) => {
@@ -2106,6 +2135,26 @@ mod tests {
use crate::config::Config;
use crate::detect::{Agent, AgentState};
use crate::workspace::Workspace;
+ use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
+
+ fn raw_key(
+ code: KeyCode,
+ modifiers: KeyModifiers,
+ kind: KeyEventKind,
+ ) -> crate::raw_input::RawInputEvent {
+ crate::raw_input::RawInputEvent::Key(
+ crate::input::TerminalKey::new(code, modifiers).with_kind(kind),
+ )
+ }
+
+ fn release_notes_state() -> state::ReleaseNotesState {
+ state::ReleaseNotesState {
+ version: "0.1.0".into(),
+ body: "notes".into(),
+ scroll: 0,
+ preview: true,
+ }
+ }
fn test_app() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -2126,6 +2175,89 @@ mod tests {
assert!(result.is_err());
}
+ #[tokio::test]
+ async fn terminal_mode_handles_repeat_key_events() {
+ let mut app = test_app();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let handled = app
+ .handle_raw_input_event(raw_key(
+ KeyCode::Backspace,
+ KeyModifiers::empty(),
+ KeyEventKind::Repeat,
+ ))
+ .await;
+
+ assert!(handled);
+ }
+
+ #[tokio::test]
+ async fn repeat_key_events_are_ignored_outside_terminal_mode() {
+ let mut app = test_app();
+ app.state.mode = Mode::ReleaseNotes;
+ app.state.release_notes = Some(release_notes_state());
+
+ let handled = app
+ .handle_raw_input_event(raw_key(
+ KeyCode::Enter,
+ KeyModifiers::empty(),
+ KeyEventKind::Repeat,
+ ))
+ .await;
+
+ assert!(!handled);
+ assert_eq!(app.state.mode, Mode::ReleaseNotes);
+ assert!(app.state.release_notes.is_some());
+ }
+
+ #[tokio::test]
+ async fn modal_press_does_not_leak_repeat_into_terminal_mode() {
+ let mut app = test_app();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::ReleaseNotes;
+ app.state.release_notes = Some(release_notes_state());
+
+ let press_handled = app
+ .handle_raw_input_event(raw_key(
+ KeyCode::Enter,
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ ))
+ .await;
+ let repeat_handled = app
+ .handle_raw_input_event(raw_key(
+ KeyCode::Enter,
+ KeyModifiers::empty(),
+ KeyEventKind::Repeat,
+ ))
+ .await;
+ let release_handled = app
+ .handle_raw_input_event(raw_key(
+ KeyCode::Enter,
+ KeyModifiers::empty(),
+ KeyEventKind::Release,
+ ))
+ .await;
+ let next_press_handled = app
+ .handle_raw_input_event(raw_key(
+ KeyCode::Enter,
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ ))
+ .await;
+
+ assert!(press_handled);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ assert!(!repeat_handled);
+ assert!(!release_handled);
+ assert!(next_press_handled);
+ }
+
#[test]
fn read_only_api_requests_do_not_force_rerender() {
let read_only = crate::api::schema::Request {