use std::io::{self, Stdout, Write}; use std::process::ExitCode; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; use secrets::{SecretStore, SecretValue}; #[derive(Debug, Clone, PartialEq, Eq)] enum Mode { Normal, EditingId, EditingValue { id: String }, ConfirmDelete { id: String }, } #[derive(Clone)] struct KeysApp { ids: Vec, selected: usize, mode: Mode, input: String, notice: String, quit: bool, } impl std::fmt::Debug for KeysApp { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let input = match self.mode { Mode::EditingValue { .. } => "[redacted]".to_string(), _ => self.input.clone(), }; f.debug_struct("KeysApp") .field("ids", &self.ids) .field("selected", &self.selected) .field("mode", &self.mode) .field("input", &input) .field("notice", &self.notice) .field("quit", &self.quit) .finish() } } impl KeysApp { fn new(ids: Vec) -> Self { let mut app = Self { ids, selected: 0, mode: Mode::Normal, input: String::new(), notice: String::new(), quit: false, }; app.clamp_selection(); app } fn refresh(&mut self, ids: Vec) { self.ids = ids; self.clamp_selection(); } fn selected_id(&self) -> Option<&str> { self.ids.get(self.selected).map(String::as_str) } fn clamp_selection(&mut self) { if self.ids.is_empty() { self.selected = 0; } else if self.selected >= self.ids.len() { self.selected = self.ids.len() - 1; } } fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Action { if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') { self.quit = true; return Action::Quit; } match self.mode.clone() { Mode::Normal => self.handle_normal(code), Mode::EditingId => self.handle_editing_id(code), Mode::EditingValue { id } => self.handle_editing_value(code, id), Mode::ConfirmDelete { id } => self.handle_confirm_delete(code, id), } } fn handle_normal(&mut self, code: KeyCode) -> Action { match code { KeyCode::Char('q') | KeyCode::Esc => { self.quit = true; Action::Quit } KeyCode::Char('a') => { self.input.clear(); self.notice = "Enter secret id, then Enter".into(); self.mode = Mode::EditingId; Action::None } KeyCode::Char('d') => { if let Some(id) = self.selected_id() { self.mode = Mode::ConfirmDelete { id: id.to_string() }; self.notice = "Delete selected secret? y/N".into(); } else { self.notice = "No key selected".into(); } Action::None } KeyCode::Down | KeyCode::Char('j') => { if !self.ids.is_empty() { self.selected = (self.selected + 1).min(self.ids.len() - 1); } Action::None } KeyCode::Up | KeyCode::Char('k') => { self.selected = self.selected.saturating_sub(1); Action::None } _ => Action::None, } } fn handle_editing_id(&mut self, code: KeyCode) -> Action { match code { KeyCode::Esc => { self.mode = Mode::Normal; self.input.clear(); self.notice = "Add cancelled".into(); Action::None } KeyCode::Enter => { let id = self.input.trim().to_string(); if id.is_empty() { self.notice = "Secret id must not be empty".into(); return Action::None; } self.input.clear(); self.notice = "Enter secret value; input is masked".into(); self.mode = Mode::EditingValue { id }; Action::None } KeyCode::Backspace => { self.input.pop(); Action::None } KeyCode::Char(c) if !c.is_control() => { self.input.push(c); Action::None } _ => Action::None, } } fn handle_editing_value(&mut self, code: KeyCode, id: String) -> Action { match code { KeyCode::Esc => { self.mode = Mode::Normal; self.input.clear(); self.notice = "Add cancelled".into(); Action::None } KeyCode::Enter => { let value = std::mem::take(&mut self.input); self.mode = Mode::Normal; Action::Set { id, value } } KeyCode::Backspace => { self.input.pop(); Action::None } KeyCode::Char(c) if !c.is_control() => { self.input.push(c); Action::None } _ => Action::None, } } fn handle_confirm_delete(&mut self, code: KeyCode, id: String) -> Action { self.mode = Mode::Normal; match code { KeyCode::Char('y') | KeyCode::Char('Y') => Action::Delete { id }, _ => { self.notice = "Delete cancelled".into(); Action::None } } } } #[derive(Clone, PartialEq, Eq)] enum Action { None, Set { id: String, value: String }, Delete { id: String }, Quit, } impl std::fmt::Debug for Action { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::None => f.write_str("None"), Self::Set { id, .. } => f .debug_struct("Set") .field("id", id) .field("value", &"[redacted]") .finish(), Self::Delete { id } => f.debug_struct("Delete").field("id", id).finish(), Self::Quit => f.write_str("Quit"), } } } pub async fn launch() -> ExitCode { let data_dir = match manifest::paths::data_dir() { Some(path) => path, None => { eprintln!("yoi keys: could not determine yoi data directory"); return ExitCode::FAILURE; } }; match run(SecretStore::new(data_dir)) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("yoi keys: {err}"); ExitCode::FAILURE } } } type UiResult = Result>; type InlineTerminal = Terminal>; const MAX_ROWS: usize = 10; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5; struct RawModeGuard { active: bool, } impl RawModeGuard { fn new() -> Self { Self { active: true } } fn restore(mut self) { self.cleanup(); self.active = false; } fn cleanup(&mut self) { let _ = disable_raw_mode(); } } impl Drop for RawModeGuard { fn drop(&mut self) { if self.active { self.cleanup(); } } } fn run(store: SecretStore) -> UiResult<()> { enable_raw_mode()?; let guard = RawModeGuard::new(); let mut terminal = make_inline_terminal()?; let result = run_loop(&mut terminal, store); let close_result = close_viewport(&mut terminal); drop(terminal); guard.restore(); result?; close_result?; Ok(()) } fn make_inline_terminal() -> io::Result { let backend = CrosstermBackend::new(io::stdout()); Terminal::with_options( backend, TerminalOptions { viewport: Viewport::Inline(VIEWPORT_LINES), }, ) } /// Park the cursor at the very bottom of the inline viewport and emit one /// newline before dropping the terminal. This matches the resume picker and /// keeps the shell prompt (or a later inline viewport) from drawing over rows. fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> { let area = terminal.get_frame().area(); let last_row = area.bottom().saturating_sub(1); terminal.set_cursor_position((0, last_row))?; let mut out = io::stdout(); out.write_all(b"\r\n")?; out.flush()?; Ok(()) } fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> { let mut app = KeysApp::new(load_ids(&store)?); loop { terminal.draw(|frame| draw(frame, &app))?; if app.quit { return Ok(()); } if event::poll(Duration::from_millis(200))? { let Event::Key(key) = event::read()? else { continue; }; if key.kind != KeyEventKind::Press { continue; } match app.handle_key(key.code, key.modifiers) { Action::None => {} Action::Quit => return Ok(()), Action::Set { id, value } => match store.set(&id, SecretValue::new(value)) { Ok(()) => { app.refresh(load_ids(&store)?); app.notice = format!("Saved `{id}`"); } Err(err) => { app.notice = format!("Save failed for `{id}`: {err}"); } }, Action::Delete { id } => match store.delete(&id) { Ok(true) => { app.refresh(load_ids(&store)?); app.notice = format!("Deleted `{id}`"); } Ok(false) => app.notice = format!("Secret `{id}` was already absent"), Err(err) => app.notice = format!("Delete failed for `{id}`: {err}"), }, } } } } fn load_ids(store: &SecretStore) -> UiResult> { Ok(store .list_ids()? .into_iter() .map(|id| id.as_str().to_string()) .collect()) } fn draw(frame: &mut Frame<'_>, app: &KeysApp) { let area = frame.area(); let mut constraints = Vec::with_capacity(MAX_ROWS + 5); constraints.push(Constraint::Length(1)); // title for _ in 0..MAX_ROWS { constraints.push(Constraint::Length(1)); // key rows } constraints.push(Constraint::Length(1)); // mode / actions constraints.push(Constraint::Length(1)); // notice constraints.push(Constraint::Length(1)); // protection note constraints.push(Constraint::Length(1)); // spacer let layout = Layout::vertical(constraints).split(area); frame.render_widget(Paragraph::new(title_line(app)), layout[0]); let (start, end) = visible_key_window(app.ids.len(), app.selected); for row in 0..MAX_ROWS { let line = if app.ids.is_empty() && row == 0 { empty_keys_line() } else if let Some(id) = app.ids.get(start + row).filter(|_| start + row < end) { key_row_line(id, start + row == app.selected) } else { Line::from("") }; frame.render_widget(Paragraph::new(line), layout[row + 1]); } let mode_row = MAX_ROWS + 1; frame.render_widget(Paragraph::new(mode_line(app)), layout[mode_row]); frame.render_widget(Paragraph::new(notice_line(app)), layout[MAX_ROWS + 2]); frame.render_widget(Paragraph::new(protection_line()), layout[MAX_ROWS + 3]); if let Some(cursor_col) = mode_cursor_col(app) { let col = cursor_col.min(area.width.saturating_sub(1) as usize); frame.set_cursor_position((layout[mode_row].x + col as u16, layout[mode_row].y)); } } fn title_line(app: &KeysApp) -> Line<'_> { Line::from(vec![ Span::styled( "yoi keys local secrets", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled( key_count_label(app.ids.len()), Style::default().fg(Color::DarkGray), ), Span::raw(" "), Span::styled("values hidden", Style::default().fg(Color::DarkGray)), ]) } fn key_count_label(count: usize) -> String { match count { 0 => "0 ids".to_string(), 1 => "1 id".to_string(), n => format!("{n} ids"), } } fn visible_key_window(total: usize, selected: usize) -> (usize, usize) { if total <= MAX_ROWS { return (0, total); } let last_start = total - MAX_ROWS; let start = selected.saturating_sub(MAX_ROWS / 2).min(last_start); (start, start + MAX_ROWS) } fn empty_keys_line() -> Line<'static> { Line::from(vec![ Span::raw(" "), Span::styled("No keys stored", Style::default().fg(Color::DarkGray)), ]) } fn key_row_line(id: &str, selected: bool) -> Line<'_> { let marker = if selected { "▶ " } else { " " }; let id_style = if selected { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Cyan) }; Line::from(vec![ Span::raw(marker), Span::styled(id, id_style), Span::raw(" "), Span::styled("[secret id]", Style::default().fg(Color::DarkGray)), ]) } fn mode_line(app: &KeysApp) -> Line<'_> { match &app.mode { Mode::Normal => Line::from(vec![ Span::raw(" "), Span::styled("[a]", Style::default().fg(Color::Green)), Span::raw(" add/set "), Span::styled("[d]", Style::default().fg(Color::Yellow)), Span::raw(" delete "), Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)), Span::raw(" select "), Span::styled("[q]", Style::default().fg(Color::Yellow)), Span::raw(" quit"), ]), Mode::EditingId => Line::from(vec![ Span::raw(" "), Span::styled("secret id: ", Style::default().fg(Color::DarkGray)), Span::styled( app.input.as_str(), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ), ]), Mode::EditingValue { id } => Line::from(vec![ Span::raw(" "), Span::styled("value for `", Style::default().fg(Color::DarkGray)), Span::styled(id.as_str(), Style::default().fg(Color::Cyan)), Span::styled("`: ", Style::default().fg(Color::DarkGray)), Span::styled( "•".repeat(app.input.chars().count()), Style::default().fg(Color::Green), ), ]), Mode::ConfirmDelete { id } => Line::from(vec![ Span::raw(" "), Span::styled("delete `", Style::default().fg(Color::DarkGray)), Span::styled(id.as_str(), Style::default().fg(Color::Cyan)), Span::styled("`? ", Style::default().fg(Color::DarkGray)), Span::styled("[y]", Style::default().fg(Color::Red)), Span::raw(" yes "), Span::styled("[n/esc]", Style::default().fg(Color::Yellow)), Span::raw(" cancel"), ]), } } fn notice_line(app: &KeysApp) -> Line<'_> { if app.notice.is_empty() { return Line::from(""); } Line::from(vec![ Span::raw(" "), Span::styled(app.notice.as_str(), notice_style(app.notice.as_str())), ]) } fn notice_style(notice: &str) -> Style { if notice.contains("failed") || notice.contains("must not") { Style::default().fg(Color::Red) } else if notice.starts_with("Saved") || notice.starts_with("Deleted") { Style::default().fg(Color::Green) } else { Style::default().fg(Color::DarkGray) } } fn protection_line() -> Line<'static> { Line::from(vec![ Span::raw(" "), Span::styled( "local obfuscation at rest; not a system keychain", Style::default().fg(Color::DarkGray), ), ]) } fn mode_cursor_col(app: &KeysApp) -> Option { match &app.mode { Mode::EditingId => Some(" secret id: ".chars().count() + app.input.chars().count()), Mode::EditingValue { id } => Some( " value for `".chars().count() + id.chars().count() + "`: ".chars().count() + app.input.chars().count(), ), Mode::Normal | Mode::ConfirmDelete { .. } => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn model_masks_value_and_emits_set_action() { let mut app = KeysApp::new(vec![]); assert_eq!( app.handle_key(KeyCode::Char('a'), KeyModifiers::NONE), Action::None ); for c in "web/brave/test".chars() { app.handle_key(KeyCode::Char(c), KeyModifiers::NONE); } assert_eq!( app.handle_key(KeyCode::Enter, KeyModifiers::NONE), Action::None ); assert!(matches!(app.mode, Mode::EditingValue { .. })); for c in "secret-value".chars() { app.handle_key(KeyCode::Char(c), KeyModifiers::NONE); } assert_eq!(app.input, "secret-value"); let action = app.handle_key(KeyCode::Enter, KeyModifiers::NONE); assert_eq!( action, Action::Set { id: "web/brave/test".into(), value: "secret-value".into() } ); assert!(!format!("{app:?}").contains("secret-value")); } #[test] fn model_confirms_delete() { let mut app = KeysApp::new(vec!["providers/anthropic/default".into()]); assert_eq!( app.handle_key(KeyCode::Char('d'), KeyModifiers::NONE), Action::None ); let action = app.handle_key(KeyCode::Char('y'), KeyModifiers::NONE); assert_eq!( action, Action::Delete { id: "providers/anthropic/default".into() } ); } fn line_text(line: Line<'_>) -> String { line.spans .iter() .map(|span| span.content.as_ref()) .collect() } #[test] fn renderer_masks_editing_value() { let mut app = KeysApp::new(vec!["web/brave/test".into()]); app.mode = Mode::EditingValue { id: "web/brave/test".into(), }; app.input = "secret-value".into(); let text = line_text(mode_line(&app)); assert!(text.contains("web/brave/test")); assert!(text.contains("••••••••••••")); assert!(!text.contains("secret-value")); } }