diff --git a/src-rust/crates/core/src/keybindings.rs b/src-rust/crates/core/src/keybindings.rs index aac0a45..2821f89 100644 --- a/src-rust/crates/core/src/keybindings.rs +++ b/src-rust/crates/core/src/keybindings.rs @@ -212,6 +212,7 @@ pub const NON_REBINDABLE: &[&str] = &["ctrl+c", "ctrl+d", "ctrl+m"]; /// - **Ctrl+K**: Open the command palette /// - **Ctrl+U**: Kill input from cursor to start of line (Emacs-style) /// - **Alt+←/Alt+→**: Navigate to previous/next message in transcript +/// - **→ (Right)**: Accept typeahead suggestion when the cursor is at the end of the input /// - **Ctrl+. (Ctrl+>)**: Jump to next error/issue in messages /// - **Ctrl+Shift+.**: Jump to previous error/issue /// - **Shift+Tab**: Reverse indent/unindent in input (cycle permission mode) @@ -255,6 +256,9 @@ pub fn default_bindings() -> Vec { // Word navigation ("ctrl+left", "moveWordBackward", KeyContext::Chat), ("ctrl+right", "moveWordForward", KeyContext::Chat), + // Cursor movement / typeahead acceptance (fish-style: right arrow at + // the end of the input accepts the current suggestion) + ("right", "moveRight", KeyContext::Chat), // Word deletion ("ctrl+w", "killWord", KeyContext::Chat), ("alt+backspace", "killWord", KeyContext::Chat), @@ -996,4 +1000,26 @@ mod tests { other => panic!("Expected Action(\"goLineStart\"), got {:?}", other), } } + + #[test] + fn test_right_resolves_to_move_right_in_chat() { + let user = UserKeybindings::default(); + let mut resolver = KeybindingResolver::new(&user); + + let keystroke = ParsedKeystroke { + key: "right".to_string(), + ctrl: false, + alt: false, + shift: false, + meta: false, + }; + + let result = resolver.process(keystroke, &KeyContext::Chat); + match result { + KeybindingResult::Action(action) => { + assert_eq!(action, "moveRight", "Right should map to moveRight"); + } + other => panic!("Expected Action(\"moveRight\"), got {:?}", other), + } + } } diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 070189a..bf74a6e 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -5477,6 +5477,26 @@ impl App { } false } + "moveRight" => { + // Right arrow: accept the typeahead suggestion when the + // cursor sits at the end of the input (fish-style); + // otherwise move the cursor. Acceptance works while + // streaming so the popup stays interactive when a turn is + // in flight — Enter then queues the completed command. + if !self.prompt_input.suggestions.is_empty() + && self.prompt_input.cursor == self.prompt_input.text.len() + { + if self.prompt_input.suggestion_index.is_none() { + self.prompt_input.suggestion_index = Some(0); + } + self.prompt_input.accept_suggestion(); + self.refresh_prompt_input(); + } else { + self.prompt_input.move_right(); + self.sync_legacy_prompt_fields(); + } + false + } "killToStart" => { if !self.is_streaming { self.prompt_input.kill_line_backward(); @@ -5652,19 +5672,19 @@ impl App { false } "indent" => { - // Tab: cycle agent mode when prompt is empty, accept - // slash-command suggestion otherwise. - if !self.is_streaming { - if !self.prompt_input.suggestions.is_empty() { - if self.prompt_input.suggestion_index.is_none() { - self.prompt_input.suggestion_index = Some(0); - } - self.prompt_input.accept_suggestion(); - self.refresh_prompt_input(); - } else if self.prompt_input.is_empty() { - self.cycle_agent_mode(); - self.companion_look_down(); + // Tab: accept the typeahead suggestion, or cycle agent mode + // when the prompt is empty. Acceptance is allowed while + // streaming so the popup stays interactive when a turn is + // in flight — Enter then queues the completed command. + if !self.prompt_input.suggestions.is_empty() { + if self.prompt_input.suggestion_index.is_none() { + self.prompt_input.suggestion_index = Some(0); } + self.prompt_input.accept_suggestion(); + self.refresh_prompt_input(); + } else if !self.is_streaming && self.prompt_input.is_empty() { + self.cycle_agent_mode(); + self.companion_look_down(); } false } @@ -8041,6 +8061,57 @@ role = "Research" assert!(app.has_credentials); } + #[test] + fn right_arrow_at_end_accepts_typeahead_suggestion() { + let mut app = make_app(); + app.prompt_input.text = "/mode".to_string(); + app.prompt_input.cursor = app.prompt_input.text.len(); + app.refresh_prompt_input(); + assert!( + !app.prompt_input.suggestions.is_empty(), + "typing /mode must surface the /model suggestion" + ); + + app.handle_keybinding_action("moveRight"); + + assert_eq!(app.prompt_input.text, "/model"); + assert_eq!(app.prompt_input.cursor, app.prompt_input.text.len()); + } + + #[test] + fn right_arrow_mid_text_moves_cursor_without_accepting() { + let mut app = make_app(); + app.prompt_input.text = "/mode".to_string(); + app.prompt_input.cursor = 1; + app.refresh_prompt_input(); + assert!(!app.prompt_input.suggestions.is_empty()); + + app.handle_keybinding_action("moveRight"); + + assert_eq!( + app.prompt_input.text, "/mode", + "right arrow mid-text must move the cursor, not accept" + ); + assert_eq!(app.prompt_input.cursor, 2); + } + + #[test] + fn tab_accepts_typeahead_suggestion_while_streaming() { + let mut app = make_app(); + app.is_streaming = true; + app.prompt_input.text = "/mode".to_string(); + app.prompt_input.cursor = app.prompt_input.text.len(); + app.refresh_prompt_input(); + assert!(!app.prompt_input.suggestions.is_empty()); + + app.handle_keybinding_action("indent"); + + assert_eq!( + app.prompt_input.text, "/model", + "Tab must accept the suggestion even while a turn is streaming" + ); + } + #[test] fn model_picker_accepts_openai_codex_alias() { let temp = tempfile::tempdir().expect("tempdir");