Skip to content

Commit 28a314d

Browse files
Merge pull request #35 from Spacecraft-Software/tui-per-field-copy
feat(vault-tui): per-field reveal/copy in the detail pane (+ card CVV)
2 parents f5c6a1f + 564c35d commit 28a314d

5 files changed

Lines changed: 255 additions & 53 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

13+
- **TUI per-field reveal/copy in the detail pane.** The detail pane is now
14+
focusable (`Tab` cycles folders → items → detail): with it focused, `j`/`k`
15+
move a field cursor and `Space`/`c` reveal/copy the **selected** field — so the
16+
**card CVV** (and any non-primary field) can finally be revealed and copied,
17+
not just the item's primary field. Masked fields (card number/CVV) still fetch
18+
only on reveal. Item-list `Space`/`c` keep hitting the primary field. Cards and
19+
identities get the field cursor; logins keep their `c`/`u`/`o`/`t` keys.
20+
1321
- **TUI form scrolling + full identity editing.** The add/edit form overlay now
1422
**scrolls** (a viewport that keeps the focused row visible; the keybind footer
1523
stays fixed, with a `` cue when there's more). This retires the curated

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,13 @@ vault get me --field identity-email # also: identity-name, identity-phone, id
104104
```
105105

106106
In the TUI, selecting a card or identity shows its fields in the detail pane:
107-
card brand/expiry with the number masked (`Space` reveals it, re-masked when you
108-
navigate away) and the CVV masked; identity name/email/phone/address. `c` copies
109-
the primary field — a card's number, an identity's email (and a login's
110-
password). The number/CVV are fetched only on reveal, so no card secret enters
111-
the TUI until you ask.
107+
card holder/brand/expiry with the number and CVV masked; identity
108+
person/email/phone/address. With the item list focused, `Space` reveals the
109+
primary secret and `c` copies the primary field (card number / identity email /
110+
login password). **`Tab` into the detail pane** to navigate fields with `j`/`k`
111+
and reveal (`Space`) or copy (`c`) the **selected** field — including the card
112+
CVV. Masked values (number, CVV) are fetched only on reveal, so no card secret
113+
enters the TUI until you ask.
112114

113115
Create a card with `vault add … --type card` — the non-secret fields are flags;
114116
the number and CVV are prompted on the terminal (never argv, so they don't leak

crates/vault-tui/src/app.rs

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ pub enum Focus {
226226
Folders,
227227
/// Center item list.
228228
Items,
229+
/// Right detail pane (per-field navigation: reveal/copy the selected field).
230+
Detail,
229231
}
230232

231233
/// What the whole screen is showing: the browser, or a centered banner (locked
@@ -375,6 +377,51 @@ pub const fn primary_copy_field(cipher_type: u8) -> Option<(Field, &'static str)
375377
}
376378
}
377379

380+
/// One navigable field in the detail pane: its label, the agent selector to
381+
/// reveal/copy it, and whether it renders masked until revealed.
382+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383+
pub struct DetailField {
384+
/// Label shown in the detail pane.
385+
pub label: &'static str,
386+
/// Agent field selector for reveal/copy.
387+
pub field: Field,
388+
/// Hidden behind `MASK` until revealed (secrets).
389+
pub masked: bool,
390+
}
391+
392+
const fn df(label: &'static str, field: Field, masked: bool) -> DetailField {
393+
DetailField {
394+
label,
395+
field,
396+
masked,
397+
}
398+
}
399+
400+
/// The per-field-navigable fields the detail pane exposes for a cipher type, in
401+
/// display order. Cards and identities only — logins already have dedicated
402+
/// `c`/`u`/`o`/`t` copy keys and `Space` reveal; notes/anything else expose none.
403+
#[must_use]
404+
pub const fn detail_fields(cipher_type: u8) -> &'static [DetailField] {
405+
const CARD: &[DetailField] = &[
406+
df("Holder", Field::CardCardholder, false),
407+
df("Brand", Field::CardBrand, false),
408+
df("Number", Field::CardNumber, true),
409+
df("Exp", Field::CardExpiry, false),
410+
df("CVV", Field::CardCode, true),
411+
];
412+
const IDENTITY: &[DetailField] = &[
413+
df("Person", Field::IdentityName, false),
414+
df("Email", Field::IdentityEmail, false),
415+
df("Phone", Field::IdentityPhone, false),
416+
df("Address", Field::IdentityAddress, false),
417+
];
418+
match cipher_type {
419+
3 => CARD,
420+
4 => IDENTITY,
421+
_ => &[],
422+
}
423+
}
424+
378425
/// belongs to, plus the plaintext. The value is zeroised on drop and never
379426
/// surfaced by `Debug`, so an `App` dump can't leak it.
380427
#[derive(Clone)]
@@ -1094,6 +1141,10 @@ pub struct App {
10941141
/// Cached non-sensitive fields for the selected card/identity, `Some` while
10951142
/// such an item is selected (populated on select; `None` for logins/notes).
10961143
pub detail: Option<DetailView>,
1144+
/// Cursor into [`detail_fields`] for the selected item, used while the
1145+
/// detail pane is focused (per-field reveal/copy). Reset when the selection
1146+
/// changes or the detail pane is (re-)focused.
1147+
pub detail_field: usize,
10971148
/// When a pending OSC52 fallback copy should be cleared from the terminal
10981149
/// clipboard. The TUI owns this timer (the agent can't — it has no
10991150
/// terminal); the run loop races it against input.
@@ -1138,6 +1189,7 @@ impl App {
11381189
confirm_delete: None,
11391190
revealed: None,
11401191
detail: None,
1192+
detail_field: 0,
11411193
osc52_clear_at: None,
11421194
toast: None,
11431195
unlock: None,
@@ -1175,6 +1227,7 @@ impl App {
11751227
confirm_delete: None,
11761228
revealed: None,
11771229
detail: None,
1230+
detail_field: 0,
11781231
osc52_clear_at: None,
11791232
toast: None,
11801233
unlock: None,
@@ -1258,12 +1311,20 @@ impl App {
12581311
if self.folder_sel + 1 < self.folders.len() {
12591312
self.folder_sel += 1;
12601313
self.item_sel = 0;
1314+
self.detail_field = 0;
12611315
}
12621316
}
12631317
Focus::Items => {
12641318
let len = self.filtered().len();
12651319
if len > 0 && self.item_sel + 1 < len {
12661320
self.item_sel += 1;
1321+
self.detail_field = 0;
1322+
}
1323+
}
1324+
Focus::Detail => {
1325+
let n = self.detail_field_count();
1326+
if n > 0 && self.detail_field + 1 < n {
1327+
self.detail_field += 1;
12671328
}
12681329
}
12691330
}
@@ -1277,9 +1338,17 @@ impl App {
12771338
if self.folder_sel > 0 {
12781339
self.folder_sel -= 1;
12791340
self.item_sel = 0;
1341+
self.detail_field = 0;
1342+
}
1343+
}
1344+
Focus::Items => {
1345+
let prev = self.item_sel;
1346+
self.item_sel = self.item_sel.saturating_sub(1);
1347+
if self.item_sel != prev {
1348+
self.detail_field = 0;
12801349
}
12811350
}
1282-
Focus::Items => self.item_sel = self.item_sel.saturating_sub(1),
1351+
Focus::Detail => self.detail_field = self.detail_field.saturating_sub(1),
12831352
}
12841353
}
12851354

@@ -1290,8 +1359,13 @@ impl App {
12901359
Focus::Folders => {
12911360
self.folder_sel = 0;
12921361
self.item_sel = 0;
1362+
self.detail_field = 0;
1363+
}
1364+
Focus::Items => {
1365+
self.item_sel = 0;
1366+
self.detail_field = 0;
12931367
}
1294-
Focus::Items => self.item_sel = 0,
1368+
Focus::Detail => self.detail_field = 0,
12951369
}
12961370
}
12971371

@@ -1302,8 +1376,13 @@ impl App {
13021376
Focus::Folders => {
13031377
self.folder_sel = self.folders.len().saturating_sub(1);
13041378
self.item_sel = 0;
1379+
self.detail_field = 0;
1380+
}
1381+
Focus::Items => {
1382+
self.item_sel = self.filtered().len().saturating_sub(1);
1383+
self.detail_field = 0;
13051384
}
1306-
Focus::Items => self.item_sel = self.filtered().len().saturating_sub(1),
1385+
Focus::Detail => self.detail_field = self.detail_field_count().saturating_sub(1),
13071386
}
13081387
}
13091388

@@ -1339,13 +1418,18 @@ impl App {
13391418
self.pending_g = false;
13401419
}
13411420

1342-
/// Toggle focus between the folder pane and the item list.
1421+
/// Cycle focus folders → items → detail → folders.
13431422
pub fn focus_next(&mut self) {
13441423
self.revealed = None;
13451424
self.focus = match self.focus {
13461425
Focus::Folders => Focus::Items,
1347-
Focus::Items => Focus::Folders,
1426+
Focus::Items => Focus::Detail,
1427+
Focus::Detail => Focus::Folders,
13481428
};
1429+
// Start at the first field each time the detail pane takes focus.
1430+
if matches!(self.focus, Focus::Detail) {
1431+
self.detail_field = 0;
1432+
}
13491433
}
13501434

13511435
/// Whether the item list currently has focus — the gate for copy / reveal
@@ -1355,6 +1439,25 @@ impl App {
13551439
matches!(self.focus, Focus::Items)
13561440
}
13571441

1442+
/// Whether the detail pane currently has focus (per-field reveal/copy).
1443+
#[must_use]
1444+
pub const fn detail_focused(&self) -> bool {
1445+
matches!(self.focus, Focus::Detail)
1446+
}
1447+
1448+
/// Number of per-field-navigable fields for the selected item (0 if none).
1449+
fn detail_field_count(&self) -> usize {
1450+
self.selected_entry()
1451+
.map_or(0, |e| detail_fields(e.cipher_type).len())
1452+
}
1453+
1454+
/// The detail field the cursor currently points at, if any.
1455+
#[must_use]
1456+
pub fn selected_detail_field(&self) -> Option<DetailField> {
1457+
let e = self.selected_entry()?;
1458+
detail_fields(e.cipher_type).get(self.detail_field).copied()
1459+
}
1460+
13581461
/// Whether `field` of the item with `entry_id` is currently revealed.
13591462
#[must_use]
13601463
pub fn is_revealed(&self, entry_id: &str, field: Field) -> bool {
@@ -2075,15 +2178,60 @@ mod tests {
20752178
}
20762179

20772180
#[test]
2078-
fn focus_next_cycles_folders_and_items() {
2181+
fn focus_next_cycles_folders_items_detail() {
20792182
let mut app = App::browsing(status(), vec![entry("a", None)]);
20802183
assert_eq!(app.focus, Focus::Items);
20812184
app.focus_next();
2185+
assert_eq!(app.focus, Focus::Detail);
2186+
app.focus_next();
20822187
assert_eq!(app.focus, Focus::Folders);
20832188
app.focus_next();
20842189
assert_eq!(app.focus, Focus::Items);
20852190
}
20862191

2192+
#[test]
2193+
fn detail_fields_cover_card_and_identity() {
2194+
assert!(detail_fields(1).is_empty(), "login uses its own copy keys");
2195+
assert!(detail_fields(2).is_empty(), "note has no per-field nav");
2196+
let card: Vec<&str> = detail_fields(3).iter().map(|f| f.label).collect();
2197+
assert_eq!(card, ["Holder", "Brand", "Number", "Exp", "CVV"]);
2198+
let cvv = detail_fields(3)
2199+
.iter()
2200+
.find(|f| f.label == "CVV")
2201+
.expect("card has a CVV field");
2202+
assert_eq!(cvv.field, Field::CardCode);
2203+
assert!(cvv.masked, "CVV is masked until revealed");
2204+
assert_eq!(detail_fields(4).len(), 4);
2205+
}
2206+
2207+
#[test]
2208+
fn detail_cursor_navigates_and_clamps() {
2209+
let mut app = App::browsing(status(), vec![card_entry()]);
2210+
app.focus = Focus::Detail;
2211+
assert_eq!(app.selected_detail_field().map(|f| f.label), Some("Holder"));
2212+
for _ in 0..10 {
2213+
app.move_down(); // clamps at the last card field
2214+
}
2215+
assert_eq!(app.detail_field, 4);
2216+
assert_eq!(app.selected_detail_field().map(|f| f.label), Some("CVV"));
2217+
for _ in 0..10 {
2218+
app.move_up();
2219+
}
2220+
assert_eq!(app.detail_field, 0);
2221+
}
2222+
2223+
#[test]
2224+
fn detail_field_resets_when_item_selection_changes() {
2225+
let mut app = App::browsing(status(), vec![card_entry(), entry("b", None)]);
2226+
app.focus = Focus::Detail;
2227+
app.move_down();
2228+
assert_eq!(app.detail_field, 1);
2229+
// Moving the item selection re-zeroes the field cursor.
2230+
app.focus = Focus::Items;
2231+
app.move_down();
2232+
assert_eq!(app.detail_field, 0);
2233+
}
2234+
20872235
#[test]
20882236
fn changing_folder_resets_item_selection() {
20892237
let entries = vec![

0 commit comments

Comments
 (0)