From 7656233c1d58fd3e82eb3dcd3efab851072a4388 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Fri, 12 Jun 2026 11:12:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(vault):=20M5=20slice=203=20=E2=80=94=20TUI?= =?UTF-8?q?=20search,=20generator=20overlay,=20command=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the three previewed keys live: `/` filters the item list as you type, `g` opens a password-generator overlay, and `:` opens a vim-style command line. Input is now routed through an InputMode (Normal / Search / Command / Generate) so each surface owns its keys. Search: live case-insensitive substring match on name + username, composed on top of the folder filter. Enter accepts (query persists, surfaced in the `Items (n) /query` pane title), Esc in search mode drops the query, Esc in normal mode peels an active filter back before quitting. Every query edit re-anchors the selection and re-masks any revealed secret; arrows still move the selection mid-search. Generator: centered overlay over the browser driven by vault-core's generate_password (same engine as `vault generate`). g/r regenerate, +/- adjust length (clamped 8–128, Bitwarden's ceiling), s toggles symbols, c copies, Esc closes. The password is held in a GeneratorState (zeroised on drop, Debug-redacted). Protocol: new Request::CopyText { text, clear_after_secs } carries the generated password to the agent's clipboard — the value rides the local UDS once (exactly like Unlock's password) and reuses the existing clipboard_set + 30s auto-clear machinery. Requires an unlocked agent; headless --no-default-features builds decline it cleanly. Command line: tiny vocabulary — q/quit, r/refresh, sync, lock — with unknown commands toasting the list. The status bar echoes the line being edited (/query▌ / :cmd▌) ahead of toasts and hints. Tests: search/compose/re-anchor, command-buffer, and generator units (defaults, regenerate, clamp, symbols, Debug-redaction) plus TestBackend smokes for the query title/status echo, command echo, and the overlay; the agent's locked-session test now covers CopyText-while-locked. No new dependencies. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 33 +++ crates/vault-agent/src/server.rs | 51 ++++ crates/vault-ipc/src/proto.rs | 16 ++ crates/vault-tui/src/app.rs | 404 ++++++++++++++++++++++++++++++- crates/vault-tui/src/main.rs | 146 ++++++++++- crates/vault-tui/src/ui.rs | 131 +++++++++- 6 files changed, 765 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d191e..879cedf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,39 @@ range may break in any release. ### Added +- **M5 (slice 3) — TUI search, generator overlay, and `:` command line.** The + three previewed keys go live: `/` filters the item list as you type, `g` + opens a password-generator overlay, and `:` opens a small vim-style command + line. + - **Search (`/`).** Live, case-insensitive substring match on item name and + username, composed on top of the active folder filter. Enter accepts the + query (filter stays, shown in the `Items (n) /query` pane title), Esc in + search mode drops it, and Esc in normal mode peels an active filter back + before quitting. Every query edit re-anchors the selection and re-masks any + revealed secret. Arrow keys still move the selection mid-search. + - **Generator (`g`).** A centered overlay over the browser showing a freshly + generated password (`vault-core`'s `generate_password`, same engine as + `vault generate`): `g`/`r` regenerate, `+`/`-` adjust length (clamped + 8–128, Bitwarden's ceiling), `s` toggles symbols, `c` copies, `Esc` closes. + The password lives in a `GeneratorState` (zeroised on drop, redacted in + `Debug`). + - **Copying a generated password** uses a new `Request::CopyText { text, + clear_after_secs }`: the value rides the local UDS once (exactly like + `Unlock`'s password already does), and the agent writes it to its own + clipboard with the same 30-second auto-clear machinery as `Request::Copy`. + Requires an unlocked agent; headless (`--no-default-features`) builds + decline it cleanly. + - **Command line (`:`).** Deliberately tiny vocabulary: `q`/`quit`, + `r`/`refresh`, `sync` (agent re-pulls `/sync`, list reloads), `lock` (agent + drops keys, screen flips to the Locked banner). Unknown commands toast the + vocabulary. The status bar echoes the line being edited (`/query▌` / + `:cmd▌`) ahead of toasts and hints. + - Tests: `vault-tui` adds search/compose/re-anchor, command-buffer, and + generator (defaults, regenerate, clamp, symbols, `Debug`-redaction) units + plus `TestBackend` smokes for the query title/status echo, command echo, + and generator overlay; `vault-agent`'s locked-session test now covers + `CopyText`-while-locked. No new dependencies. + - **M5 (slice 2) — TUI reveal + clipboard copy.** The detail pane is no longer secret-free: `Space` reveals the selected login's password on demand and `c` / `u` / `o` copy the password / username / URI to the clipboard, with a diff --git a/crates/vault-agent/src/server.rs b/crates/vault-agent/src/server.rs index ce62f84..8f4d6b7 100644 --- a/crates/vault-agent/src/server.rs +++ b/crates/vault-agent/src/server.rs @@ -169,6 +169,43 @@ async fn dispatch(req: Request, state: &Arc>) -> Response { Request::Copy { .. } => Response::Error(vault_ipc::proto::Error::Internal( "clipboard support not compiled in".to_owned(), )), + #[cfg(feature = "clipboard")] + Request::CopyText { + text, + clear_after_secs, + } => { + // The wrapper zeroises the inbound bytes no matter which way the + // arm exits; `value` is the copy the clear task carries. + let text = zeroize::Zeroizing::new(text); + let mut s = state.lock().await; + let outcome = if s.is_unlocked() { + std::str::from_utf8(&text) + .map_err(|e| { + vault_ipc::proto::Error::Internal(format!( + "copy text is not valid UTF-8: {e}" + )) + }) + .and_then(|v| { + let value = zeroize::Zeroizing::new(v.to_owned()); + s.clipboard_set(&value).map(|()| value) + }) + } else { + Err(vault_ipc::proto::Error::Locked) + }; + s.touch(); + drop(s); + match outcome { + Ok(value) => { + schedule_clipboard_clear(state.clone(), value, clear_after_secs); + Response::Ok + } + Err(e) => Response::Error(e), + } + } + #[cfg(not(feature = "clipboard"))] + Request::CopyText { .. } => Response::Error(vault_ipc::proto::Error::Internal( + "clipboard support not compiled in".to_owned(), + )), Request::Remove { selector } => { // Hold the agent mutex across the network call. Vault is // single-user / single-agent, so request concurrency is low and @@ -364,6 +401,20 @@ mod tests { let resp: Response = read_frame(&mut rd).await.unwrap(); assert!(matches!(resp, Response::Error(_))); + // CopyText-while-locked must likewise decline before touching the + // clipboard (Locked with the feature, "not compiled in" without). + write_frame( + &mut wr, + &Request::CopyText { + text: b"generated-password".to_vec(), + clear_after_secs: Some(0), + }, + ) + .await + .unwrap(); + let resp: Response = read_frame(&mut rd).await.unwrap(); + assert!(matches!(resp, Response::Error(_))); + write_frame(&mut wr, &Request::Quit).await.unwrap(); let resp: Response = read_frame(&mut rd).await.unwrap(); assert!(matches!(resp, Response::Ok)); diff --git a/crates/vault-ipc/src/proto.rs b/crates/vault-ipc/src/proto.rs index 90e87f8..ed9fd4a 100644 --- a/crates/vault-ipc/src/proto.rs +++ b/crates/vault-ipc/src/proto.rs @@ -83,6 +83,22 @@ pub enum Request { clear_after_secs: Option, }, + /// Place caller-supplied text on the agent's clipboard with the same + /// timed auto-clear as [`Request::Copy`]. + /// + /// This is the copy path for values that don't live in the vault — e.g. a + /// freshly generated password the user hasn't saved yet. The plaintext + /// rides the local UDS exactly like `Unlock`'s password does, and the + /// agent zeroises it after the clipboard write. Requires an unlocked + /// agent, mirroring every other data verb. + CopyText { + /// The value to copy; secret, wiped by the agent after use. + text: Vec, + /// Seconds before the agent clears the clipboard; `None` uses the + /// agent default, `Some(0)` disables auto-clear. + clear_after_secs: Option, + }, + /// Soft-delete a cipher by id or decrypted name. /// /// `selector` is matched against `Cipher.id` first (exact); if no id diff --git a/crates/vault-tui/src/app.rs b/crates/vault-tui/src/app.rs index dc97126..597498e 100644 --- a/crates/vault-tui/src/app.rs +++ b/crates/vault-tui/src/app.rs @@ -7,15 +7,40 @@ //! performs the agent I/O for reveal/copy), and `ui.rs` renders from this state. //! The state holds the non-secret [`ListEntry`] metadata plus, transiently, a //! single revealed secret ([`RevealedSecret`], zeroised on drop and re-masked -//! on any navigation). +//! on any navigation), a live search query, a pending `:` command line, and +//! the password-generator overlay ([`GeneratorState`], zeroised on drop). use std::collections::BTreeSet; use std::fmt; use zeroize::Zeroizing; +use vault_core::{GenerateOptions, generate_password}; use vault_ipc::proto::{Field, ListEntry, Status}; +/// Smallest password the generator overlay will produce. Comfortably above the +/// four-character floor `generate_password` needs to seat one character from +/// every enabled class, and below it a generated password isn't worth copying. +const GEN_MIN_LEN: usize = 8; + +/// Largest password the generator overlay will produce — matches Bitwarden's +/// own generator ceiling so saved values round-trip everywhere. +const GEN_MAX_LEN: usize = 128; + +/// What keyboard input currently drives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InputMode { + /// Normal browsing — keys are commands. + #[default] + Normal, + /// `/` pressed — keys edit the live search query. + Search, + /// `:` pressed — keys edit a pending command line. + Command, + /// `g` pressed — the password-generator overlay is open. + Generate, +} + /// Which pane currently takes navigation keys. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Focus { @@ -102,6 +127,34 @@ impl fmt::Debug for RevealedSecret { } } +/// The password-generator overlay's state: the options in force and the +/// password generated under them. The password is zeroised on drop and never +/// surfaced by `Debug`. +#[derive(Clone)] +pub struct GeneratorState { + /// Options the current password was generated under. + pub opts: GenerateOptions, + /// The freshly generated password. + password: Zeroizing, +} + +impl GeneratorState { + /// The generated password, for display and copy. + #[must_use] + pub fn password(&self) -> &str { + &self.password + } +} + +impl fmt::Debug for GeneratorState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GeneratorState") + .field("opts", &self.opts) + .field("password", &"") + .finish() + } +} + /// Top-level TUI state. #[derive(Clone, Debug)] pub struct App { @@ -119,6 +172,16 @@ pub struct App { pub item_sel: usize, /// Which pane has focus. pub focus: Focus, + /// What keyboard input currently drives. + pub mode: InputMode, + /// Live search query, applied on top of the folder filter. Persists after + /// the user accepts it with Enter; cleared by Esc. + pub search: String, + /// Pending `:` command-line buffer, only meaningful in + /// [`InputMode::Command`]. + pub command: String, + /// Generator overlay state, `Some` while [`InputMode::Generate`] is open. + pub generator: Option, /// Secret currently revealed in the detail pane, if any. pub revealed: Option, /// Transient status-bar message (copy feedback / errors). Cleared on the @@ -141,6 +204,10 @@ impl App { folder_sel: 0, item_sel: 0, focus: Focus::Items, + mode: InputMode::Normal, + search: String::new(), + command: String::new(), + generator: None, revealed: None, toast: None, should_quit: false, @@ -165,6 +232,10 @@ impl App { folder_sel: 0, item_sel: 0, focus: Focus::Items, + mode: InputMode::Normal, + search: String::new(), + command: String::new(), + generator: None, revealed: None, toast: None, should_quit: false, @@ -179,10 +250,12 @@ impl App { .map_or(&FolderFilter::All, |f| &f.filter) } - /// Items visible under the selected folder, in `entries` order. + /// Items visible under the selected folder — and, when a search query is + /// active, matching it — in `entries` order. #[must_use] pub fn filtered(&self) -> Vec<&ListEntry> { let filter = self.active_filter(); + let query = self.search.to_lowercase(); self.entries .iter() .filter(|e| match filter { @@ -190,6 +263,7 @@ impl App { FolderFilter::Unfiled => e.folder.is_none(), FolderFilter::Named(n) => e.folder.as_deref() == Some(n.as_str()), }) + .filter(|e| query.is_empty() || matches_search(e, &query)) .collect() } @@ -282,6 +356,150 @@ impl App { pub const fn quit(&mut self) { self.should_quit = true; } + + // --- search --------------------------------------------------------- + + /// Enter search mode, editing the current query in place. + pub const fn open_search(&mut self) { + self.mode = InputMode::Search; + } + + /// Append a character to the live search query. + pub fn search_push(&mut self, c: char) { + self.search.push(c); + self.on_query_changed(); + } + + /// Delete the last character of the live search query. + pub fn search_pop(&mut self) { + self.search.pop(); + self.on_query_changed(); + } + + /// Accept the query as-is and return to normal mode; the filter stays + /// applied until cleared. + pub const fn accept_search(&mut self) { + self.mode = InputMode::Normal; + } + + /// Abandon search mode and drop the query entirely. + pub fn cancel_search(&mut self) { + self.mode = InputMode::Normal; + self.clear_search(); + } + + /// Drop any active search query (also reachable from normal mode via Esc). + pub fn clear_search(&mut self) { + self.search.clear(); + self.on_query_changed(); + } + + /// Whether a search query is currently narrowing the item list. + #[must_use] + pub const fn has_search(&self) -> bool { + !self.search.is_empty() + } + + /// Every query edit re-anchors the selection at the top of the (new) + /// filtered list and re-masks — the selected row just changed identity. + fn on_query_changed(&mut self) { + self.item_sel = 0; + self.revealed = None; + } + + // --- command line ---------------------------------------------------- + + /// Enter command mode with an empty buffer. + pub fn open_command(&mut self) { + self.command.clear(); + self.mode = InputMode::Command; + } + + /// Append a character to the pending command. + pub fn command_push(&mut self, c: char) { + self.command.push(c); + } + + /// Delete the last character of the pending command. + pub fn command_pop(&mut self) { + self.command.pop(); + } + + /// Abandon the command line. + pub fn cancel_command(&mut self) { + self.command.clear(); + self.mode = InputMode::Normal; + } + + /// Take the pending command for execution, leaving normal mode behind. + #[must_use] + pub fn take_command(&mut self) -> String { + self.mode = InputMode::Normal; + std::mem::take(&mut self.command) + } + + // --- generator overlay ----------------------------------------------- + + /// Open the generator overlay with a fresh default-options password. + pub fn open_generator(&mut self) { + let opts = GenerateOptions::default(); + match generate_password(&opts) { + Ok(password) => { + self.generator = Some(GeneratorState { opts, password }); + self.mode = InputMode::Generate; + } + Err(e) => self.set_toast(format!("generate failed: {e}")), + } + } + + /// Close the generator overlay, dropping (and zeroising) its password. + pub fn close_generator(&mut self) { + self.generator = None; + self.mode = InputMode::Normal; + } + + /// Replace the overlay's password with a fresh one under the same options. + pub fn regenerate(&mut self) { + if let Some(g) = self.generator.as_mut() { + match generate_password(&g.opts) { + Ok(password) => g.password = password, + Err(e) => self.toast = Some(format!("generate failed: {e}")), + } + } + } + + /// Grow or shrink the generated length by `delta`, clamped to + /// [`GEN_MIN_LEN`]..=[`GEN_MAX_LEN`], regenerating on change. + pub fn gen_adjust_length(&mut self, delta: isize) { + if let Some(g) = self.generator.as_mut() { + let len = g + .opts + .length + .saturating_add_signed(delta) + .clamp(GEN_MIN_LEN, GEN_MAX_LEN); + if len != g.opts.length { + g.opts.length = len; + self.regenerate(); + } + } + } + + /// Toggle the symbol class on the generator, regenerating immediately. + pub fn gen_toggle_symbols(&mut self) { + if let Some(g) = self.generator.as_mut() { + g.opts.symbols = !g.opts.symbols; + self.regenerate(); + } + } +} + +/// Case-insensitive substring match of `query` (already lower-cased) against +/// an entry's name and username — the two columns the item list displays. +fn matches_search(e: &ListEntry, query: &str) -> bool { + e.name.to_lowercase().contains(query) + || e.username + .as_deref() + .is_some_and(|u| u.to_lowercase().contains(query)) } /// Build the folder pane from a set of entries: a leading `All`, an `Unfiled` @@ -491,6 +709,188 @@ mod tests { assert!(!app.items_focused()); } + #[test] + fn search_matches_name_and_username_case_insensitively() { + let entries = vec![ + entry("GitHub", None), + entry("gitlab", None), + entry("bank", None), + ]; + let mut app = App::browsing(status(), entries); + app.open_search(); + for c in "git".chars() { + app.search_push(c); + } + let names: Vec<&str> = app.filtered().iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["GitHub", "gitlab"]); + + // Username matches too: "bank@example.org" contains "bank@". + app.cancel_search(); + app.open_search(); + for c in "BANK@".chars() { + app.search_push(c); + } + let names: Vec<&str> = app.filtered().iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["bank"]); + } + + #[test] + fn search_composes_with_folder_filter() { + let entries = vec![ + entry("github", Some("Work")), + entry("gitlab", None), + entry("bank", None), + ]; + let mut app = App::browsing(status(), entries); + // Select Unfiled (folders: All, Unfiled, Work), then search "git". + app.focus = Focus::Folders; + app.move_down(); + assert_eq!(app.active_filter(), &FolderFilter::Unfiled); + app.open_search(); + for c in "git".chars() { + app.search_push(c); + } + let names: Vec<&str> = app.filtered().iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["gitlab"], "search must apply within the folder"); + } + + #[test] + fn query_edits_reset_selection_and_remask() { + let entries = vec![entry("aa", None), entry("ab", None), entry("zz", None)]; + let mut app = App::browsing(status(), entries); + app.move_down(); // item_sel -> 1 + app.reveal(RevealedSecret::new( + "id-ab".to_owned(), + Field::Password, + "secret".to_owned(), + )); + app.open_search(); + app.search_push('a'); + assert_eq!(app.item_sel, 0, "query edit must re-anchor the selection"); + assert!(app.revealed.is_none(), "query edit must re-mask"); + app.search_pop(); + assert_eq!(app.item_sel, 0); + } + + #[test] + fn accept_keeps_query_cancel_and_clear_drop_it() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + app.open_search(); + app.search_push('a'); + app.accept_search(); + assert_eq!(app.mode, InputMode::Normal); + assert!(app.has_search(), "Enter must keep the filter applied"); + + app.clear_search(); // Esc from normal mode + assert!(!app.has_search()); + + app.open_search(); + app.search_push('a'); + app.cancel_search(); // Esc from search mode + assert_eq!(app.mode, InputMode::Normal); + assert!(!app.has_search(), "Esc must drop the query"); + } + + #[test] + fn command_buffer_take_and_cancel() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + app.open_command(); + assert_eq!(app.mode, InputMode::Command); + for c in "syncx".chars() { + app.command_push(c); + } + app.command_pop(); + assert_eq!(app.command, "sync"); + assert_eq!(app.take_command(), "sync"); + assert_eq!(app.mode, InputMode::Normal); + assert!(app.command.is_empty()); + + app.open_command(); + app.command_push('q'); + app.cancel_command(); + assert_eq!(app.mode, InputMode::Normal); + assert!(app.command.is_empty()); + } + + #[test] + fn generator_opens_with_defaults_and_regenerates() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + app.open_generator(); + assert_eq!(app.mode, InputMode::Generate); + let first = app + .generator + .as_ref() + .map(|g| g.password().to_owned()) + .expect("generator open"); + assert_eq!(first.chars().count(), 20, "default length is 20"); + + app.regenerate(); + let second = app + .generator + .as_ref() + .map(|g| g.password().to_owned()) + .expect("generator still open"); + // 62^20 keyspace — a collision here means the RNG is broken. + assert_ne!(first, second, "regenerate must draw a fresh password"); + + app.close_generator(); + assert!(app.generator.is_none()); + assert_eq!(app.mode, InputMode::Normal); + } + + #[test] + fn generator_length_adjusts_and_clamps() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + app.open_generator(); + app.gen_adjust_length(1); + assert_eq!(app.generator.as_ref().map(|g| g.opts.length), Some(21)); + assert_eq!( + app.generator.as_ref().map(|g| g.password().chars().count()), + Some(21) + ); + app.gen_adjust_length(-1000); + assert_eq!( + app.generator.as_ref().map(|g| g.opts.length), + Some(GEN_MIN_LEN), + "length clamps at the floor" + ); + app.gen_adjust_length(1000); + assert_eq!( + app.generator.as_ref().map(|g| g.opts.length), + Some(GEN_MAX_LEN), + "length clamps at the ceiling" + ); + } + + #[test] + fn generator_symbols_toggle_regenerates() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + app.open_generator(); + assert_eq!(app.generator.as_ref().map(|g| g.opts.symbols), Some(false)); + app.gen_toggle_symbols(); + let g = app.generator.as_ref().expect("generator open"); + assert!(g.opts.symbols); + assert!( + g.password().chars().any(|c| "!@#$%^&*".contains(c)), + "an enabled class is guaranteed at least one character" + ); + } + + #[test] + fn generator_debug_redacts_password() { + let mut app = App::browsing(status(), vec![entry("a", None)]); + app.open_generator(); + let g = app.generator.as_ref().expect("generator open"); + let pw = g.password().to_owned(); + let rendered = format!("{g:?}"); + assert!(rendered.contains("GeneratorState")); + assert!(rendered.contains("")); + assert!( + !rendered.contains(&pw), + "Debug leaked the generated password: {rendered}" + ); + } + #[test] fn revealed_secret_debug_redacts_plaintext() { let secret = RevealedSecret::new( diff --git a/crates/vault-tui/src/main.rs b/crates/vault-tui/src/main.rs index 86259c0..4c8494c 100644 --- a/crates/vault-tui/src/main.rs +++ b/crates/vault-tui/src/main.rs @@ -5,9 +5,13 @@ //! A cruxpass-style three-pane browser over the agent. It is just another UDS //! client (the user key never crosses into it) and drives `Request::Status` + //! `Request::List` for browsing, `Request::Get` for reveal-on-demand, and -//! `Request::Copy` for clipboard copies (the secret stays in the agent on that -//! path). Search / generate / editing land in later slices. Requires a -//! pre-unlocked agent; a locked or absent agent shows a centered banner. +//! `Request::Copy` / `Request::CopyText` for clipboard copies (the secret +//! stays in the agent on the `Copy` path; `CopyText` carries the locally +//! generated password the other way, like `Unlock`'s does). `/` filters the +//! item list live, `g` opens the password-generator overlay, and `:` opens a +//! small command line (`q` / `r` / `sync` / `lock`). Item editing lands in a +//! later slice. Requires a pre-unlocked agent; a locked or absent agent shows +//! a centered banner. #![forbid(unsafe_code)] @@ -32,7 +36,7 @@ use tokio::sync::mpsc; use vault_ipc::proto::{Field, Request, Response}; use vault_ipc::{default_socket_path, sanitize_socket_path}; -use app::{App, RevealedSecret}; +use app::{App, InputMode, RevealedSecret}; const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -167,21 +171,40 @@ async fn load_app(socket: &Path) -> App { } } -/// Translate one key press into an [`App`] action. Non-press events (key release -/// on Windows) are ignored; unbound keys are no-ops. +/// Translate one key press into an [`App`] action, routed by input mode. +/// Non-press events (key release on Windows) are ignored; unbound keys are +/// no-ops. async fn handle_key(state: &mut App, key: KeyEvent, socket: &Path) { if key.kind != KeyEventKind::Press { return; } - // Ctrl+C always quits, regardless of which character key carries it. + // Ctrl+C always quits, regardless of mode or which key carries it. if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { state.quit(); return; } // Each key press supersedes the previous transient message. state.clear_toast(); + match state.mode { + InputMode::Normal => handle_normal_key(state, key, socket).await, + InputMode::Search => handle_search_key(state, key), + InputMode::Command => handle_command_key(state, key, socket).await, + InputMode::Generate => handle_generate_key(state, key, socket).await, + } +} + +/// Normal-mode keys — navigation, reveal/copy, and mode entry. +async fn handle_normal_key(state: &mut App, key: KeyEvent, socket: &Path) { match key.code { - KeyCode::Char('q') | KeyCode::Esc => state.quit(), + KeyCode::Char('q') => state.quit(), + // Esc peels back one layer: an active search filter first, then quit. + KeyCode::Esc => { + if state.has_search() { + state.clear_search(); + } else { + state.quit(); + } + } KeyCode::Char('j') | KeyCode::Down => state.move_down(), KeyCode::Char('k') | KeyCode::Up => state.move_up(), KeyCode::Tab | KeyCode::Left | KeyCode::Right | KeyCode::Char('h' | 'l') => { @@ -192,10 +215,117 @@ async fn handle_key(state: &mut App, key: KeyEvent, socket: &Path) { KeyCode::Char('c') => copy_field(state, socket, Field::Password, "password").await, KeyCode::Char('u') => copy_field(state, socket, Field::Username, "username").await, KeyCode::Char('o') => copy_field(state, socket, Field::Uri, "URI").await, + KeyCode::Char('/') => state.open_search(), + KeyCode::Char(':') => state.open_command(), + KeyCode::Char('g') => state.open_generator(), + _ => {} + } +} + +/// Search-mode keys — live query editing; arrows still move the selection so +/// the user can pick a hit without leaving the mode. +fn handle_search_key(state: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc => state.cancel_search(), + KeyCode::Enter => state.accept_search(), + KeyCode::Backspace => state.search_pop(), + KeyCode::Down => state.move_down(), + KeyCode::Up => state.move_up(), + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + state.search_push(c); + } + _ => {} + } +} + +/// Command-mode keys — edit the `:` buffer; Enter executes it. +async fn handle_command_key(state: &mut App, key: KeyEvent, socket: &Path) { + match key.code { + KeyCode::Esc => state.cancel_command(), + KeyCode::Backspace => state.command_pop(), + KeyCode::Enter => { + let cmd = state.take_command(); + execute_command(state, socket, &cmd).await; + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + state.command_push(c); + } + _ => {} + } +} + +/// Generator-overlay keys. +async fn handle_generate_key(state: &mut App, key: KeyEvent, socket: &Path) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => state.close_generator(), + KeyCode::Char('g' | 'r') => state.regenerate(), + KeyCode::Char('+' | '=') => state.gen_adjust_length(1), + KeyCode::Char('-') => state.gen_adjust_length(-1), + KeyCode::Char('s') => state.gen_toggle_symbols(), + KeyCode::Char('c') => copy_generated(state, socket).await, _ => {} } } +/// Run one `:` command. The vocabulary is deliberately tiny — anything that +/// needs arguments or confirmation belongs to a dedicated key or later slice. +async fn execute_command(state: &mut App, socket: &Path, cmd: &str) { + match cmd.trim() { + "" => {} + "q" | "quit" => state.quit(), + "r" | "refresh" => { + *state = load_app(socket).await; + state.set_toast("refreshed"); + } + "sync" => match client::request(socket, &Request::Sync).await { + Ok(Response::Status(_)) => { + *state = load_app(socket).await; + state.set_toast("synced"); + } + Ok(Response::Error(e)) => state.set_toast(format!("sync failed: {e}")), + Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), + Err(e) => state.set_toast(e.to_string()), + }, + "lock" => { + match client::request(socket, &Request::Lock).await { + // Reload so the screen flips to the Locked banner. + Ok(Response::Ok) => *state = load_app(socket).await, + Ok(Response::Error(e)) => state.set_toast(format!("lock failed: {e}")), + Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), + Err(e) => state.set_toast(e.to_string()), + } + } + other => state.set_toast(format!("unknown command: {other} (q · r · sync · lock)")), + } +} + +/// Ask the agent to put the overlay's generated password on the clipboard via +/// `CopyText`, with the same timed auto-clear as item copies. The value rides +/// the local UDS once, exactly like `Unlock`'s password does. +async fn copy_generated(state: &mut App, socket: &Path) { + let Some(text) = state + .generator + .as_ref() + .map(|g| g.password().as_bytes().to_vec()) + else { + return; + }; + let req = Request::CopyText { + text, + clear_after_secs: Some(COPY_CLEAR_SECS), + }; + match client::request(socket, &req).await { + Ok(Response::Ok) => { + state.set_toast(format!( + "copied generated password · clears in {COPY_CLEAR_SECS}s" + )); + } + Ok(Response::Error(e)) => state.set_toast(format!("copy failed: {e}")), + Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), + Err(e) => state.set_toast(e.to_string()), + } +} + /// Toggle reveal of the selected item's password in the detail pane. The first /// press fetches the plaintext from the agent (id-targeted, so duplicate names /// can't mislead it); the second re-masks. No-op unless the item list is diff --git a/crates/vault-tui/src/ui.rs b/crates/vault-tui/src/ui.rs index 52eefc2..f7ce965 100644 --- a/crates/vault-tui/src/ui.rs +++ b/crates/vault-tui/src/ui.rs @@ -12,7 +12,7 @@ use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wra use vault_ipc::proto::Field; use vault_theme::steelbore; -use crate::app::{App, Focus, Screen}; +use crate::app::{App, Focus, InputMode, Screen}; /// Mask shown for a secret field that has not been revealed. const MASK: &str = "••••••••"; @@ -59,6 +59,9 @@ pub fn render(frame: &mut Frame, app: &App) { Screen::Message { title, body: text } => render_message(frame, body, title, text), Screen::Browsing => render_browser(frame, app, body), } + if app.mode == InputMode::Generate { + render_generator(frame, app, body); + } render_status_bar(frame, app, status_bar); } @@ -123,7 +126,13 @@ fn render_items(frame: &mut Frame, app: &App, area: Rect) { ListItem::new(format!("{marker}{}", e.name)) }) .collect(); - let title = format!("Items ({})", filtered.len()); + // Surface an active search query in the pane title so a narrowed list is + // never mistaken for the full vault. + let title = if app.search.is_empty() { + format!("Items ({})", filtered.len()) + } else { + format!("Items ({}) /{}", filtered.len(), app.search) + }; let list = List::new(items) .block(pane_block(&title, app.focus == Focus::Items)) .highlight_style(highlight(app.focus == Focus::Items)); @@ -176,6 +185,59 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(para, area); } +/// Centered password-generator overlay, drawn over the browser. +fn render_generator(frame: &mut Frame, app: &App, area: Rect) { + let Some(g) = app.generator.as_ref() else { + return; + }; + let amber = hex(steelbore::MOLTEN_AMBER); + let info = hex(steelbore::INFO); + let classes = format!( + "Length {:<4} a-z {} A-Z {} 0-9 {} !@# {}", + g.opts.length, + onoff(g.opts.lowercase), + onoff(g.opts.uppercase), + onoff(g.opts.digits), + onoff(g.opts.symbols), + ); + let lines = vec![ + Line::from(""), + Line::from(Span::styled( + g.password().to_owned(), + Style::default().fg(amber).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled(classes, Style::default().fg(info))), + Line::from(""), + Line::from(Span::styled( + "g regen · +/- length · s symbols · c copy · Esc close", + Style::default() + .fg(hex(steelbore::STEEL_BLUE)) + .add_modifier(Modifier::ITALIC), + )), + ]; + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(amber)) + .title(" Generate ") + .style(Style::default().bg(hex(steelbore::VOID_NAVY))); + let overlay = centered(area, 70, 40); + // Clear whatever the browser drew underneath so the overlay reads cleanly. + frame.render_widget(ratatui::widgets::Clear, overlay); + frame.render_widget( + Paragraph::new(lines) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .block(block), + overlay, + ); +} + +/// `on` / `off` chip text for a generator class toggle. +const fn onoff(b: bool) -> &'static str { + if b { "on" } else { "off" } +} + fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { let amber = hex(steelbore::MOLTEN_AMBER); let (state_txt, state_color) = app.status.as_ref().map_or_else( @@ -214,9 +276,19 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::raw(" ")); } } - // A transient toast (copy feedback / errors) takes the trailing slot; - // otherwise show the key hints. - if let Some(toast) = app.toast.as_deref() { + // The trailing slot shows, in priority order: the line being edited + // (search / command input), a transient toast, or the key hints. + let editing = match app.mode { + InputMode::Search => Some(format!("/{}\u{258c}", app.search)), + InputMode::Command => Some(format!(":{}\u{258c}", app.command)), + InputMode::Normal | InputMode::Generate => None, + }; + if let Some(input) = editing { + spans.push(Span::styled( + input, + Style::default().fg(amber).add_modifier(Modifier::BOLD), + )); + } else if let Some(toast) = app.toast.as_deref() { spans.push(Span::styled( toast.to_owned(), Style::default() @@ -225,7 +297,7 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { )); } else { spans.push(Span::styled( - "q quit j/k move Tab pane Space reveal c/u/o copy r refresh · / g : soon", + "q quit j/k move Tab pane Space reveal c/u/o copy / search g generate : cmd r refresh", Style::default().fg(hex(steelbore::STEEL_BLUE)), )); } @@ -398,4 +470,51 @@ mod tests { "toast missing from status bar:\n{text}" ); } + + #[test] + fn search_query_renders_in_title_and_status_bar() { + let mut app = App::browsing(status(), vec![login_entry()]); + app.open_search(); + for c in "git".chars() { + app.search_push(c); + } + let text = draw(&app); + assert!( + text.contains("Items (1) /git"), + "query missing from items title:\n{text}" + ); + assert!( + text.contains("/git\u{258c}"), + "live query missing from status bar:\n{text}" + ); + } + + #[test] + fn command_line_renders_in_status_bar() { + let mut app = App::browsing(status(), vec![login_entry()]); + app.open_command(); + for c in "sync".chars() { + app.command_push(c); + } + let text = draw(&app); + assert!( + text.contains(":sync\u{258c}"), + "command line missing from status bar:\n{text}" + ); + } + + #[test] + fn generator_overlay_renders_password_and_options() { + let mut app = App::browsing(status(), vec![login_entry()]); + app.open_generator(); + let pw = app + .generator + .as_ref() + .map(|g| g.password().to_owned()) + .expect("generator open"); + let text = draw(&app); + assert!(text.contains("Generate"), "overlay title missing:\n{text}"); + assert!(text.contains(&pw), "generated password missing:\n{text}"); + assert!(text.contains("Length 20"), "options line missing:\n{text}"); + } }