diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 957fd86..9e1a710 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,5 @@ +{"_type":"issue","id":"hm-68a","title":"introduce an element that is to be used anywhere where i am typing a reference to a secret. it should autocomplete as i type, showing me the closest match based on levenshtein distance. the same code should be used to also add a 'did you mean X' when himitsu search returns no results","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-07T06:22:32Z","created_by":"Cooper Maruyama","updated_at":"2026-05-07T06:22:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-7ob","title":"Update command palette layout","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-07T06:06:18Z","created_by":"Cooper Maruyama","updated_at":"2026-05-07T06:10:27Z","started_at":"2026-05-07T06:06:18Z","closed_at":"2026-05-07T06:10:27Z","close_reason":"Updated command palette layout and copy","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hm-isi","title":"add support for TUI hints - a small bit of text that can render in subtle text on the bottom left corner, sort of like a floating element","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:21:58Z","created_by":"Cooper Maruyama","updated_at":"2026-05-06T23:21:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hm-3w5","title":"all form fields should have placeholders containing an example","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:20:58Z","created_by":"Cooper Maruyama","updated_at":"2026-05-06T23:20:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hm-3rr","title":"add a submit button to the new secret tui, and add a confirm dialog when clicking esc that lets you save or discard","status":"open","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:20:25Z","created_by":"Cooper Maruyama","updated_at":"2026-05-06T23:20:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index fa9dd74..96e94ad 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ Search is the **root view** -- the app opens straight into a fuzzy filter over e | `ctrl-n` | new secret | | `ctrl-s` | switch store | | `ctrl-y` | copy selected value | +| `Y` (`shift-y`) | copy `himitsu read ` for the selected row | | `shift-e` | browse env presets | | `?` | help | | `esc` / `ctrl-c` | quit | @@ -191,13 +192,20 @@ Search is the **root view** -- the app opens straight into a fuzzy filter over e | Key | Action | |-----|--------| | `r` | reveal / hide value | -| `y` | copy to clipboard | +| `y` | copy decrypted value to clipboard | +| `Y` (`shift-y`) | copy `himitsu read ` (the *command*, not the value) | | `e` | edit in `$EDITOR` | | `R` | rekey for current recipients | | `d` | delete (confirms with `y`) | | `?` | help | | `esc` | back | +`Y` lets you share *how to fetch* a secret in a PR comment, chat message, +or runbook without putting the plaintext on your clipboard. The clipboard +gets `himitsu read prod/API_KEY` (or `himitsu -r org/repo read …` when the +row lives in a different store from the active one), ready to paste into +a terminal that has the right age key. + ![secret viewer](demo/tui-us-012.gif) ### New-secret form @@ -502,10 +510,12 @@ tui: # Per-action keybindings. Each action takes a list, so multiple keys can # trigger the same action. Unspecified actions fall back to the - # hardcoded defaults documented in [TUI](#tui). + # hardcoded defaults documented in [TUI](#tui). Leader-key chords are + # whitespace-separated (see "Leader-key chords" below). keys: - new_secret: ["F2", "ctrl+n"] - quit: ["esc", "ctrl+q"] + new_secret: ["F2", "ctrl+n"] + save_secret: ["ctrl+x s"] + quit: ["esc", "ctrl+q"] ``` Binding strings are `++`, lowercased, modifiers first -- @@ -514,11 +524,39 @@ characters imply `shift` (`"Y"` == `"shift+y"`). Bare letters match case-insensitively, so `"y"` matches both `y` and `Y`. Malformed bindings surface as a clear config error at startup. +#### Leader-key chords + +Multi-step chord bindings are written as whitespace-separated steps: + +```yaml +tui: + keys: + # Press Ctrl+X, then s. Useful when terminal/tmux ate Ctrl+S (XOFF). + save_secret: ["ctrl+x s"] + # Mix and match — multiple bindings per action; chord and single-step + # entries can coexist. + new_secret: ["F2", "ctrl+x ctrl+n"] +``` + +When you press the first step of a chord, the dashboard shows a `ctrl+x …` +breadcrumb at the bottom of the screen and waits for the continuation. +A non-continuation key aborts the chord (no spurious action fires; the +breadcrumb flips to `chord aborted: …`). Single-step bindings still flow +through each view's normal key handling, so you can keep typing into the +search box without your letters getting eaten. + +There is no chord timeout — the dispatcher resolves on the next key, +not on a wall clock. If you bind both `ctrl+x` (single-step) and +`ctrl+x s` (chord) to different actions, the chord wins: pressing +`ctrl+x` enters the pending state instead of firing the single-step +binding immediately. + The full action list (with defaults) is in [`rust/src/tui/keymap.rs`](rust/src/tui/keymap.rs): `quit`, `help`, `command_palette`, `new_secret`, `switch_store`, -`copy_selected`, `envs`, `reveal`, `copy_value`, `rekey`, `edit`, `delete`, -`back`, `save_secret`, `next_field`, `prev_field`, `cancel`. +`copy_selected`, `copy_ref_selected`, `envs`, `reveal`, `copy_value`, +`copy_ref`, `rekey`, `edit`, `delete`, `back`, `save_secret`, +`next_field`, `prev_field`, `cancel`. ## Sync & Store Health diff --git a/rust/src/cli/search.rs b/rust/src/cli/search.rs index fd4d8ae..145ea7a 100644 --- a/rust/src/cli/search.rs +++ b/rust/src/cli/search.rs @@ -277,7 +277,7 @@ fn collect_stores(ctx: &Context) -> Result> { /// /// If the path is under `stores_dir`, returns the `org/repo` slug. /// Otherwise falls back to the full path string. -fn store_label(store: &std::path::Path, ctx: &Context) -> String { +pub(crate) fn store_label(store: &std::path::Path, ctx: &Context) -> String { if let Ok(rel) = store.strip_prefix(ctx.stores_dir()) { let s = rel.to_string_lossy().replace('\\', "/"); if !s.is_empty() { diff --git a/rust/src/tui/app.rs b/rust/src/tui/app.rs index 86f22d7..879c8f7 100644 --- a/rust/src/tui/app.rs +++ b/rust/src/tui/app.rs @@ -14,7 +14,7 @@ use ratatui::widgets::{Block, Clear}; use ratatui::Frame; use crate::cli::Context; -use crate::tui::keymap::{Bindings, KeyMap}; +use crate::tui::keymap::{Dispatch, KeyAction, KeyMap}; use crate::tui::theme; pub use crate::tui::toast::{Toast, ToastKind}; use crate::tui::views::envs::{EnvsAction, EnvsView}; @@ -56,6 +56,16 @@ pub struct App { /// until [`Toast::is_expired`] returns true, at which point `draw` /// clears it. Non-modal: key events still flow to the current view. toast: Option, + /// Buffer of chord steps already pressed but not yet resolved. Set by + /// [`KeyMap::dispatch`] returning [`Dispatch::Pending`]; cleared on the + /// next match, abort, or non-chord keypress. + pending_chord: Vec, + /// `true` while the active toast is the chord-progress breadcrumb. + /// Tracked explicitly so [`Self::dismiss_chord_breadcrumb`] doesn't + /// have to inspect the toast's text — an unrelated info toast that + /// happens to land during a pending chord must not be cleared by + /// breadcrumb dismissal. + chord_breadcrumb_active: bool, } impl App { @@ -68,21 +78,25 @@ impl App { keymap, help: None, toast: None, + pending_chord: Vec::new(), + chord_breadcrumb_active: false, } } /// Publish a transient status-line message. Replaces any previous /// toast (rapid actions don't stack) and resets the 3-second TTL. + /// Any active chord breadcrumb is also cleared — a normal toast + /// supersedes the chord prompt. pub fn push_toast(&mut self, msg: impl Into, kind: ToastKind) { self.toast = Some(Toast::new(msg, kind)); + self.chord_breadcrumb_active = false; } pub fn on_key(&mut self, key: KeyEvent) -> Option { // ── Help overlay intercept (US-012) ──────────────────────────── - // If the overlay is open, route every key to it. Otherwise, the - // configured `help` chord opens the overlay populated from the - // current view. Done before view dispatch so inner views never - // have to swallow `?`. + // If the overlay is open, route every key to it. Done before + // chord dispatch so the help overlay can never accidentally + // consume a leader chord step. if let Some(help) = self.help.as_mut() { match help.on_key(key) { HelpAction::None => {} @@ -90,157 +104,283 @@ impl App { } return None; } - if self.keymap.help.matches(&key) { - self.help = Some(self.help_for_current_view()); - return None; - } - match &mut self.view { - View::Search(search) => match search.on_key(key, &self.keymap) { - SearchAction::None => {} - SearchAction::Quit => self.should_quit = true, - SearchAction::OpenViewer(r) => { - self.view = View::SecretViewer(SecretViewerView::new( - &self.ctx, - r.store, - r.store_path, - r.path, - )); - } - SearchAction::NewSecret => { - self.view = View::NewSecret(NewSecretView::new(&self.ctx)); - } - SearchAction::AddRemote => { - self.view = View::RemoteAdd(RemoteAddView::new(&self.ctx)); - } - SearchAction::OpenEnvs => { - self.view = View::Envs(EnvsView::new(&self.ctx)); - } - SearchAction::SwitchStore(path) => { - let label = path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); - self.ctx.store = path; - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast(format!("switched to {label}"), ToastKind::Info); - } - SearchAction::ShowHelp => { - self.help = Some(self.help_for_current_view()); - } - SearchAction::Copied(path) => { - self.push_toast(format!("copied {path}"), ToastKind::Success); - } - SearchAction::CopyFailed(msg) => { - self.push_toast(msg, ToastKind::Error); - } - SearchAction::Synced(msg) => { - self.push_toast(msg, ToastKind::Success); - } - SearchAction::Rekeyed(msg) => { - self.push_toast(msg, ToastKind::Success); - } - SearchAction::Joined(msg) => { - self.push_toast(msg, ToastKind::Success); - } - SearchAction::CommandFailed(msg) => { - self.push_toast(msg, ToastKind::Error); - } - SearchAction::CommandHint(msg) => { - self.push_toast(msg, ToastKind::Info); - } - }, - View::SecretViewer(viewer) => match viewer.on_key(key, &self.keymap) { - SecretViewerAction::None => {} - SecretViewerAction::Quit => self.should_quit = true, - SecretViewerAction::Back => { - self.view = View::Search(SearchView::new(&self.ctx)); - } - SecretViewerAction::EditValue(plain) => { - return Some(AppIntent::EditSecretValue(plain)); - } - SecretViewerAction::Copied => { - self.push_toast("copied to clipboard", ToastKind::Success); - } - SecretViewerAction::CopyFailed(msg) => { - self.push_toast(msg, ToastKind::Error); - } - SecretViewerAction::Deleted => { - // Rebuild search fresh so the (now missing) secret - // drops out of listings. - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast("deleted", ToastKind::Success); - } - }, - View::Envs(envs) => match envs.on_key(key, &self.keymap) { - EnvsAction::None => {} - EnvsAction::Quit => self.should_quit = true, - EnvsAction::Back => { - self.view = View::Search(SearchView::new(&self.ctx)); - } - EnvsAction::Deleted { label, scope } => { - let scope_str = match scope { - crate::config::env_cache::Scope::Project => "project", - crate::config::env_cache::Scope::Global => "global", - }; - self.push_toast( - format!("deleted `{label}` ({scope_str})"), - ToastKind::Success, - ); - } - EnvsAction::DeleteFailed(msg) => { - self.push_toast(msg, ToastKind::Error); - } - EnvsAction::Created { label, scope } => { - let scope_str = match scope { - crate::config::env_cache::Scope::Project => "project", - crate::config::env_cache::Scope::Global => "global", - }; + // ── Leader-key chord dispatcher ─────────────────────────────── + // Drives the multi-step chord state machine. If the key is part + // of an in-flight chord (or starts one), it's swallowed here. + // Only `Unmatched` falls through to the legacy per-key flow. + match self.keymap.dispatch(&self.pending_chord, &key) { + Dispatch::Match(action) => { + self.pending_chord.clear(); + self.dismiss_chord_breadcrumb(); + return self.run_keymap_action(action); + } + Dispatch::Pending => { + self.pending_chord.push(key); + self.show_chord_breadcrumb(); + return None; + } + Dispatch::Unmatched => { + if !self.pending_chord.is_empty() { + // The pending chord aborted because this key isn't a + // continuation. Surface the abort so the user knows + // their leader sequence didn't fire anything. + let summary = format_pending(&self.pending_chord); + self.pending_chord.clear(); self.push_toast( - format!("created `{label}` ({scope_str})"), - ToastKind::Success, + format!("chord aborted: {summary}"), + ToastKind::Info, ); + return None; } - EnvsAction::CreateFailed(msg) => { - self.push_toast(msg, ToastKind::Error); - } - }, - View::NewSecret(form) => match form.on_key(key, &self.keymap) { - NewSecretAction::None => {} - NewSecretAction::Quit => self.should_quit = true, - NewSecretAction::Cancel => { - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast("create cancelled", ToastKind::Info); - } - NewSecretAction::Created(path) => { - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast(format!("created {path}"), ToastKind::Success); - } - NewSecretAction::Failed(err) => { - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast(format!("create failed: {err}"), ToastKind::Error); - } - }, - View::RemoteAdd(form) => match form.on_key(key, &self.keymap) { - RemoteAddAction::None => {} - RemoteAddAction::Quit => self.should_quit = true, - RemoteAddAction::Cancel => { - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast("add remote cancelled", ToastKind::Info); + } + } + + // ── Single-key fallthrough (no active chord) ────────────────── + match &mut self.view { + View::Search(search) => { + let action = search.on_key(key, &self.keymap); + self.handle_search_action(action) + } + View::SecretViewer(viewer) => { + let action = viewer.on_key(key, &self.keymap); + self.handle_secret_viewer_action(action) + } + View::Envs(envs) => { + let action = envs.on_key(key, &self.keymap); + self.handle_envs_action(action) + } + View::NewSecret(form) => { + let action = form.on_key(key, &self.keymap); + self.handle_new_secret_action(action) + } + View::RemoteAdd(form) => { + let action = form.on_key(key, &self.keymap); + self.handle_remote_add_action(action) + } + } + } + + /// Deliver a completed chord (or any [`KeyAction`] resolved by name) + /// to whichever target owns it. App-level actions like `Help` and + /// `Quit` are handled directly; everything else is forwarded to the + /// active view's `dispatch_action`. + fn run_keymap_action(&mut self, action: KeyAction) -> Option { + match action { + KeyAction::Quit => { + self.should_quit = true; + return None; + } + KeyAction::Help => { + self.help = Some(self.help_for_current_view()); + return None; + } + _ => {} + } + + match &mut self.view { + View::Search(search) => { + if let Some(action) = search.dispatch_action(action) { + return self.handle_search_action(action); } - RemoteAddAction::Created(slug) => { - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast(format!("added remote {slug}"), ToastKind::Success); + } + View::SecretViewer(viewer) => { + if let Some(action) = viewer.dispatch_action(action) { + return self.handle_secret_viewer_action(action); } - RemoteAddAction::Failed(err) => { - self.view = View::Search(SearchView::new(&self.ctx)); - self.push_toast(format!("add remote failed: {err}"), ToastKind::Error); + } + View::NewSecret(form) => { + if let Some(action) = form.dispatch_action(action) { + return self.handle_new_secret_action(action); } - }, + } + // Envs and RemoteAdd don't yet expose an action dispatcher; + // their keymap-driven behaviour stays inside their `on_key` + // for now. Falling through is fine — chord completion in + // those views just no-ops, since none of their bindings are + // multi-step by default. + View::Envs(_) | View::RemoteAdd(_) => {} } None } + fn handle_search_action(&mut self, action: SearchAction) -> Option { + match action { + SearchAction::None => {} + SearchAction::Quit => self.should_quit = true, + SearchAction::OpenViewer(r) => { + self.view = View::SecretViewer(SecretViewerView::new( + &self.ctx, + r.store, + r.store_path, + r.path, + )); + } + SearchAction::NewSecret => { + self.view = View::NewSecret(NewSecretView::new(&self.ctx)); + } + SearchAction::AddRemote => { + self.view = View::RemoteAdd(RemoteAddView::new(&self.ctx)); + } + SearchAction::OpenEnvs => { + self.view = View::Envs(EnvsView::new(&self.ctx)); + } + SearchAction::SwitchStore(path) => { + let label = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + self.ctx.store = path; + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast(format!("switched to {label}"), ToastKind::Info); + } + SearchAction::ShowHelp => { + self.help = Some(self.help_for_current_view()); + } + SearchAction::Copied(path) => { + self.push_toast(format!("copied {path}"), ToastKind::Success); + } + SearchAction::CopyFailed(msg) => { + self.push_toast(msg, ToastKind::Error); + } + SearchAction::Synced(msg) => { + self.push_toast(msg, ToastKind::Success); + } + SearchAction::Rekeyed(msg) => { + self.push_toast(msg, ToastKind::Success); + } + SearchAction::Joined(msg) => { + self.push_toast(msg, ToastKind::Success); + } + SearchAction::CommandFailed(msg) => { + self.push_toast(msg, ToastKind::Error); + } + SearchAction::CommandHint(msg) => { + self.push_toast(msg, ToastKind::Info); + } + } + None + } + + fn handle_secret_viewer_action(&mut self, action: SecretViewerAction) -> Option { + match action { + SecretViewerAction::None => {} + SecretViewerAction::Quit => self.should_quit = true, + SecretViewerAction::Back => { + self.view = View::Search(SearchView::new(&self.ctx)); + } + SecretViewerAction::EditValue(plain) => { + return Some(AppIntent::EditSecretValue(plain)); + } + SecretViewerAction::Copied => { + self.push_toast("copied to clipboard", ToastKind::Success); + } + SecretViewerAction::CopyFailed(msg) => { + self.push_toast(msg, ToastKind::Error); + } + SecretViewerAction::Deleted => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast("deleted", ToastKind::Success); + } + } + None + } + + fn handle_envs_action(&mut self, action: EnvsAction) -> Option { + match action { + EnvsAction::None => {} + EnvsAction::Quit => self.should_quit = true, + EnvsAction::Back => { + self.view = View::Search(SearchView::new(&self.ctx)); + } + EnvsAction::Deleted { label, scope } => { + let scope_str = match scope { + crate::config::env_cache::Scope::Project => "project", + crate::config::env_cache::Scope::Global => "global", + }; + self.push_toast( + format!("deleted `{label}` ({scope_str})"), + ToastKind::Success, + ); + } + EnvsAction::DeleteFailed(msg) => { + self.push_toast(msg, ToastKind::Error); + } + EnvsAction::Created { label, scope } => { + let scope_str = match scope { + crate::config::env_cache::Scope::Project => "project", + crate::config::env_cache::Scope::Global => "global", + }; + self.push_toast( + format!("created `{label}` ({scope_str})"), + ToastKind::Success, + ); + } + EnvsAction::CreateFailed(msg) => { + self.push_toast(msg, ToastKind::Error); + } + } + None + } + + fn handle_new_secret_action(&mut self, action: NewSecretAction) -> Option { + match action { + NewSecretAction::None => {} + NewSecretAction::Quit => self.should_quit = true, + NewSecretAction::Cancel => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast("create cancelled", ToastKind::Info); + } + NewSecretAction::Created(path) => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast(format!("created {path}"), ToastKind::Success); + } + NewSecretAction::Failed(err) => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast(format!("create failed: {err}"), ToastKind::Error); + } + } + None + } + + fn handle_remote_add_action(&mut self, action: RemoteAddAction) -> Option { + match action { + RemoteAddAction::None => {} + RemoteAddAction::Quit => self.should_quit = true, + RemoteAddAction::Cancel => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast("add remote cancelled", ToastKind::Info); + } + RemoteAddAction::Created(slug) => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast(format!("added remote {slug}"), ToastKind::Success); + } + RemoteAddAction::Failed(err) => { + self.view = View::Search(SearchView::new(&self.ctx)); + self.push_toast(format!("add remote failed: {err}"), ToastKind::Error); + } + } + None + } + + fn show_chord_breadcrumb(&mut self) { + let summary = format_pending(&self.pending_chord); + // Set the toast directly (don't go through `push_toast`, which + // would clobber the breadcrumb flag we're about to set). + self.toast = Some(Toast::new(format!("{summary} …"), ToastKind::Info)); + self.chord_breadcrumb_active = true; + } + + /// Drop the chord-prompt toast iff it's still the active toast. An + /// unrelated toast that landed during the pending chord (e.g. an + /// auto-pull warning) is left alone. + fn dismiss_chord_breadcrumb(&mut self) { + if self.chord_breadcrumb_active { + self.toast = None; + self.chord_breadcrumb_active = false; + } + } + /// Path to the currently active store. Exposed for integration tests /// that drive the App through real key events and need to assert the /// router updated `ctx.store` after a `SwitchStore` action. @@ -266,6 +406,12 @@ impl App { } } + /// Snapshot of the pending-chord buffer for integration tests. + #[cfg(test)] + pub fn pending_chord_len(&self) -> usize { + self.pending_chord.len() + } + /// Name of the currently active view, for integration-test assertions. /// Returns one of `"search"`, `"secret_viewer"`, `"new_secret"`, `"envs"`. #[cfg(test)] @@ -351,6 +497,16 @@ impl App { } } +/// Pretty-print a pending chord buffer for the breadcrumb toast — defers +/// to [`KeyChord::from_events`] + its `Display` impl so the rendering is +/// always in lock-step with the parser users edit in their config. +fn format_pending(events: &[KeyEvent]) -> String { + use crate::tui::keymap::KeyChord; + KeyChord::from_events(events) + .map(|c| c.to_string()) + .unwrap_or_default() +} + fn clone_ctx(ctx: &Context) -> Context { Context { data_dir: ctx.data_dir.clone(), @@ -359,3 +515,22 @@ fn clone_ctx(ctx: &Context) -> Context { recipients_path: ctx.recipients_path.clone(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + + fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, mods) + } + + #[test] + fn format_pending_renders_canonical_keys() { + let events = vec![ + key(KeyCode::Char('x'), KeyModifiers::CONTROL), + key(KeyCode::Char('s'), KeyModifiers::NONE), + ]; + assert_eq!(format_pending(&events), "ctrl+x s"); + } +} diff --git a/rust/src/tui/harness.rs b/rust/src/tui/harness.rs index c3acd99..778233b 100644 --- a/rust/src/tui/harness.rs +++ b/rust/src/tui/harness.rs @@ -539,6 +539,89 @@ new_secret: ["F2"] ); } + /// Multi-step (leader-key) chords must drive their bound action through + /// the App's chord dispatcher: press Ctrl+X, see a breadcrumb toast and + /// pending state, then press `s` and watch the new-secret form save + /// even though `Ctrl+S` is unbound. + #[test] + fn leader_key_chord_fires_save_after_completion() { + let fx = Fixture::new(); + let yaml = r#" +save_secret: ["ctrl+x s"] +"#; + let keymap: KeyMap = serde_yaml::from_str(yaml).expect("parse keymap"); + let mut h = TuiHarness::with_keymap(&fx.ctx, 120, 30, keymap); + + // Open the new-secret form, type a path + value. + h.press_ctrl('n'); + assert_eq!(h.app.current_view(), "new_secret"); + h.type_str("prod/CHORD_KEY"); + h.press(KeyCode::Tab); + h.type_str("chord-fired-value"); + + // Ctrl+S must NOT save — it's no longer bound. + h.press_ctrl('s'); + assert_eq!( + h.app.current_view(), + "new_secret", + "ctrl+s should be inert under the chord-only save binding:\n{}", + h.rendered() + ); + + // Step 1 of the leader chord: Ctrl+X enters Pending state. + h.press_ctrl('x'); + assert_eq!( + h.app.pending_chord_len(), + 1, + "Ctrl+X should enter pending chord state" + ); + assert_eq!(h.app.current_view(), "new_secret"); + + // Step 2 completes the chord and submits the form. + h.press(KeyCode::Char('s')); + assert_eq!( + h.app.pending_chord_len(), + 0, + "chord buffer should clear after match" + ); + assert_eq!( + h.app.current_view(), + "search", + "chord should have triggered save (form returned to search):\n{}", + h.rendered() + ); + assert!( + h.contains("CHORD_KEY"), + "saved secret should appear in search:\n{}", + h.rendered() + ); + } + + /// A pending chord that doesn't continue must abort cleanly: pending + /// buffer clears, no spurious action fires. + #[test] + fn leader_key_chord_aborts_on_non_continuation() { + let fx = Fixture::new(); + let yaml = r#" +save_secret: ["ctrl+x s"] +"#; + let keymap: KeyMap = serde_yaml::from_str(yaml).expect("parse keymap"); + let mut h = TuiHarness::with_keymap(&fx.ctx, 120, 30, keymap); + + h.press_ctrl('n'); + assert_eq!(h.app.current_view(), "new_secret"); + + // Enter pending state with Ctrl+X. + h.press_ctrl('x'); + assert_eq!(h.app.pending_chord_len(), 1); + + // 'q' isn't a continuation of `ctrl+x` — chord aborts and the + // key is consumed by the abort path (no view-side typing). + h.press(KeyCode::Char('q')); + assert_eq!(h.app.pending_chord_len(), 0); + assert_eq!(h.app.current_view(), "new_secret"); + } + /// A malformed binding string must surface as a parse error at config /// load time, not silently ignore the user's intent. #[test] diff --git a/rust/src/tui/keymap.rs b/rust/src/tui/keymap.rs index 6265823..ca95412 100644 --- a/rust/src/tui/keymap.rs +++ b/rust/src/tui/keymap.rs @@ -2,8 +2,8 @@ //! //! The [`KeyMap`] struct holds one list of bindings per **action**, not per //! key. Views consult the map through small `matches` helpers on -//! [`KeyBinding`] so a single action can be triggered by any number of -//! equivalent key combinations. +//! [`KeyBinding`] / [`KeyChord`] so a single action can be triggered by any +//! number of equivalent key combinations. //! //! [`KeyMap::default`] reproduces the hardcoded bindings that shipped before //! this config existed, so users who never touch their config file see no @@ -14,9 +14,19 @@ //! tui: //! keys: //! new_secret: ["F2", "ctrl+n"] -//! quit: ["esc", "ctrl+q"] +//! # Leader-key chord: press Ctrl+X, then s. Avoids terminal Ctrl+S +//! # collisions (XOFF). +//! save_secret: ["ctrl+x s"] +//! quit: ["esc", "ctrl+q"] //! ``` //! +//! ## Chord syntax +//! +//! A binding string is one or more chord steps separated by whitespace. +//! Each step uses the canonical `++` form. A single-step +//! binding (`"ctrl+n"`) is just a degenerate chord. Multi-step chords +//! enter "pending" state on their first step — see [`KeyMap::dispatch`]. +//! //! Unknown entries fall back to their defaults; missing sections fall back //! to [`KeyMap::default`]. Parsing happens at deserialisation time, so a //! malformed binding string surfaces as a clear config error rather than a @@ -27,7 +37,7 @@ use std::fmt; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -/// A single key combination: a [`KeyCode`] plus a set of [`KeyModifiers`]. +/// A single chord step: a [`KeyCode`] plus a set of [`KeyModifiers`]. /// /// Serialises/deserialises from strings like `"ctrl+n"`, `"shift+tab"`, /// `"esc"`, `"?"`, `"F2"`. The canonical string form is @@ -90,12 +100,9 @@ impl KeyBinding { } if self_mods.contains(KeyModifiers::SHIFT) { - // Exact modifier match — shift is load-bearing. let _ = (self_lower, ev_lower); ev_mods == self_mods } else { - // Shift-insensitive: a bare `y` binding still matches - // `Y` / `Shift+y`, but ctrl/alt must still agree. let strip = KeyModifiers::SHIFT; (self_mods & !strip) == (ev_mods & !strip) } @@ -116,9 +123,6 @@ impl KeyBinding { == (self.modifiers & !KeyModifiers::SHIFT) } (KeyCode::BackTab, KeyCode::BackTab) => { - // BackTab always implies shift; treat the SHIFT modifier as - // optional so a `"backtab"` binding still matches a raw - // `BackTab + NONE` event. let strip = KeyModifiers::SHIFT; (self.modifiers & !strip) == (event_mods & !strip) } @@ -192,13 +196,8 @@ impl std::str::FromStr for KeyBinding { return Ok(KeyBinding::bare(KeyCode::Char('+'))); } - // Split on '+', but if the final segment is empty it means the - // binding itself ends with a literal '+' (e.g. "ctrl++"). Recover - // that as a trailing '+' keycode segment. let raw: Vec<&str> = input.split('+').collect(); let (mod_parts, code_part): (Vec<&str>, &str) = if raw.last() == Some(&"") { - // Trailing '+': everything except the last empty piece is the - // modifier stack; the code is literally '+'. let modifiers = raw[..raw.len() - 1].to_vec(); (modifiers[..modifiers.len().saturating_sub(1)].to_vec(), "+") } else { @@ -228,9 +227,6 @@ impl std::str::FromStr for KeyBinding { code: code_part.to_string(), })?; - // A single-character binding like "Y" implicitly carries shift; we - // prefer the canonical lowercase-char + SHIFT form so later matching - // is consistent. let (code, modifiers) = match code { KeyCode::Char(c) if c.is_ascii_uppercase() => ( KeyCode::Char(c.to_ascii_lowercase()), @@ -263,7 +259,6 @@ fn parse_code(s: &str) -> Option { "delete" | "del" => Some(KeyCode::Delete), "insert" | "ins" => Some(KeyCode::Insert), _ => { - // Function keys: f1..f24 if let Some(rest) = lower.strip_prefix('f') { if let Ok(n) = rest.parse::() { if (1..=24).contains(&n) { @@ -271,7 +266,6 @@ fn parse_code(s: &str) -> Option { } } } - // Single char (case-preserving from the original input) let mut chars = s.chars(); let first = chars.next()?; if chars.next().is_none() { @@ -325,104 +319,439 @@ impl<'de> Deserialize<'de> for KeyBinding { } } -/// Helper so views can take either a `&Vec` or `&[KeyBinding]` -/// and test against a live `KeyEvent` with `.matches(&key)`. +// ── KeyChord ─────────────────────────────────────────────────────────────── + +/// A sequence of one or more [`KeyBinding`] steps. +/// +/// A single-step chord (the default for most bindings) behaves exactly like +/// the underlying [`KeyBinding`]. Multi-step chords (e.g. `"ctrl+x s"`) +/// require the leader-key dispatcher to track pending state across events; +/// see [`KeyMap::dispatch`]. +/// +/// String form: chord steps separated by whitespace, each step in canonical +/// `++` form. Examples: +/// - `"ctrl+s"` — single-step, fires immediately. +/// - `"ctrl+x s"` — two-step leader chord: press Ctrl+X, then bare `s`. +/// - `"ctrl+x ctrl+s"` — two Ctrl-modified steps. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyChord { + steps: Vec, +} + +impl KeyChord { + /// Construct a chord from a non-empty step list. Returns `None` for + /// an empty input — chords with zero steps are nonsensical. + pub fn try_new(steps: Vec) -> Option { + if steps.is_empty() { + None + } else { + Some(Self { steps }) + } + } + + /// Single-step chord wrapping a [`KeyBinding`]. + pub fn single(binding: KeyBinding) -> Self { + Self { + steps: vec![binding], + } + } + + /// Lift a sequence of live `KeyEvent`s into a chord. The shift-from- + /// uppercase-char fixup mirrors `KeyBinding::FromStr` so the resulting + /// chord round-trips cleanly through `Display` — i.e. `format("{c}")` + /// on a chord built from the events of `Ctrl+X` then `Y` produces + /// `"ctrl+x shift+y"`, matching the user-facing config syntax. + pub fn from_events(events: &[KeyEvent]) -> Option { + let steps: Vec = events + .iter() + .map(|ev| { + let mut mods = ev.modifiers; + if let KeyCode::Char(c) = ev.code { + if c.is_ascii_uppercase() { + mods |= KeyModifiers::SHIFT; + } + } + KeyBinding::new(ev.code, mods) + }) + .collect(); + Self::try_new(steps) + } + + /// Number of chord steps (always ≥ 1; chords with zero steps are + /// rejected at construction time, so [`Self::try_new`] returns + /// `Option`). + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.steps.len() + } + + pub fn is_single_step(&self) -> bool { + self.steps.len() == 1 + } + + /// All chord steps, in order. + pub fn steps(&self) -> &[KeyBinding] { + &self.steps + } + + /// First chord step. Used to surface a "what to press to enter this + /// chord" hint without exposing the whole sequence. + pub fn first_step(&self) -> &KeyBinding { + &self.steps[0] + } + + /// Does the supplied event sequence (length N) match the chord's first + /// N steps exactly? Useful for prefix-matching during chord dispatch. + pub fn matches_prefix(&self, events: &[KeyEvent]) -> bool { + if events.len() > self.steps.len() { + return false; + } + events + .iter() + .zip(self.steps.iter()) + .all(|(ev, step)| step.matches(ev)) + } + + /// Does the supplied event sequence match the chord exactly (same + /// length, every step matches)? + pub fn matches_exact(&self, events: &[KeyEvent]) -> bool { + events.len() == self.steps.len() && self.matches_prefix(events) + } +} + +impl fmt::Display for KeyChord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, step) in self.steps.iter().enumerate() { + if i > 0 { + f.write_str(" ")?; + } + write!(f, "{step}")?; + } + Ok(()) + } +} + +impl std::str::FromStr for KeyChord { + type Err = KeyBindingParseError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(KeyBindingParseError::Empty); + } + let steps: Result, _> = trimmed + .split_whitespace() + .map(|step| step.parse::()) + .collect(); + let steps = steps?; + if steps.is_empty() { + return Err(KeyBindingParseError::Empty); + } + Ok(Self { steps }) + } +} + +impl Serialize for KeyChord { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for KeyChord { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + raw.parse().map_err(serde::de::Error::custom) + } +} + +/// Helper so views can take either a `&Vec` or `&[KeyChord]` and +/// test against a live `KeyEvent` with `.matches(&key)`. +/// +/// Only **single-step** chords participate in this match. Multi-step chords +/// fire exclusively through [`KeyMap::dispatch`] at the app level — they +/// would be impossible to fire from a one-shot key match. pub trait Bindings { fn matches(&self, key: &KeyEvent) -> bool; } -impl Bindings for Vec { +impl Bindings for Vec { fn matches(&self, key: &KeyEvent) -> bool { - self.iter().any(|b| b.matches(key)) + self.iter() + .any(|c| c.is_single_step() && c.first_step().matches(key)) } } -impl Bindings for [KeyBinding] { +impl Bindings for [KeyChord] { fn matches(&self, key: &KeyEvent) -> bool { - self.iter().any(|b| b.matches(key)) + self.iter() + .any(|c| c.is_single_step() && c.first_step().matches(key)) } } +// ── KeyAction + KeyMap ───────────────────────────────────────────────────── + +/// First-class identifier for every keymap-driven action. Used by the chord +/// dispatcher to deliver completed multi-step bindings to the active view +/// without going through a synthesized [`KeyEvent`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyAction { + Quit, + Help, + + CommandPalette, + NewSecret, + SwitchStore, + CopySelected, + /// In the search view: copy `himitsu read ` for the selected row. + CopyRefSelected, + Envs, + + Reveal, + CopyValue, + /// In the secret viewer: copy `himitsu read ` for the open secret. + CopyRef, + Rekey, + Edit, + Delete, + Back, + + SaveSecret, + NextField, + PrevField, + Cancel, +} + +/// Outcome of feeding a key event to [`KeyMap::dispatch`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Dispatch { + /// The pending sequence plus this key uniquely match a complete chord. + /// The matching action fires; the dispatcher's pending buffer should + /// be cleared. + Match(KeyAction), + /// At least one chord is a strict prefix of the pending+key sequence. + /// The key should be added to the pending buffer; nothing fires yet. + Pending, + /// Neither a complete match nor a prefix. The pending buffer should be + /// cleared and the key should be processed as a normal (non-chord) + /// keystroke. + Unmatched, +} + /// User-configurable keybindings grouped by action. /// -/// Each field is a list so multiple key combinations can map to the same -/// action. Unspecified fields fall back to [`KeyMap::default`], which -/// reproduces the original hardcoded bindings. -/// -/// Actions are grouped loosely by the view that consumes them, but there is -/// no enforcement — a single binding list can be reused across views. +/// Each field is a list of [`KeyChord`]s so multiple key combinations can +/// map to the same action. Unspecified fields fall back to +/// [`KeyMap::default`], which reproduces the original hardcoded bindings. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct KeyMap { // ── Global ──────────────────────────────────────────────────────── - /// Quit the app from any view (default: Esc, Ctrl+C). - pub quit: Vec, - /// Open the contextual help overlay (default: `?`). - pub help: Vec, + pub quit: Vec, + pub help: Vec, // ── Search view ────────────────────────────────────────────────── - /// Open the command palette overlay (default: Ctrl+P). The palette is - /// the canonical way to discover and run commands; individual hotkeys - /// remain as power-user shortcuts. - pub command_palette: Vec, - /// Open the new-secret form from the search view (default: Ctrl+N). - pub new_secret: Vec, - /// Open the store-picker overlay (default: Ctrl+S). - pub switch_store: Vec, - /// Copy the selected search result's value to the clipboard (default: Ctrl+Y). - pub copy_selected: Vec, - /// Open the envs view (browse/delete preset env labels) from search - /// (default: Shift+E). - pub envs: Vec, + pub command_palette: Vec, + pub new_secret: Vec, + pub switch_store: Vec, + /// Copy the selected search result's value to the clipboard + /// (default: Ctrl+Y). + pub copy_selected: Vec, + /// Copy `himitsu read ` (the *command*, not the value) to the + /// clipboard for the selected row. Useful when sharing how to fetch a + /// secret without putting plaintext on the clipboard. Default: `Y` + /// (Shift+y) — symmetric with the viewer. + pub copy_ref_selected: Vec, + pub envs: Vec, // ── Secret viewer ──────────────────────────────────────────────── - /// Reveal / hide the decrypted value (default: `r`). - pub reveal: Vec, + pub reveal: Vec, /// Copy the revealed value to the clipboard (default: `y`). - pub copy_value: Vec, - /// Rekey the secret (default: `R` — shift+r). - pub rekey: Vec, - /// Open the external editor on the current secret (default: `e`). - pub edit: Vec, - /// Enter the confirm-delete overlay (default: `d`). - pub delete: Vec, - /// Go back to the parent view from the secret viewer (default: Esc). - pub back: Vec, + pub copy_value: Vec, + /// Copy `himitsu read ` (the *command*) to the clipboard for the + /// currently open secret. Default: `Y` (Shift+y). + pub copy_ref: Vec, + pub rekey: Vec, + pub edit: Vec, + pub delete: Vec, + pub back: Vec, // ── New-secret form ─────────────────────────────────────────────── - /// Save the new secret (default: Ctrl+S, Ctrl+W). - pub save_secret: Vec, - /// Advance to the next form field (default: Tab). - pub next_field: Vec, - /// Return to the previous form field (default: Shift+Tab). - pub prev_field: Vec, - /// Cancel the new-secret form and return to search (default: Esc). - pub cancel: Vec, + pub save_secret: Vec, + pub next_field: Vec, + pub prev_field: Vec, + pub cancel: Vec, } impl Default for KeyMap { fn default() -> Self { + let single = |b: KeyBinding| KeyChord::single(b); + let bare = |c: KeyCode| single(KeyBinding::bare(c)); + let ctrl = |c: char| single(KeyBinding::ctrl(c)); + let shift_char = |c: char| single(KeyBinding::new(KeyCode::Char(c), KeyModifiers::SHIFT)); + Self { - quit: vec![KeyBinding::bare(KeyCode::Esc), KeyBinding::ctrl('c')], - help: vec![KeyBinding::bare(KeyCode::Char('?'))], - - command_palette: vec![KeyBinding::ctrl('p')], - new_secret: vec![KeyBinding::ctrl('n')], - switch_store: vec![KeyBinding::ctrl('s')], - copy_selected: vec![KeyBinding::ctrl('y')], - envs: vec![KeyBinding::new(KeyCode::Char('e'), KeyModifiers::SHIFT)], - - reveal: vec![KeyBinding::bare(KeyCode::Char('r'))], - copy_value: vec![KeyBinding::bare(KeyCode::Char('y'))], - rekey: vec![KeyBinding::new(KeyCode::Char('r'), KeyModifiers::SHIFT)], - edit: vec![KeyBinding::bare(KeyCode::Char('e'))], - delete: vec![KeyBinding::bare(KeyCode::Char('d'))], - back: vec![KeyBinding::bare(KeyCode::Esc)], - - save_secret: vec![KeyBinding::ctrl('s'), KeyBinding::ctrl('w')], - next_field: vec![KeyBinding::bare(KeyCode::Tab)], - prev_field: vec![KeyBinding::bare(KeyCode::BackTab)], - cancel: vec![KeyBinding::bare(KeyCode::Esc)], + quit: vec![bare(KeyCode::Esc), ctrl('c')], + help: vec![bare(KeyCode::Char('?'))], + + command_palette: vec![ctrl('p')], + new_secret: vec![ctrl('n')], + switch_store: vec![ctrl('s')], + copy_selected: vec![ctrl('y')], + copy_ref_selected: vec![shift_char('y')], + envs: vec![shift_char('e')], + + reveal: vec![bare(KeyCode::Char('r'))], + copy_value: vec![bare(KeyCode::Char('y'))], + copy_ref: vec![shift_char('y')], + rekey: vec![shift_char('r')], + edit: vec![bare(KeyCode::Char('e'))], + delete: vec![bare(KeyCode::Char('d'))], + back: vec![bare(KeyCode::Esc)], + + save_secret: vec![ctrl('s'), ctrl('w')], + next_field: vec![bare(KeyCode::Tab)], + prev_field: vec![bare(KeyCode::BackTab)], + cancel: vec![bare(KeyCode::Esc)], + } + } +} + +impl KeyMap { + /// `(action, chords)` pairs across every keymap field. Stack-allocated + /// so the dispatcher (called per keystroke) doesn't churn the heap. + /// When you add a new `KeyAction`, append it here and bump the array + /// length. + fn entries(&self) -> [(KeyAction, &Vec); 19] { + [ + (KeyAction::Quit, &self.quit), + (KeyAction::Help, &self.help), + (KeyAction::CommandPalette, &self.command_palette), + (KeyAction::NewSecret, &self.new_secret), + (KeyAction::SwitchStore, &self.switch_store), + (KeyAction::CopySelected, &self.copy_selected), + (KeyAction::CopyRefSelected, &self.copy_ref_selected), + (KeyAction::Envs, &self.envs), + (KeyAction::Reveal, &self.reveal), + (KeyAction::CopyValue, &self.copy_value), + (KeyAction::CopyRef, &self.copy_ref), + (KeyAction::Rekey, &self.rekey), + (KeyAction::Edit, &self.edit), + (KeyAction::Delete, &self.delete), + (KeyAction::Back, &self.back), + (KeyAction::SaveSecret, &self.save_secret), + (KeyAction::NextField, &self.next_field), + (KeyAction::PrevField, &self.prev_field), + (KeyAction::Cancel, &self.cancel), + ] + } + + /// Single-step direct lookup: find the action whose binding list + /// contains a single-step chord matching `key`. Returns the FIRST + /// matching action in [`Self::entries`] order; ties favour the action + /// declared earliest. Multi-step chords are NEVER returned here — + /// they fire only via [`Self::dispatch`]. + pub fn action_for_key(&self, key: &KeyEvent) -> Option { + for (action, chords) in self.entries() { + if chords + .iter() + .any(|c| c.is_single_step() && c.first_step().matches(key)) + { + return Some(action); + } + } + None + } + + /// View-scoped variant of [`Self::action_for_key`]: scan only the + /// listed actions, in the order given. Each view declares its own + /// priority slice (e.g. the secret viewer wants `Rekey` before + /// `Reveal` so `Shift+R` doesn't fall through to bare `r`); shared + /// here so the per-view helpers don't each rebuild the same iteration. + pub fn action_for_key_in( + &self, + key: &KeyEvent, + priority: &[KeyAction], + ) -> Option { + priority + .iter() + .copied() + .find(|&action| self.chords_for(action).matches(key)) + } + + /// Borrow the chord list registered for a given action. Looked up + /// linearly through [`Self::entries`] — ~19 entries, called per + /// keystroke; the cost is dwarfed by terminal redraw. + pub fn chords_for(&self, action: KeyAction) -> &Vec { + for (a, chords) in self.entries() { + if a == action { + return chords; + } + } + // entries() covers every variant of KeyAction, so this is + // unreachable in practice. + unreachable!("KeyMap::entries missing variant {action:?}") + } + + /// Drive the leader-key state machine. + /// + /// `pending` is the buffer of events accumulated from previously-pending + /// chord steps; `key` is the just-arrived event. Returns: + /// + /// - [`Dispatch::Match`] when `pending + [key]` exactly matches some + /// **multi-step** chord. Caller should fire the action and clear + /// `pending`. + /// - [`Dispatch::Pending`] when at least one chord has `pending + [key]` + /// as a strict prefix (i.e. more keys could complete a chord). Caller + /// should append `key` to `pending` and swallow it. + /// - [`Dispatch::Unmatched`] otherwise. Caller should clear `pending` + /// and treat `key` as a normal non-chord input. + /// + /// Single-step chords are deliberately invisible to this dispatcher: + /// they're already handled by each view's existing per-action priority + /// match (which knows which actions that view cares about). Letting + /// `dispatch` claim single-step bindings would steal plain typing + /// keys (e.g. `e` matching the viewer's `edit` while the user is + /// typing into the new-secret form's `path` field). + /// + /// Caveat — multi-step chords always shadow single-step bindings on + /// the same first key: if a user binds both `edit: ["e"]` and + /// `delete: ["e d"]`, pressing `e` enters Pending state because the + /// `e d` chord has `e` as a prefix. The single-step `e` binding can + /// then never fire without a continuation that aborts the chord. + /// This is by design — leader chords need to swallow their first + /// step or they wouldn't work. + /// + /// Resolution rule when several chords match: + /// - If both an exact multi-step match and a longer prefix-match exist + /// for the same buffer, the exact match wins (greedy short-match). + /// Practical implication: don't bind both `ctrl+x s` and + /// `ctrl+x s ctrl+w` — the shorter chord will always fire first. + pub fn dispatch(&self, pending: &[KeyEvent], key: &KeyEvent) -> Dispatch { + let mut buf: Vec = Vec::with_capacity(pending.len() + 1); + buf.extend_from_slice(pending); + buf.push(*key); + + let mut exact: Option = None; + let mut has_longer_prefix = false; + + for (action, chords) in self.entries() { + for chord in chords { + if !chord.is_single_step() && chord.matches_exact(&buf) { + if exact.is_none() { + exact = Some(action); + } + } else if chord.len() > buf.len() && chord.matches_prefix(&buf) { + has_longer_prefix = true; + } + } + } + + if let Some(action) = exact { + Dispatch::Match(action) + } else if has_longer_prefix { + Dispatch::Pending + } else { + Dispatch::Unmatched } } } @@ -537,7 +866,6 @@ new_secret: ["F2"] assert!(!km .new_secret .matches(&key(KeyCode::Char('n'), KeyModifiers::CONTROL))); - // Unspecified actions still match the default. assert!(km.quit.matches(&key(KeyCode::Esc, KeyModifiers::NONE))); } @@ -553,4 +881,162 @@ new_secret: ["ctrl+ctrl+foo"] "expected parse error to mention the bad binding, got: {msg}" ); } + + // ── Chord parsing ────────────────────────────────────────────────── + + #[test] + fn parses_single_step_chord() { + let c: KeyChord = "ctrl+s".parse().unwrap(); + assert!(c.is_single_step()); + assert_eq!(c.first_step(), &KeyBinding::ctrl('s')); + } + + #[test] + fn parses_two_step_leader_chord() { + let c: KeyChord = "ctrl+x s".parse().unwrap(); + assert_eq!(c.len(), 2); + assert_eq!(c.steps()[0], KeyBinding::ctrl('x')); + assert_eq!(c.steps()[1], KeyBinding::bare(KeyCode::Char('s'))); + } + + #[test] + fn parses_chord_with_modifier_steps() { + let c: KeyChord = "ctrl+x ctrl+s".parse().unwrap(); + assert_eq!(c.steps()[0], KeyBinding::ctrl('x')); + assert_eq!(c.steps()[1], KeyBinding::ctrl('s')); + } + + #[test] + fn chord_display_round_trip() { + let original = "ctrl+x s"; + let parsed: KeyChord = original.parse().unwrap(); + assert_eq!(parsed.to_string(), original); + } + + #[test] + fn empty_chord_string_rejected() { + assert!("".parse::().is_err()); + assert!(" ".parse::().is_err()); + } + + #[test] + fn chord_yaml_round_trip() { + let yaml = r#" +save_secret: ["ctrl+x s", "ctrl+w"] +"#; + let km: KeyMap = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(km.save_secret.len(), 2); + assert_eq!(km.save_secret[0].len(), 2); + assert!(km.save_secret[1].is_single_step()); + + // Round-trip preserves the chord shape. + let back = serde_yaml::to_string(&km).unwrap(); + assert!(back.contains("ctrl+x s")); + assert!(back.contains("ctrl+w")); + } + + // ── Bindings::matches semantics ──────────────────────────────────── + + #[test] + fn bindings_match_ignores_multi_step_chords() { + // A user binding `save_secret: ["ctrl+x s"]` must NOT fire on a bare + // `s` keypress — multi-step chords go through dispatch, never the + // single-key matcher. + let km: KeyMap = serde_yaml::from_str(r#"save_secret: ["ctrl+x s"]"#).unwrap(); + assert!(!km + .save_secret + .matches(&key(KeyCode::Char('s'), KeyModifiers::NONE))); + assert!(!km + .save_secret + .matches(&key(KeyCode::Char('x'), KeyModifiers::CONTROL))); + } + + #[test] + fn bindings_match_allows_single_step_chords() { + let km: KeyMap = serde_yaml::from_str(r#"save_secret: ["ctrl+s"]"#).unwrap(); + assert!(km + .save_secret + .matches(&key(KeyCode::Char('s'), KeyModifiers::CONTROL))); + } + + // ── KeyMap::dispatch state machine ───────────────────────────────── + + #[test] + fn dispatch_ignores_single_step_chords() { + // Single-step bindings flow through each view's per-action priority + // match, not the chord dispatcher — otherwise typing 'e' into a + // form would steal the viewer's `edit` binding. + let km = KeyMap::default(); + let result = km.dispatch(&[], &key(KeyCode::Char('n'), KeyModifiers::CONTROL)); + assert_eq!(result, Dispatch::Unmatched); + } + + #[test] + fn dispatch_two_step_chord_pending_then_match() { + let km: KeyMap = serde_yaml::from_str(r#"save_secret: ["ctrl+x s"]"#).unwrap(); + + // Step 1: ctrl+x with no pending → Pending (because ctrl+x is a + // strict prefix of ctrl+x s). + let r1 = km.dispatch(&[], &key(KeyCode::Char('x'), KeyModifiers::CONTROL)); + assert_eq!(r1, Dispatch::Pending); + + // Step 2: feed 's' with pending = [ctrl+x] → Match. + let pending = vec![key(KeyCode::Char('x'), KeyModifiers::CONTROL)]; + let r2 = km.dispatch(&pending, &key(KeyCode::Char('s'), KeyModifiers::NONE)); + assert_eq!(r2, Dispatch::Match(KeyAction::SaveSecret)); + } + + #[test] + fn dispatch_chord_aborts_when_no_continuation_matches() { + let km: KeyMap = serde_yaml::from_str(r#"save_secret: ["ctrl+x s"]"#).unwrap(); + let pending = vec![key(KeyCode::Char('x'), KeyModifiers::CONTROL)]; + + // 'q' isn't a continuation of ctrl+x → Unmatched. + let r = km.dispatch(&pending, &key(KeyCode::Char('q'), KeyModifiers::NONE)); + assert_eq!(r, Dispatch::Unmatched); + } + + #[test] + fn dispatch_unmatched_when_no_chord_starts_with_key() { + let km = KeyMap::default(); + // 'z' isn't bound anywhere by default. + let r = km.dispatch(&[], &key(KeyCode::Char('z'), KeyModifiers::NONE)); + assert_eq!(r, Dispatch::Unmatched); + } + + #[test] + fn dispatch_lets_chord_take_precedence_over_single_step_prefix() { + // User binds both `ctrl+x` (single-step) and `ctrl+x s` (chord) + // to different actions. Pressing Ctrl+X enters Pending state + // because the chord dispatcher only fires on multi-step matches — + // the single-step `quit` is left to the per-view path, which the + // App would only consult if the chord aborts. + let yaml = r#" +quit: ["ctrl+x"] +save_secret: ["ctrl+x s"] +"#; + let km: KeyMap = serde_yaml::from_str(yaml).unwrap(); + let r = km.dispatch(&[], &key(KeyCode::Char('x'), KeyModifiers::CONTROL)); + assert_eq!(r, Dispatch::Pending); + } + + #[test] + fn action_for_key_skips_multi_step_chords() { + let km: KeyMap = serde_yaml::from_str(r#"save_secret: ["ctrl+x s"]"#).unwrap(); + // bare 's' must not be claimed by save_secret since the only binding + // is multi-step. + assert_eq!( + km.action_for_key(&key(KeyCode::Char('s'), KeyModifiers::NONE)), + None + ); + } + + #[test] + fn action_for_key_finds_single_step_chord() { + let km = KeyMap::default(); + assert_eq!( + km.action_for_key(&key(KeyCode::Char('n'), KeyModifiers::CONTROL)), + Some(KeyAction::NewSecret) + ); + } } diff --git a/rust/src/tui/views/new_secret.rs b/rust/src/tui/views/new_secret.rs index 512ec11..4de7c70 100644 --- a/rust/src/tui/views/new_secret.rs +++ b/rust/src/tui/views/new_secret.rs @@ -43,7 +43,18 @@ use crate::cli::Context; use crate::crypto::{age, secret_value, tags as tag_grammar}; use crate::proto::SecretValue; use crate::remote::store; -use crate::tui::keymap::{Bindings, KeyMap}; +use crate::tui::keymap::{Bindings, KeyAction, KeyMap}; + +/// New-secret form's keymap action priority (excluding NextField, which +/// is dispatched inside the field-specific handlers since it must run +/// the per-field validator before advancing). Cancel comes before save +/// so an explicit Esc binding always wins over any save chord that +/// happens to share its first key. +const FORM_ACTION_PRIORITY: &[KeyAction] = &[ + KeyAction::Cancel, + KeyAction::SaveSecret, + KeyAction::PrevField, +]; /// Outcome of handling a key — routed by [`crate::tui::app::App`]. #[derive(Debug, Clone)] @@ -184,23 +195,14 @@ impl NewSecretView { ) { return NewSecretAction::Quit; } - if keymap.cancel.matches(&key) { - return NewSecretAction::Cancel; - } - - // Save from any step. Default bindings are Ctrl+S and Ctrl+W — - // Ctrl+W being the tmux-safe alternative to Ctrl+S for users who - // bind tmux's prefix to Ctrl+S. - if keymap.save_secret.matches(&key) { - return self.submit(); - } - - // Prev-field wraps backward from any step. Checked before field - // dispatch so a keymap override still works inside the multi-line - // value editor. - if keymap.prev_field.matches(&key) { - self.move_to(self.step.prev()); - return NewSecretAction::None; + // Resolve cancel / save / prev_field up front so a chord-completed + // action takes the same path as the bare keystroke. NextField + // stays inside the field-specific handlers because it interacts + // with per-field validation. + if let Some(action) = keymap.action_for_key_in(&key, FORM_ACTION_PRIORITY) { + if let Some(outcome) = self.dispatch_action(action) { + return outcome; + } } match self.step { @@ -209,6 +211,22 @@ impl NewSecretView { } } + /// Run a [`KeyAction`] against the new-secret form. Returns `None` for + /// actions this form doesn't own (e.g. NextField, which is intentionally + /// scoped to the field-specific handlers below so it interacts with the + /// per-field validate-then-advance flow). + pub fn dispatch_action(&mut self, action: KeyAction) -> Option { + match action { + KeyAction::Cancel => Some(NewSecretAction::Cancel), + KeyAction::SaveSecret => Some(self.submit()), + KeyAction::PrevField => { + self.move_to(self.step.prev()); + Some(NewSecretAction::None) + } + _ => None, + } + } + /// Single-line editor used by every field except `Value`. `Tab` / `Enter` /// advances to the next field (running field-local validation first); /// `Backspace` erases; printable chars append. diff --git a/rust/src/tui/views/search.rs b/rust/src/tui/views/search.rs index f50775c..b0d0161 100644 --- a/rust/src/tui/views/search.rs +++ b/rust/src/tui/views/search.rs @@ -27,7 +27,7 @@ use crate::cli::Context; use crate::crypto::{age, secret_value}; use crate::remote::store; use crate::tui::icons; -use crate::tui::keymap::{Bindings, KeyMap}; +use crate::tui::keymap::{KeyAction, KeyMap}; use crate::tui::views::command_palette::{Command, CommandPalette, CommandPaletteOutcome}; use crate::tui::views::store_picker::{StorePicker, StorePickerOutcome}; @@ -227,28 +227,12 @@ impl SearchView { // Configurable action bindings take precedence over the fall-through // text-editing keys below. Quit is checked first so a user who rebinds // new_secret to a printable character still has an escape hatch. - if keymap.quit.matches(&key) { - return SearchAction::Quit; - } - if keymap.command_palette.matches(&key) { - self.palette = Some(CommandPalette::new()); - return SearchAction::None; - } - if keymap.new_secret.matches(&key) { - return SearchAction::NewSecret; - } - if keymap.envs.matches(&key) { - return SearchAction::OpenEnvs; - } - if keymap.switch_store.matches(&key) { - self.picker = Some(StorePicker::new( - &self.ctx.stores_dir(), - self.ctx.store.clone(), - )); - return SearchAction::None; - } - if keymap.copy_selected.matches(&key) { - return self.copy_selected_to_clipboard(); + // All matches route through `dispatch_action` so leader-key chord + // completions (resolved at the App layer) take the same code path. + if let Some(action) = match_keymap_action(keymap, &key) { + if let Some(outcome) = self.dispatch_action(action) { + return outcome; + } } match (key.code, key.modifiers) { @@ -292,6 +276,36 @@ impl SearchView { } } + /// Run a [`KeyAction`] against the search view. Returns `None` for + /// actions this view doesn't own (so the caller can fall through to + /// raw-key handling), `Some(SearchAction::None)` for actions that are + /// consumed but produce no router work (overlay opens, etc.), and + /// other variants for outcomes the router needs to surface. + /// + /// Used both by the single-key matcher in `on_key` and by the leader- + /// key dispatcher in `App::on_key` when a multi-step chord completes. + pub fn dispatch_action(&mut self, action: KeyAction) -> Option { + match action { + KeyAction::Quit => Some(SearchAction::Quit), + KeyAction::CommandPalette => { + self.palette = Some(CommandPalette::new()); + Some(SearchAction::None) + } + KeyAction::NewSecret => Some(SearchAction::NewSecret), + KeyAction::Envs => Some(SearchAction::OpenEnvs), + KeyAction::SwitchStore => { + self.picker = Some(StorePicker::new( + &self.ctx.stores_dir(), + self.ctx.store.clone(), + )); + Some(SearchAction::None) + } + KeyAction::CopySelected => Some(self.copy_selected_to_clipboard()), + KeyAction::CopyRefSelected => Some(self.copy_selected_ref_to_clipboard()), + _ => None, + } + } + /// Decrypt the currently selected secret and copy its value to the system /// clipboard. Returns a [`SearchAction`] carrying the copy outcome so the /// router can surface it as a toast; never panics on headless/no-selection. @@ -311,6 +325,24 @@ impl SearchView { } } + /// Copy `himitsu read ` for the selected row to the clipboard. No + /// decryption — just the path, suitable for pasting into a terminal, + /// pull request, or chat message without putting plaintext on the + /// clipboard. The ref is qualified with `-r ` when the active + /// store differs from the selected row's store, so the command works + /// from a different shell. + fn copy_selected_ref_to_clipboard(&mut self) -> SearchAction { + let Some(result) = self.selected_result().cloned() else { + return SearchAction::CopyFailed("no selection to copy".to_string()); + }; + let active_label = crate::cli::search::store_label(&self.ctx.store, &self.ctx); + let cmd = format_read_command(&result.store, &result.path, &active_label); + match arboard::Clipboard::new().and_then(|mut c| c.set_text(cmd.clone())) { + Ok(()) => SearchAction::Copied(format!("$ {cmd}")), + Err(e) => SearchAction::CopyFailed(format!("clipboard unavailable: {e}")), + } + } + fn refresh_results(&mut self) { // Pass an empty tag filter; the TUI handles tag chips/filtering in a // separate worker so this view always asks for everything. @@ -1045,6 +1077,40 @@ fn build_env_index() -> std::collections::HashMap> { .collect() } +/// Search view's keymap action priority. Quit comes first so a user who +/// rebinds an action to a printable character still has an escape hatch. +/// `CopyRefSelected` is listed before `CopySelected` because their +/// default bindings overlap on the bare `y` key: shift-insensitive +/// matching means a `y` binding would otherwise claim `Shift+Y` first. +const SEARCH_ACTION_PRIORITY: &[KeyAction] = &[ + KeyAction::Quit, + KeyAction::CommandPalette, + KeyAction::NewSecret, + KeyAction::Envs, + KeyAction::SwitchStore, + KeyAction::CopyRefSelected, + KeyAction::CopySelected, +]; + +fn match_keymap_action(keymap: &KeyMap, key: &crossterm::event::KeyEvent) -> Option { + keymap.action_for_key_in(key, SEARCH_ACTION_PRIORITY) +} + +/// Build the `himitsu read ` command for a given (store, path) pair. +/// +/// `active_label` is the canonical label of the currently-active store, +/// produced by [`crate::cli::search::store_label`] — same function that +/// populates `SearchResult.store`. Comparing labels directly means +/// `same_store` is decided in one place and stays consistent whether the +/// active store is set by slug or by absolute path. +fn format_read_command(row_store: &str, secret_path: &str, active_label: &str) -> String { + if row_store == active_label { + format!("himitsu read {secret_path}") + } else { + format!("himitsu -r {row_store} read {secret_path}") + } +} + /// Decrypt a search result's ciphertext to its UTF-8 value, using the /// identity file tied to the result's origin store. Kept as a free function /// so it stays trivially testable without the full view state. @@ -1310,6 +1376,31 @@ impl SearchView { } } +#[cfg(test)] +mod read_command_tests { + use super::*; + + #[test] + fn same_store_emits_bare_path() { + let cmd = format_read_command("acme/secrets", "prod/API_KEY", "acme/secrets"); + assert_eq!(cmd, "himitsu read prod/API_KEY"); + } + + #[test] + fn cross_store_qualifies_with_remote_flag() { + let cmd = format_read_command("acme/infra", "prod/SHARED_KEY", "acme/secrets"); + assert_eq!(cmd, "himitsu -r acme/infra read prod/SHARED_KEY"); + } + + #[test] + fn full_path_label_round_trips_too() { + // When the active store is set by absolute path, store_label + // returns the same path string — equality still says "same store". + let cmd = format_read_command("/tmp/x/.himitsu", "p", "/tmp/x/.himitsu"); + assert_eq!(cmd, "himitsu read p"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/tui/views/secret_viewer.rs b/rust/src/tui/views/secret_viewer.rs index 0e81269..e5ef76d 100644 --- a/rust/src/tui/views/secret_viewer.rs +++ b/rust/src/tui/views/secret_viewer.rs @@ -36,7 +36,7 @@ use crate::crypto::{age, secret_value, tags as tag_grammar}; use crate::error::HimitsuError; use crate::proto::SecretValue; use crate::remote::store::{self, SecretMeta}; -use crate::tui::keymap::{Bindings, KeyMap}; +use crate::tui::keymap::{KeyAction, KeyMap}; /// Outcome of handling a key — routed by [`crate::tui::app::App`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -153,33 +153,42 @@ impl SecretViewerView { return self.on_key_confirm_delete(key); } - // Rekey is checked before reveal/copy because its default binding - // (`Shift+R`) overlaps the same `KeyCode::Char('r')` as `reveal`; - // matching in this order keeps the existing behaviour exact. - if keymap.rekey.matches(&key) { - self.rekey(); - return SecretViewerAction::None; - } - if keymap.reveal.matches(&key) { - self.toggle_reveal(); - return SecretViewerAction::None; - } - if keymap.copy_value.matches(&key) { - return self.copy_to_clipboard(); - } - if keymap.edit.matches(&key) { - return self.begin_edit(); - } - if keymap.delete.matches(&key) { - self.enter_confirm_delete(); - return SecretViewerAction::None; - } - if keymap.back.matches(&key) { - return SecretViewerAction::Back; + // Resolve the keymap action in view-defined priority order so + // overlapping bindings (Shift+R vs `r`, copy_ref vs copy_value) + // match the way they did before chord support landed. + if let Some(action) = match_keymap_action(keymap, &key) { + if let Some(outcome) = self.dispatch_action(action) { + return outcome; + } } SecretViewerAction::None } + /// Run a [`KeyAction`] against the secret viewer. Returns `None` for + /// actions this view doesn't own. See [`super::search::SearchView::dispatch_action`] + /// for the same contract. + pub fn dispatch_action(&mut self, action: KeyAction) -> Option { + match action { + KeyAction::Rekey => { + self.rekey(); + Some(SecretViewerAction::None) + } + KeyAction::Reveal => { + self.toggle_reveal(); + Some(SecretViewerAction::None) + } + KeyAction::CopyValue => Some(self.copy_to_clipboard()), + KeyAction::CopyRef => Some(self.copy_ref_to_clipboard()), + KeyAction::Edit => Some(self.begin_edit()), + KeyAction::Delete => { + self.enter_confirm_delete(); + Some(SecretViewerAction::None) + } + KeyAction::Back => Some(SecretViewerAction::Back), + _ => None, + } + } + /// Decrypt the current secret, render it as an editable document /// (metadata header + `---` separator + raw value) and ask the event /// loop to run `$EDITOR`. @@ -376,6 +385,17 @@ impl SecretViewerView { } } + /// Copy `himitsu read ` to the clipboard — the *command* that + /// would fetch this secret, not the value itself. Lets the user share + /// "how to grab this" in a PR / chat without leaking plaintext. + fn copy_ref_to_clipboard(&mut self) -> SecretViewerAction { + let cmd = format!("himitsu read {}", self.path); + match arboard::Clipboard::new().and_then(|mut c| c.set_text(cmd)) { + Ok(()) => SecretViewerAction::Copied, + Err(e) => SecretViewerAction::CopyFailed(format!("clipboard unavailable: {e}")), + } + } + fn rekey(&mut self) { match rekey::rekey_store(&self.ctx, Some(&self.path)) { Ok(n) => { @@ -658,6 +678,25 @@ const EDIT_DOC_HEADER: &str = "# himitsu edit — metadata above, secret value b "; +/// Secret-viewer keymap priority. The order encodes overlap-resolution +/// for default bindings: `Shift+R` (rekey) must beat bare `r` (reveal); +/// explicit `Shift+Y` (copy_ref) must beat shift-insensitive `y` +/// (copy_value); `back` (Esc) lands before any future `quit` overlap so +/// Esc reliably navigates rather than quitting from inside the viewer. +const VIEWER_ACTION_PRIORITY: &[KeyAction] = &[ + KeyAction::Rekey, + KeyAction::Reveal, + KeyAction::CopyRef, + KeyAction::CopyValue, + KeyAction::Edit, + KeyAction::Delete, + KeyAction::Back, +]; + +fn match_keymap_action(keymap: &KeyMap, key: &KeyEvent) -> Option { + keymap.action_for_key_in(key, VIEWER_ACTION_PRIORITY) +} + fn render_edit_doc(path: &str, decoded: &secret_value::Decoded) -> String { let expires = decoded .expires_at