@@ -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