Skip to content

Commit 5721a6c

Browse files
Merge pull request #20 from Spacecraft-Software/tui-vim
feat(vault-tui): tui.vim — vim jump motions
2 parents a212915 + efa5032 commit 5721a6c

5 files changed

Lines changed: 227 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **`tui.vim` — vim jump motions in the TUI.** Opt-in (`vault config set tui.vim
14+
true`): on top of the default `hjkl`, the browser gains `gg` (top), `G`
15+
(bottom), and `Ctrl-d`/`Ctrl-u` (half-page; a fixed step — the pure-render
16+
`App` has no viewport height). Because `g` becomes the `gg` prefix in vim
17+
mode, the generator overlay moves from `g` to `Ctrl-g`; non-vim mode is
18+
unchanged (`g` opens the generator). Read by `vault-tui` directly (not relayed
19+
to the agent). New `App` motions (`move_top`/`move_bottom`/`page_up`/
20+
`page_down`, reusing the focus-aware re-masking `move_*` pattern) + a `gg`
21+
prefix state machine. Completes the PRD §7.1 config registry. Tested in
22+
`vault-config` (round-trip / reject / not-an-agent-flag) and `vault-tui` (jump
23+
+ clamp + empty-list no-op + the `gg` arm/take sequence).
24+
1325
- **`ui.reduced_motion` config key (reserved).** Completes the PRD §7.1 config
1426
registry with the accessibility preference to suppress animated TUI elements.
1527
The TUI has no animations yet (it's fully event-driven — no spinner, no

README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,19 @@ Recognised keys: `clipboard.clear_secs` (auto-clear window, `0` disables),
150150
`agent.idle_lock_secs` (idle-lock timeout, `0` disables),
151151
`agent.session_keyring` (resume across restarts; see above),
152152
`sync.interval_secs` (background `/sync` interval while unlocked, `0` disables),
153-
and `ui.reduced_motion` (suppress animated TUI elements — **reserved**: the TUI
154-
has no animations yet, so this records the preference for when a spinner /
155-
lock-countdown lands). When the CLI auto-starts the agent, the agent-side keys
156-
populate its launch flags (changes apply on the next agent spawn);
157-
`ui.reduced_motion` is read by `vault-tui` directly. Wipe the on-disk item cache
158-
(and drop a running agent's keys) with `vault purge`.
153+
`ui.reduced_motion` (suppress animated TUI elements — **reserved**: the TUI has
154+
no animations yet, so this records the preference for when a spinner /
155+
lock-countdown lands), and `tui.vim` (vim motions; see below). When the CLI
156+
auto-starts the agent, the agent-side keys populate its launch flags (changes
157+
apply on the next agent spawn); `ui.reduced_motion` and `tui.vim` are read by
158+
`vault-tui` directly. Wipe the on-disk item cache (and drop a running agent's
159+
keys) with `vault purge`.
160+
161+
With `tui.vim` set, `vault-tui` adds vim jump motions on top of the default
162+
`hjkl` navigation: `gg` to the top, `G` to the bottom, and `Ctrl-d`/`Ctrl-u` for
163+
a half-page. Because `g` becomes the `gg` prefix, the password generator moves
164+
from `g` to `Ctrl-g` while vim mode is on (with it off, `g` opens the generator
165+
as before).
159166

160167
With `sync.interval_secs` set, the agent re-pulls `/sync` on that cadence while
161168
unlocked — keeping the in-memory vault and offline cache fresh without a manual

crates/vault-config/src/lib.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub const KNOWN_KEYS: &[&str] = &[
2929
"agent.session_keyring",
3030
"sync.interval_secs",
3131
"ui.reduced_motion",
32+
"tui.vim",
3233
];
3334

3435
/// Accepted values for `clipboard.backend`.
@@ -48,6 +49,8 @@ pub struct Config {
4849
pub sync: SyncCfg,
4950
/// TUI rendering preferences.
5051
pub ui: UiCfg,
52+
/// TUI keymap preferences.
53+
pub tui: TuiCfg,
5154
/// Registered account profile (written by `vault register`). Skipped from
5255
/// the file until something is set, so an unregistered config carries no
5356
/// empty `[account]` table.
@@ -100,6 +103,16 @@ pub struct UiCfg {
100103
pub reduced_motion: Option<bool>,
101104
}
102105

106+
/// `[tui]` table — TUI-only keymap preferences (read by `vault-tui` directly,
107+
/// never relayed to the agent).
108+
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
109+
#[serde(default, deny_unknown_fields)]
110+
pub struct TuiCfg {
111+
/// Enable vim-style jump motions (`gg`/`G`/`Ctrl-d`/`Ctrl-u`); the generator
112+
/// overlay moves from `g` to `Ctrl-g` while on.
113+
pub vim: Option<bool>,
114+
}
115+
103116
/// `[account]` table — the registered account, written by `vault register`
104117
/// and read by `login`/`unlock` to default `server`/`email`/`device_id`.
105118
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
@@ -162,6 +175,12 @@ impl Config {
162175
self.ui.reduced_motion
163176
}
164177

178+
/// Effective `tui.vim`, if set.
179+
#[must_use]
180+
pub const fn tui_vim(&self) -> Option<bool> {
181+
self.tui.vim
182+
}
183+
165184
/// The registered account profile.
166185
#[must_use]
167186
pub const fn account(&self) -> &AccountCfg {
@@ -192,6 +211,7 @@ impl Config {
192211
"agent.session_keyring" => Ok(self.agent.session_keyring.map(|v| v.to_string())),
193212
"sync.interval_secs" => Ok(self.sync.interval_secs.map(|v| v.to_string())),
194213
"ui.reduced_motion" => Ok(self.ui.reduced_motion.map(|v| v.to_string())),
214+
"tui.vim" => Ok(self.tui.vim.map(|v| v.to_string())),
195215
other => Err(other.to_owned()),
196216
}
197217
}
@@ -228,6 +248,10 @@ impl Config {
228248
self.ui.reduced_motion = Some(parse_bool(key, raw)?);
229249
Ok(())
230250
}
251+
"tui.vim" => {
252+
self.tui.vim = Some(parse_bool(key, raw)?);
253+
Ok(())
254+
}
231255
other => Err(unknown_key(other)),
232256
}
233257
}
@@ -263,6 +287,10 @@ impl Config {
263287
self.ui.reduced_motion = None;
264288
Ok(())
265289
}
290+
"tui.vim" => {
291+
self.tui.vim = None;
292+
Ok(())
293+
}
266294
other => Err(unknown_key(other)),
267295
}
268296
}
@@ -574,6 +602,24 @@ mod tests {
574602
assert_eq!(c.reduced_motion(), None);
575603
}
576604

605+
#[test]
606+
fn tui_vim_round_trips_and_is_tui_only() {
607+
let mut c = Config::default();
608+
assert_eq!(c.tui_vim(), None);
609+
c.set("tui.vim", "true").expect("set true");
610+
assert_eq!(c.tui_vim(), Some(true));
611+
assert!(
612+
!agent_args(&c).contains(&OsString::from("--vim")),
613+
"tui.vim must not become an agent flag"
614+
);
615+
let text = toml::to_string_pretty(&c).expect("serialise");
616+
let back: Config = toml::from_str(&text).expect("parse");
617+
assert_eq!(back.tui_vim(), Some(true));
618+
assert!(c.set("tui.vim", "maybe").is_err());
619+
c.unset("tui.vim").expect("unset");
620+
assert_eq!(c.tui_vim(), None);
621+
}
622+
577623
#[test]
578624
fn sync_interval_round_trips() {
579625
let mut c = Config::default();

crates/vault-tui/src/app.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ const GEN_MIN_LEN: usize = 8;
2929
/// own generator ceiling so saved values round-trip everywhere.
3030
const GEN_MAX_LEN: usize = 128;
3131

32+
/// Rows a vim half-page motion (`Ctrl-d`/`Ctrl-u`) moves. A fixed approximation
33+
/// of vim's "half the window": the pure-render `App` has no viewport height, so
34+
/// a constant step keeps the motion useful without plumbing the pane geometry.
35+
const VIM_PAGE: usize = 10;
36+
3237
/// A single-line editable text buffer with a cursor — the shared core behind
3338
/// the `/` search, `:` command line, and every form field. Edits happen at the
3439
/// cursor; `cursor` is a byte offset kept on a `char` boundary so all the
@@ -704,6 +709,10 @@ impl FormState {
704709

705710
/// Top-level TUI state.
706711
#[derive(Clone, Debug)]
712+
#[expect(
713+
clippy::struct_excessive_bools,
714+
reason = "App is the TUI's flat UI-state aggregate; these are independent toggles (config prefs + transient flags), not a state machine to fold into an enum"
715+
)]
707716
pub struct App {
708717
/// What the screen shows.
709718
pub screen: Screen,
@@ -751,6 +760,11 @@ pub struct App {
751760
/// animations yet, so nothing reads this — it's populated from config so a
752761
/// future spinner / lock-countdown can honor it without re-plumbing.
753762
pub reduced_motion: bool,
763+
/// Vim-style jump motions enabled (`tui.vim`): `gg`/`G`/`Ctrl-d`/`Ctrl-u`,
764+
/// with the generator moved from `g` to `Ctrl-g`.
765+
pub vim: bool,
766+
/// Whether a `g` is pending the second `g` of a `gg` (vim mode only).
767+
pub pending_g: bool,
754768
/// Set when the user asks to quit.
755769
pub should_quit: bool,
756770
}
@@ -780,6 +794,8 @@ impl App {
780794
toast: None,
781795
unlock: None,
782796
reduced_motion: false,
797+
vim: false,
798+
pending_g: false,
783799
should_quit: false,
784800
}
785801
}
@@ -814,6 +830,8 @@ impl App {
814830
toast: None,
815831
unlock: None,
816832
reduced_motion: false,
833+
vim: false,
834+
pending_g: false,
817835
should_quit: false,
818836
}
819837
}
@@ -915,6 +933,62 @@ impl App {
915933
}
916934
}
917935

936+
/// Jump to the first row of the focused pane (vim `gg`).
937+
pub fn move_top(&mut self) {
938+
self.revealed = None;
939+
match self.focus {
940+
Focus::Folders => {
941+
self.folder_sel = 0;
942+
self.item_sel = 0;
943+
}
944+
Focus::Items => self.item_sel = 0,
945+
}
946+
}
947+
948+
/// Jump to the last row of the focused pane (vim `G`); no-op when empty.
949+
pub fn move_bottom(&mut self) {
950+
self.revealed = None;
951+
match self.focus {
952+
Focus::Folders => {
953+
self.folder_sel = self.folders.len().saturating_sub(1);
954+
self.item_sel = 0;
955+
}
956+
Focus::Items => self.item_sel = self.filtered().len().saturating_sub(1),
957+
}
958+
}
959+
960+
/// Move the focused selection down by a half-page (vim `Ctrl-d`), clamped.
961+
pub fn page_down(&mut self) {
962+
for _ in 0..VIM_PAGE {
963+
self.move_down();
964+
}
965+
}
966+
967+
/// Move the focused selection up by a half-page (vim `Ctrl-u`), clamped.
968+
pub fn page_up(&mut self) {
969+
for _ in 0..VIM_PAGE {
970+
self.move_up();
971+
}
972+
}
973+
974+
/// Arm the `gg` prefix: the next `g` jumps to the top.
975+
pub const fn arm_pending_g(&mut self) {
976+
self.pending_g = true;
977+
}
978+
979+
/// Consume the `gg` prefix, returning whether a `g` was pending (and
980+
/// clearing it either way).
981+
pub const fn take_pending_g(&mut self) -> bool {
982+
let was = self.pending_g;
983+
self.pending_g = false;
984+
was
985+
}
986+
987+
/// Cancel any pending `gg` prefix (a non-`g` key was pressed).
988+
pub const fn clear_pending_g(&mut self) {
989+
self.pending_g = false;
990+
}
991+
918992
/// Toggle focus between the folder pane and the item list.
919993
pub fn focus_next(&mut self) {
920994
self.revealed = None;
@@ -1511,6 +1585,44 @@ mod tests {
15111585
assert_eq!(name, "github");
15121586
}
15131587

1588+
#[test]
1589+
fn vim_motions_jump_and_clamp() {
1590+
let entries: Vec<ListEntry> = (0..25).map(|i| entry(&format!("e{i}"), None)).collect();
1591+
let mut app = App::browsing(status(), entries);
1592+
assert_eq!(app.item_sel, 0);
1593+
app.move_bottom();
1594+
assert_eq!(app.item_sel, 24, "G lands on the last row");
1595+
app.move_top();
1596+
assert_eq!(app.item_sel, 0, "gg lands on the first row");
1597+
app.page_down();
1598+
assert_eq!(app.item_sel, VIM_PAGE, "Ctrl-d moves a half-page");
1599+
app.page_up();
1600+
assert_eq!(app.item_sel, 0, "Ctrl-u clamps at the top");
1601+
// page motions clamp at the bottom.
1602+
app.move_bottom();
1603+
app.page_down();
1604+
assert_eq!(app.item_sel, 24, "Ctrl-d clamps at the last row");
1605+
}
1606+
1607+
#[test]
1608+
fn move_bottom_on_empty_list_is_a_noop() {
1609+
let mut app = App::browsing(status(), vec![]);
1610+
app.move_bottom();
1611+
assert_eq!(app.item_sel, 0);
1612+
}
1613+
1614+
#[test]
1615+
fn pending_g_arms_once_then_fires() {
1616+
let mut app = App::browsing(status(), vec![entry("a", None)]);
1617+
assert!(!app.take_pending_g(), "nothing pending initially");
1618+
app.arm_pending_g();
1619+
assert!(app.take_pending_g(), "first take after arm fires gg");
1620+
assert!(!app.take_pending_g(), "and is cleared afterwards");
1621+
app.arm_pending_g();
1622+
app.clear_pending_g();
1623+
assert!(!app.take_pending_g(), "a non-g key cancels the prefix");
1624+
}
1625+
15141626
#[test]
15151627
fn search_edit_via_unified_path_reanchors_selection() {
15161628
let entries = vec![entry("aa", None), entry("ab", None), entry("zz", None)];

crates/vault-tui/src/main.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,12 @@ async fn load_app(socket: &Path) -> App {
194194
Ok(Response::Error(err)) => App::message("Error", err.to_string(), None),
195195
Ok(other) => App::message("Error", format!("unexpected response: {other:?}"), None),
196196
};
197-
// Reserved accessibility preference (`ui.reduced_motion`): record it on the
198-
// App so a future animated element can honor it. No visible effect yet.
199-
app.reduced_motion = vault_config::load()
200-
.ok()
201-
.and_then(|c| c.reduced_motion())
202-
.unwrap_or(false);
197+
// Apply TUI config preferences: `tui.vim` (vim motions) and the reserved
198+
// `ui.reduced_motion` flag. One load, both flags.
199+
if let Ok(cfg) = vault_config::load() {
200+
app.vim = cfg.tui_vim().unwrap_or(false);
201+
app.reduced_motion = cfg.reduced_motion().unwrap_or(false);
202+
}
203203
app
204204
}
205205

@@ -302,6 +302,44 @@ async fn submit_unlock(state: &mut App, socket: &Path) {
302302

303303
/// Normal-mode keys — navigation, reveal/copy, and mode entry.
304304
async fn handle_normal_key(state: &mut App, key: KeyEvent, socket: &Path) {
305+
// Vim mode (`tui.vim`) adds jump motions and remaps the generator to Ctrl-g
306+
// so `g` can be the `gg` prefix. Intercept before the normal match (which
307+
// matches `g`/`u`/`d` without checking modifiers).
308+
if state.vim {
309+
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
310+
match key.code {
311+
KeyCode::Char('g') if !ctrl => {
312+
if state.take_pending_g() {
313+
state.move_top();
314+
} else {
315+
state.arm_pending_g();
316+
}
317+
return;
318+
}
319+
KeyCode::Char('g') if ctrl => {
320+
state.clear_pending_g();
321+
state.open_generator();
322+
return;
323+
}
324+
KeyCode::Char('G') => {
325+
state.clear_pending_g();
326+
state.move_bottom();
327+
return;
328+
}
329+
KeyCode::Char('d') if ctrl => {
330+
state.clear_pending_g();
331+
state.page_down();
332+
return;
333+
}
334+
KeyCode::Char('u') if ctrl => {
335+
state.clear_pending_g();
336+
state.page_up();
337+
return;
338+
}
339+
// Any other key cancels a pending `g` and falls through.
340+
_ => state.clear_pending_g(),
341+
}
342+
}
305343
match key.code {
306344
KeyCode::Char('q') => state.quit(),
307345
// Esc peels back one layer: an active search filter first, then quit.

0 commit comments

Comments
 (0)