Skip to content

Commit f8e1851

Browse files
authored
feat(tui): right arrow accepts the typeahead suggestion at end of input (#157)
Fish-style acceptance: a configurable 'right' -> moveRight binding in the Chat context routes through the keybinding system; the handler accepts the current suggestion when the cursor sits at the end of the input and moves the cursor otherwise. Tab acceptance now also works while a turn is streaming, so the popup stays interactive and Enter queues the completed command.
1 parent 238293a commit f8e1851

2 files changed

Lines changed: 109 additions & 12 deletions

File tree

src-rust/crates/core/src/keybindings.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ pub const NON_REBINDABLE: &[&str] = &["ctrl+c", "ctrl+d", "ctrl+m"];
212212
/// - **Ctrl+K**: Open the command palette
213213
/// - **Ctrl+U**: Kill input from cursor to start of line (Emacs-style)
214214
/// - **Alt+←/Alt+→**: Navigate to previous/next message in transcript
215+
/// - **→ (Right)**: Accept typeahead suggestion when the cursor is at the end of the input
215216
/// - **Ctrl+. (Ctrl+>)**: Jump to next error/issue in messages
216217
/// - **Ctrl+Shift+.**: Jump to previous error/issue
217218
/// - **Shift+Tab**: Reverse indent/unindent in input (cycle permission mode)
@@ -255,6 +256,9 @@ pub fn default_bindings() -> Vec<ParsedBinding> {
255256
// Word navigation
256257
("ctrl+left", "moveWordBackward", KeyContext::Chat),
257258
("ctrl+right", "moveWordForward", KeyContext::Chat),
259+
// Cursor movement / typeahead acceptance (fish-style: right arrow at
260+
// the end of the input accepts the current suggestion)
261+
("right", "moveRight", KeyContext::Chat),
258262
// Word deletion
259263
("ctrl+w", "killWord", KeyContext::Chat),
260264
("alt+backspace", "killWord", KeyContext::Chat),
@@ -996,4 +1000,26 @@ mod tests {
9961000
other => panic!("Expected Action(\"goLineStart\"), got {:?}", other),
9971001
}
9981002
}
1003+
1004+
#[test]
1005+
fn test_right_resolves_to_move_right_in_chat() {
1006+
let user = UserKeybindings::default();
1007+
let mut resolver = KeybindingResolver::new(&user);
1008+
1009+
let keystroke = ParsedKeystroke {
1010+
key: "right".to_string(),
1011+
ctrl: false,
1012+
alt: false,
1013+
shift: false,
1014+
meta: false,
1015+
};
1016+
1017+
let result = resolver.process(keystroke, &KeyContext::Chat);
1018+
match result {
1019+
KeybindingResult::Action(action) => {
1020+
assert_eq!(action, "moveRight", "Right should map to moveRight");
1021+
}
1022+
other => panic!("Expected Action(\"moveRight\"), got {:?}", other),
1023+
}
1024+
}
9991025
}

src-rust/crates/tui/src/app.rs

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5477,6 +5477,26 @@ impl App {
54775477
}
54785478
false
54795479
}
5480+
"moveRight" => {
5481+
// Right arrow: accept the typeahead suggestion when the
5482+
// cursor sits at the end of the input (fish-style);
5483+
// otherwise move the cursor. Acceptance works while
5484+
// streaming so the popup stays interactive when a turn is
5485+
// in flight — Enter then queues the completed command.
5486+
if !self.prompt_input.suggestions.is_empty()
5487+
&& self.prompt_input.cursor == self.prompt_input.text.len()
5488+
{
5489+
if self.prompt_input.suggestion_index.is_none() {
5490+
self.prompt_input.suggestion_index = Some(0);
5491+
}
5492+
self.prompt_input.accept_suggestion();
5493+
self.refresh_prompt_input();
5494+
} else {
5495+
self.prompt_input.move_right();
5496+
self.sync_legacy_prompt_fields();
5497+
}
5498+
false
5499+
}
54805500
"killToStart" => {
54815501
if !self.is_streaming {
54825502
self.prompt_input.kill_line_backward();
@@ -5652,19 +5672,19 @@ impl App {
56525672
false
56535673
}
56545674
"indent" => {
5655-
// Tab: cycle agent mode when prompt is empty, accept
5656-
// slash-command suggestion otherwise.
5657-
if !self.is_streaming {
5658-
if !self.prompt_input.suggestions.is_empty() {
5659-
if self.prompt_input.suggestion_index.is_none() {
5660-
self.prompt_input.suggestion_index = Some(0);
5661-
}
5662-
self.prompt_input.accept_suggestion();
5663-
self.refresh_prompt_input();
5664-
} else if self.prompt_input.is_empty() {
5665-
self.cycle_agent_mode();
5666-
self.companion_look_down();
5675+
// Tab: accept the typeahead suggestion, or cycle agent mode
5676+
// when the prompt is empty. Acceptance is allowed while
5677+
// streaming so the popup stays interactive when a turn is
5678+
// in flight — Enter then queues the completed command.
5679+
if !self.prompt_input.suggestions.is_empty() {
5680+
if self.prompt_input.suggestion_index.is_none() {
5681+
self.prompt_input.suggestion_index = Some(0);
56675682
}
5683+
self.prompt_input.accept_suggestion();
5684+
self.refresh_prompt_input();
5685+
} else if !self.is_streaming && self.prompt_input.is_empty() {
5686+
self.cycle_agent_mode();
5687+
self.companion_look_down();
56685688
}
56695689
false
56705690
}
@@ -8041,6 +8061,57 @@ role = "Research"
80418061
assert!(app.has_credentials);
80428062
}
80438063

8064+
#[test]
8065+
fn right_arrow_at_end_accepts_typeahead_suggestion() {
8066+
let mut app = make_app();
8067+
app.prompt_input.text = "/mode".to_string();
8068+
app.prompt_input.cursor = app.prompt_input.text.len();
8069+
app.refresh_prompt_input();
8070+
assert!(
8071+
!app.prompt_input.suggestions.is_empty(),
8072+
"typing /mode must surface the /model suggestion"
8073+
);
8074+
8075+
app.handle_keybinding_action("moveRight");
8076+
8077+
assert_eq!(app.prompt_input.text, "/model");
8078+
assert_eq!(app.prompt_input.cursor, app.prompt_input.text.len());
8079+
}
8080+
8081+
#[test]
8082+
fn right_arrow_mid_text_moves_cursor_without_accepting() {
8083+
let mut app = make_app();
8084+
app.prompt_input.text = "/mode".to_string();
8085+
app.prompt_input.cursor = 1;
8086+
app.refresh_prompt_input();
8087+
assert!(!app.prompt_input.suggestions.is_empty());
8088+
8089+
app.handle_keybinding_action("moveRight");
8090+
8091+
assert_eq!(
8092+
app.prompt_input.text, "/mode",
8093+
"right arrow mid-text must move the cursor, not accept"
8094+
);
8095+
assert_eq!(app.prompt_input.cursor, 2);
8096+
}
8097+
8098+
#[test]
8099+
fn tab_accepts_typeahead_suggestion_while_streaming() {
8100+
let mut app = make_app();
8101+
app.is_streaming = true;
8102+
app.prompt_input.text = "/mode".to_string();
8103+
app.prompt_input.cursor = app.prompt_input.text.len();
8104+
app.refresh_prompt_input();
8105+
assert!(!app.prompt_input.suggestions.is_empty());
8106+
8107+
app.handle_keybinding_action("indent");
8108+
8109+
assert_eq!(
8110+
app.prompt_input.text, "/model",
8111+
"Tab must accept the suggestion even while a turn is streaming"
8112+
);
8113+
}
8114+
80448115
#[test]
80458116
fn model_picker_accepts_openai_codex_alias() {
80468117
let temp = tempfile::tempdir().expect("tempdir");

0 commit comments

Comments
 (0)