Skip to content

Commit 8da4a29

Browse files
committed
fix(keyboard): fix schema upgrade and ctrl+a conflict
Fixed keybinding schema versioning to properly merge defaults with user configs. Old-format keybindings.json files were assigned schema_version=1 (preventing smart_merge), now assigned version=0 to trigger merges. Also migrated ctrl+a from openModelPicker to goLineStart (standard terminal behavior, fixes Ghostty CMD+Left conflict), with openModelPicker now on ctrl+shift+a. Added tests verifying schema upgrade behavior and keybinding resolution.
1 parent df50192 commit 8da4a29

3 files changed

Lines changed: 79 additions & 8 deletions

File tree

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

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ pub const NON_REBINDABLE: &[&str] = &["ctrl+c", "ctrl+d", "ctrl+m"];
145145
///
146146
/// # Standard Keybindings (Phase 1 Implementation)
147147
/// - **Ctrl+L**: Clear current input line (like bash) [Chat context only due to conflict]
148-
/// - **Ctrl+A**: Open the model picker
148+
/// - **Ctrl+Shift+A**: Open the model picker
149149
/// - **Ctrl+K**: Open the command palette
150150
/// - **Ctrl+U**: Kill input from cursor to start of line (Emacs-style)
151151
/// - **Alt+←/Alt+→**: Navigate to previous/next message in transcript
@@ -190,6 +190,7 @@ pub fn default_bindings() -> Vec<ParsedBinding> {
190190
// Line start/end navigation
191191
("home", "goLineStart", KeyContext::Chat),
192192
("cmd+left", "goLineStart", KeyContext::Chat),
193+
("ctrl+a", "goLineStart", KeyContext::Chat),
193194
("end", "goLineEnd", KeyContext::Chat),
194195
("cmd+right", "goLineEnd", KeyContext::Chat),
195196
("ctrl+e", "goLineEnd", KeyContext::Chat),
@@ -240,7 +241,7 @@ pub fn default_bindings() -> Vec<ParsedBinding> {
240241
("pagedown", "scrollDown", KeyContext::Chat),
241242

242243
// App shortcuts
243-
("ctrl+a", "openModelPicker", KeyContext::Chat),
244+
("ctrl+shift+a", "openModelPicker", KeyContext::Chat),
244245
("ctrl+k", "openCommandPalette", KeyContext::Chat),
245246

246247
// ========== CONFIRMATION DIALOGS ==========
@@ -456,7 +457,7 @@ impl UserKeybindings {
456457
})
457458
.collect();
458459
Ok(Self {
459-
schema_version: KEYBINDINGS_SCHEMA_VERSION,
460+
schema_version: 0,
460461
bindings,
461462
})
462463
}
@@ -475,6 +476,22 @@ impl UserKeybindings {
475476
let mut user_customizations: std::collections::HashMap<String, Option<String>> =
476477
std::collections::HashMap::new();
477478
for binding in &self.bindings {
479+
// Migration: remove old bindings that have changed in defaults
480+
// This distinguishes between "user customized" and "old default that changed"
481+
482+
// Old: ctrl+a -> openModelPicker (moved to ctrl+shift+a)
483+
if binding.chord == "ctrl+a" && binding.action.as_deref() == Some("openModelPicker") {
484+
continue;
485+
}
486+
487+
// Old: tab -> togglePreview in Chat context (changed to indent)
488+
if binding.chord == "tab"
489+
&& binding.context.as_deref() == Some("Chat")
490+
&& binding.action.as_deref() == Some("togglePreview")
491+
{
492+
continue;
493+
}
494+
478495
user_customizations
479496
.insert(binding.chord.clone(), binding.action.clone());
480497
}
@@ -713,12 +730,13 @@ mod tests {
713730
}
714731

715732
#[test]
716-
fn test_default_bindings_map_ctrl_a_and_ctrl_k_to_app_shortcuts() {
733+
fn test_default_bindings_map_ctrl_shift_a_and_ctrl_k_to_app_shortcuts() {
717734
let bindings = default_bindings();
718735

719-
let ctrl_a = bindings.iter().find(|b| {
736+
let ctrl_shift_a = bindings.iter().find(|b| {
720737
b.chord.len() == 1
721738
&& b.chord[0].ctrl
739+
&& b.chord[0].shift
722740
&& b.chord[0].key == "a"
723741
&& b.context == KeyContext::Chat
724742
});
@@ -729,7 +747,7 @@ mod tests {
729747
&& b.context == KeyContext::Chat
730748
});
731749

732-
assert_eq!(ctrl_a.and_then(|b| b.action.as_deref()), Some("openModelPicker"));
750+
assert_eq!(ctrl_shift_a.and_then(|b| b.action.as_deref()), Some("openModelPicker"));
733751
assert_eq!(
734752
ctrl_k.and_then(|b| b.action.as_deref()),
735753
Some("openCommandPalette")
@@ -847,4 +865,57 @@ mod tests {
847865
actions.len()
848866
);
849867
}
868+
869+
#[test]
870+
fn test_old_format_keybindings_get_upgraded() {
871+
let old_format_json = r#"{
872+
"bindings": [
873+
{
874+
"context": "Chat",
875+
"bindings": {
876+
"ctrl+shift+a": "openModelPicker",
877+
"ctrl+e": "goLineEnd"
878+
}
879+
}
880+
]
881+
}"#;
882+
883+
let mut kb = UserKeybindings::from_json_str(old_format_json);
884+
assert_eq!(kb.schema_version, 0, "Old format should start at version 0");
885+
886+
kb.smart_merge_with_defaults();
887+
888+
assert_eq!(kb.schema_version, 1, "Should be upgraded to version 1");
889+
assert!(
890+
kb.bindings.iter().any(|b| b.chord == "meta+left"),
891+
"meta+left (cmd+left) should be added from defaults after merge"
892+
);
893+
assert!(
894+
kb.bindings.iter().any(|b| b.chord == "ctrl+shift+a" && b.action.as_deref() == Some("openModelPicker")),
895+
"User customization (ctrl+shift+a -> openModelPicker) should be preserved"
896+
);
897+
}
898+
899+
#[test]
900+
fn test_cmd_left_resolves_to_go_line_start() {
901+
let user = UserKeybindings::default();
902+
let mut resolver = KeybindingResolver::new(&user);
903+
904+
// Create a keystroke for CMD+Left (SUPER modifier + left arrow)
905+
let keystroke = ParsedKeystroke {
906+
key: "left".to_string(),
907+
ctrl: false,
908+
alt: false,
909+
shift: false,
910+
meta: true,
911+
};
912+
913+
let result = resolver.process(keystroke, &KeyContext::Chat);
914+
match result {
915+
KeybindingResult::Action(action) => {
916+
assert_eq!(action, "goLineStart", "CMD+Left should map to goLineStart");
917+
}
918+
other => panic!("Expected Action(\"goLineStart\"), got {:?}", other),
919+
}
920+
}
850921
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ fn render_keybindings_page(frame: &mut Frame, area: Rect) {
346346
Line::from(Span::styled(" Navigation", Style::default().fg(pink).add_modifier(Modifier::BOLD))),
347347
kb("PgUp/PgDn", "scroll transcript"),
348348
kb("Ctrl+K", "command palette"),
349-
kb("Ctrl+A", "model picker"),
349+
kb("Ctrl+Shift+A", "model picker"),
350350
Line::from(""),
351351
Line::from(Span::styled(" Permissions", Style::default().fg(pink).add_modifier(Modifier::BOLD))),
352352
kb("y", "allow tool once"),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ pub fn render_help_overlay(frame: &mut Frame, overlay: &HelpOverlay, area: Rect)
415415
)));
416416
for (key, desc) in &[
417417
("F1 / ?", "Toggle help"),
418-
("Ctrl+A", "Model picker"),
418+
("Ctrl+Shift+A", "Model picker"),
419419
("Ctrl+K", "Command palette"),
420420
("Ctrl+C", "Cancel / quit"),
421421
("Ctrl+D", "Quit (empty input)"),

0 commit comments

Comments
 (0)