From 638b4c96642696c445ba700883df0593dcd7e3e3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 21:36:18 +0000 Subject: [PATCH] feat(app): vi-style j/k/g/G navigation in the load picker (#24) Add j (down), k (up), g (first) and G (last) to the load picker's list sub-mode, alongside the existing arrow keys. Typeable keys keep the picker drivable by autocast in the website's documentation casts, which cannot emit arrow keys. Footer hint left unchanged. --- src/app.rs | 18 ++++- tests/it/iteration4b_lifecycle_commands.rs | 85 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 69d3c2e..6747172 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2488,20 +2488,34 @@ impl App { self.note_system(crate::t!("modal.load_cancelled")); Vec::new() } - KeyCode::Up => { + // `k` mirrors Up; vi-style keys keep the picker drivable by + // autocast, which can only emit typeable characters (#24). + KeyCode::Up | KeyCode::Char('k') => { if state.selected > 0 { state.selected -= 1; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } - KeyCode::Down => { + // `j` mirrors Down (see the Up arm above). + KeyCode::Down | KeyCode::Char('j') => { if state.selected + 1 < state.entries.len() { state.selected += 1; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } + // `g` jumps to the first entry, `G` to the last (vi convention). + KeyCode::Char('g') => { + state.selected = 0; + self.modal = Some(Modal::LoadPicker(state)); + Vec::new() + } + KeyCode::Char('G') => { + state.selected = state.entries.len().saturating_sub(1); + self.modal = Some(Modal::LoadPicker(state)); + Vec::new() + } KeyCode::Enter => { if let Some(entry) = state.entries.get(state.selected).cloned() { self.modal = None; diff --git a/tests/it/iteration4b_lifecycle_commands.rs b/tests/it/iteration4b_lifecycle_commands.rs index a323799..bcd15dd 100644 --- a/tests/it/iteration4b_lifecycle_commands.rs +++ b/tests/it/iteration4b_lifecycle_commands.rs @@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() { assert_eq!(source, "load"); } +/// Build a load picker with three entries for the vi-navigation tests. +fn three_entry_picker() -> App { + let mut app = App::new(); + app.update(AppEvent::LoadPickerReady { + entries: vec![ + LoadPickerEntry { + display_name: "First".to_string(), + modified: "2026-05-07 14:30".to_string(), + path: std::path::PathBuf::from("/tmp/first"), + is_temp: true, + }, + LoadPickerEntry { + display_name: "Second".to_string(), + modified: "2026-05-05 10:00".to_string(), + path: std::path::PathBuf::from("/tmp/second"), + is_temp: false, + }, + LoadPickerEntry { + display_name: "Third".to_string(), + modified: "2026-05-01 09:15".to_string(), + path: std::path::PathBuf::from("/tmp/third"), + is_temp: false, + }, + ], + }); + app +} + +fn picker_selected(app: &App) -> usize { + let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else { + panic!("expected LoadPicker modal"); + }; + picker.selected +} + +#[test] +fn load_picker_jk_navigates_like_arrows() { + // vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive + // the load picker in documentation casts (#24). + let mut app = three_entry_picker(); + assert_eq!(picker_selected(&app), 0); + + // j moves the selection down. + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 1); + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 2); + + // j at the last entry does not wrap past the end. + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 2); + + // k moves the selection up. + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 1); + + // k at the first entry does not wrap past the start. + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 0); + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 0); +} + +#[test] +fn load_picker_g_jumps_to_first_and_last() { + // g → first entry, G → last entry (vi convention). + let mut app = three_entry_picker(); + + // G jumps to the last entry from the top. + app.update(key(KeyCode::Char('G'))); + assert_eq!(picker_selected(&app), 2); + + // G again is idempotent at the end. + app.update(key(KeyCode::Char('G'))); + assert_eq!(picker_selected(&app), 2); + + // g jumps back to the first entry. + app.update(key(KeyCode::Char('g'))); + assert_eq!(picker_selected(&app), 0); + + // g again is idempotent at the start. + app.update(key(KeyCode::Char('g'))); + assert_eq!(picker_selected(&app), 0); +} + #[test] fn load_picker_b_enters_path_entry_submode() { let mut app = App::new();