use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
Frame,
};
use super::theme;
use super::{
judge_name_label, tier_label, App, ClassifyForm, CreateForm, InitForm, JudgeEditForm,
JudgeEntry, JudgeForm, LoginForm, MsgKind, OnboardForm, OnboardStep, Screen, SecretAddForm,
SecretScreen, SettingsForm, UnlockForm, VaultRow,
};
const CYAN: Color = theme::ACCENT;
const DIM: Color = theme::MUTED;
pub fn draw(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(3), ])
.split(frame.area());
draw_header(frame, chunks[0], app.daemon_running);
match &mut app.screen {
Screen::List => draw_list(frame, chunks[1], &app.vaults, &mut app.list_state),
Screen::Login(form) => draw_login(frame, chunks[1], form),
Screen::Onboard(form) => draw_onboard(frame, chunks[1], form),
Screen::Create(form) => draw_create(frame, chunks[1], form),
Screen::Settings(form) => draw_settings(frame, chunks[1], form),
Screen::Unlock(form) => draw_unlock(frame, chunks[1], form),
Screen::Secrets(scr) => draw_secrets(frame, chunks[1], scr),
Screen::SecretAdd(form) => draw_secret_add(frame, chunks[1], form),
Screen::RecoveryCode(show) => draw_recovery_code(frame, chunks[1], show),
Screen::Import(form) => draw_import(frame, chunks[1], form),
Screen::Recover(form) => draw_recover(frame, chunks[1], form),
Screen::Activity(scr) => draw_activity(frame, chunks[1], scr),
Screen::Classify(form) => draw_classify(frame, chunks[1], form),
Screen::Judge(form) => draw_judge(frame, chunks[1], form),
Screen::Mcp => draw_mcp(frame, chunks[1], app.daemon_running, &app.vaults),
}
draw_status(frame, chunks[2], app);
draw_footer(frame, chunks[3], app);
if app.show_help {
draw_help(frame, chunks[1], &app.screen);
}
if app.confirm_quit {
draw_quit(frame, chunks[1]);
}
if app.pending_fido.is_some() {
draw_touch(frame, chunks[1]);
}
}
fn draw_touch(frame: &mut Frame, area: Rect) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" Touch your YubiKey",
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
" The key is blinking — tap the sensor to continue.",
Style::default().fg(theme::TEXT),
)),
Line::from(Span::styled(
" (enrolling asks for a second tap)",
Style::default().fg(DIM),
)),
];
let popup = centered_rect(54, 32, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" YubiKey ")
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn draw_quit(frame: &mut Frame, area: Rect) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(" Quit Svault?", theme::title())),
Line::from(""),
Line::from(Span::styled(
" enter quit esc / any key stay",
theme::label_dim(),
)),
];
let popup = centered_rect(44, 30, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Confirm ")
.border_style(Style::default().fg(theme::WARN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn draw_header(frame: &mut Frame, area: Rect, daemon_running: bool) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::border());
let inner = block.inner(area);
frame.render_widget(block, area);
let title = Line::from(vec![
Span::styled(" Svault ", theme::title()),
Span::styled("— secret access for AI agents", theme::label_dim()),
]);
frame.render_widget(Paragraph::new(title), inner);
let (label, color) = if daemon_running {
("daemon running ", theme::OK)
} else {
("daemon off ", theme::MUTED)
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(label, Style::default().fg(color))))
.alignment(Alignment::Right),
inner,
);
}
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let Some(status) = &app.status else {
return;
};
let (color, prefix) = match status.kind {
MsgKind::Ok => (theme::OK, "ok: "),
MsgKind::Warn => (theme::WARN, "warning: "),
MsgKind::Error => (theme::ERR, "error: "),
MsgKind::Info => (theme::ACCENT, "note: "),
};
let line = Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{prefix}{}", status.text),
Style::default().fg(color),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
let (full, compact): (&str, &str) = if app.confirm_quit {
(
"enter quit esc / any key stay",
"enter quit esc stay",
)
} else if app.show_help {
("any key / esc close help", "any key close")
} else {
match &app.screen {
Screen::List => (
"↑/↓ move enter open c create u unlock l lock o logout s settings shift-J judge m mcp v activity e export i import r recover d daemon h/? help q quit",
"↑/↓ move enter open u unlock o logout shift-J judge h/? help q quit",
),
Screen::Login(form) => (
if form.yubikey {
"type master passphrase enter sign in ctrl+y yubikey esc quit"
} else {
"type master passphrase enter sign in esc quit"
},
"enter sign in esc quit",
),
Screen::Onboard(form) => match form.step {
super::OnboardStep::Disclaimer => (
"enter I understand — continue esc quit",
"enter continue esc quit",
),
super::OnboardStep::Passphrase => (
"type passphrase ↑/↓ or tab switch field enter next / confirm esc quit",
"enter next esc quit",
),
super::OnboardStep::Recovery => (
"y I've saved the recovery code — continue",
"y continue",
),
super::OnboardStep::Yubikey => (
"type PIN if your key has one enter enroll YubiKey esc skip",
"enter enroll esc skip",
),
},
Screen::Activity(_) => ("↑/↓ scroll esc / b back q quit", "↑/↓ scroll esc back"),
Screen::Create(_) => (
"↑/↓ field ←/→ change space toggle enter next/create esc cancel",
"↑/↓ field enter next esc cancel",
),
Screen::Settings(_) => (
"↑/↓ field ←/→ change space toggle enter next/save esc cancel",
"↑/↓ field enter next esc cancel",
),
Screen::Unlock(form) => (
if form.yubikey {
"type master passphrase enter unlock ctrl+y yubikey esc cancel"
} else {
"type master passphrase enter unlock esc cancel"
},
"enter unlock esc cancel",
),
Screen::Secrets(scr) => {
if scr.reveal.is_some() {
("space reveal/hide esc close", "space hide esc close")
} else if scr.pending_delete.is_some() {
("y confirm delete n cancel", "y delete n cancel")
} else {
(
"↑/↓ move enter view a add c classify A approve d delete l lock h/? help esc back",
"↑/↓ move enter view c classify A approve esc back",
)
}
}
Screen::SecretAdd(_) => (
"↑/↓ field enter next/save esc cancel",
"↑/↓ field enter save esc cancel",
),
Screen::Classify(_) => (
"↑/↓ field ←/→ change space toggle enter next/save esc cancel",
"↑/↓ field enter save esc cancel",
),
Screen::Judge(form) => {
if form.entry.is_some() {
(
"type/paste tab move enter confirm esc cancel",
"enter confirm esc cancel",
)
} else if !form.unlocked {
(
"enter unlock / create the keyring esc back",
"enter unlock esc back",
)
} else {
(
"↑/↓ move space toggle a add e edit v view k key d default t test x remove esc back",
"↑/↓ move a add e edit esc back",
)
}
}
Screen::RecoveryCode(_) => (
"press 'y' to confirm you have saved the code",
"'y' to confirm saved",
),
Screen::Import(_) => (
"type/paste path to bundle enter import esc cancel",
"enter import esc cancel",
),
Screen::Recover(_) => (
"↑/↓ field enter next/recover esc cancel",
"↑/↓ field enter next esc cancel",
),
Screen::Mcp => (
"w write .mcp.json d toggle daemon h/? help esc back q quit",
"w write d daemon esc back",
),
}
};
let avail = area.width.saturating_sub(2) as usize;
let hint = if full.chars().count() <= avail {
full
} else {
compact
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::border());
let p = Paragraph::new(Span::styled(hint, theme::hint())).block(block);
frame.render_widget(p, area);
}
fn draw_help(frame: &mut Frame, area: Rect, screen: &Screen) {
let mut lines = vec![
Line::from(Span::styled(" Keybindings", theme::title())),
Line::from(""),
];
let rows: &[(&str, &str)] = match screen {
Screen::Secrets(_) => &[
("↑/↓ or j/k", "move selection"),
("enter or g", "reveal secret value"),
("a", "add a secret"),
("c", "classify (tier / scope / reason / windows / callers)"),
("A", "approve (clear a SEALED secret)"),
("d", "delete the selected secret"),
("l", "lock the vault"),
("h or ?", "show this help"),
("esc or b", "back to vault list"),
],
Screen::Judge(_) => &[
("↑/↓", "move between rows"),
("enter", "unlock / create the keyring · view a judge"),
("space / ←→", "toggle the judge on/off (global)"),
("a / e", "add a judge · edit the selected judge"),
("v / k", "view detail · set the selected judge's API key"),
("d / t / x", "set default · test · remove judge"),
("esc", "back to vault list"),
],
Screen::Mcp => &[
("w", "write the svault entry into ./.mcp.json"),
("d", "start / stop the daemon (so keys stay in memory)"),
("esc or b", "back to vault list"),
],
_ => &[
("↑/↓ or j/k", "move selection"),
("enter", "open a vault's secrets"),
("c", "create a new vault"),
("u / l", "unlock / lock the selected vault"),
(
"o",
"log out of all vaults (locks them); unlocking re-prompts the master",
),
(
"s",
"edit settings (description, agents, rate limit, auto-lock)",
),
(
"shift-J",
"manage the AI judge (key, model, thresholds, test)",
),
("m", "MCP server — status + wiring for AI agents"),
("v", "view the activity timeline (human + agent)"),
("e / i", "export / import an encrypted bundle"),
("r", "recover a vault with its recovery code"),
("d", "start / stop the background daemon (Unix)"),
("h or ?", "show this help"),
("q", "quit"),
],
};
for (keys, desc) in rows {
lines.push(Line::from(vec![
Span::styled(format!(" {keys:<14}"), theme::label_focused()),
Span::styled((*desc).to_string(), Style::default().fg(theme::TEXT)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press any key to close.",
theme::label_dim(),
)));
let popup = centered_rect(70, 70, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help ")
.border_style(theme::title());
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn draw_list(
frame: &mut Frame,
area: Rect,
vaults: &[super::VaultRow],
state: &mut ratatui::widgets::TableState,
) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Vaults ")
.border_style(theme::border());
if vaults.is_empty() {
let p = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" No vaults yet.",
Style::default()
.fg(theme::WARN)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" Press 'c' to create your first vault.",
Style::default().fg(theme::TEXT),
)),
])
.block(block);
frame.render_widget(p, area);
return;
}
let header =
Row::new(["STORAGE", "VAULT", "STATUS", "CREATED", "DESCRIPTION"]).style(theme::header());
let rows: Vec<Row> = vaults
.iter()
.map(|v| {
let (status, status_style) = if v.unlocked {
("unlocked", Style::default().fg(theme::OK))
} else {
("locked", Style::default().fg(theme::MUTED))
};
let desc = if v.description.is_empty() {
"-".to_string()
} else {
v.description.clone()
};
Row::new(vec![
Cell::from(v.storage.clone()).style(Style::default().fg(theme::TEXT)),
Cell::from(v.name.clone()).style(theme::title()),
Cell::from(status).style(status_style),
Cell::from(v.created.clone()).style(Style::default().fg(theme::MUTED)),
Cell::from(desc).style(Style::default().fg(theme::TEXT)),
])
})
.collect();
let widths = [
Constraint::Length(12),
Constraint::Length(22),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Min(10),
];
let table = Table::new(rows, widths)
.header(header)
.block(block)
.column_spacing(2)
.row_highlight_style(theme::selected_row())
.highlight_symbol("> ");
frame.render_stateful_widget(table, area, state);
}
fn draw_activity(frame: &mut Frame, area: Rect, scr: &mut super::ActivityScreen) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Activity · {} ", scr.name))
.border_style(theme::border());
if scr.events.is_empty() {
let p = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" No activity recorded yet.",
Style::default()
.fg(theme::WARN)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" Unlocks, reveals, edits, and agent 'get' requests will show up here.",
Style::default().fg(theme::TEXT),
)),
])
.block(block);
frame.render_widget(p, area);
return;
}
let header = Row::new(["WHEN", "ACTOR", "VIA", "ACTION", "TARGET"]).style(theme::header());
let rows: Vec<Row> = scr
.events
.iter()
.map(|e| {
let when = e
.timestamp()
.map(|t| {
t.with_timezone(&chrono::Local)
.format("%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|| e.ts.chars().take(16).collect());
let actor_style = if e.actor == crate::core::usage::AGENT {
Style::default().fg(theme::WARN)
} else {
Style::default().fg(theme::ACCENT)
};
let actor = format!("{} {}", e.actor, e.actor_id);
let via = if e.source.is_empty() {
"-".to_string()
} else {
e.source.clone()
};
let target = e.target.clone().unwrap_or_else(|| "-".to_string());
Row::new(vec![
Cell::from(when).style(Style::default().fg(theme::MUTED)),
Cell::from(actor).style(actor_style),
Cell::from(via).style(Style::default().fg(theme::MUTED)),
Cell::from(e.action.clone()).style(Style::default().fg(theme::TEXT)),
Cell::from(target).style(Style::default().fg(theme::MUTED)),
])
})
.collect();
let widths = [
Constraint::Length(12),
Constraint::Length(16),
Constraint::Length(4),
Constraint::Length(14),
Constraint::Min(8),
];
let table = Table::new(rows, widths)
.header(header)
.block(block)
.column_spacing(2)
.row_highlight_style(theme::selected_row())
.highlight_symbol("> ");
frame.render_stateful_widget(table, area, &mut scr.state);
}
fn allow_label(mode: usize, list: &str) -> String {
match mode {
0 => "all agents".to_string(),
1 => "none".to_string(),
_ => format!(
"specific list ({})",
if list.is_empty() { "—" } else { list }
),
}
}
fn yes_no(v: bool) -> &'static str {
if v {
"yes"
} else {
"no"
}
}
fn mask(s: &str) -> String {
"*".repeat(s.chars().count())
}
fn field_lines<'a>(fields: &'a [(&'a str, String)], focus: usize, caret: bool) -> Vec<Line<'a>> {
let mut lines = vec![Line::from("")];
for (i, (label, value)) in fields.iter().enumerate() {
let focused = i == focus;
let marker = if focused { "> " } else { " " };
let label_style = if focused {
theme::label_focused()
} else {
theme::label_dim()
};
let value_style = if focused {
theme::value_focused()
} else {
Style::default()
};
let mut spans = vec![
Span::raw(marker),
Span::styled(format!("{label:<18}"), label_style),
Span::styled(value.clone(), value_style),
];
if focused && caret {
spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
}
lines.push(Line::from(spans));
}
lines
}
fn draw_create(frame: &mut Frame, area: Rect, form: &CreateForm) {
use super::{CreateField, MasterStep};
let fields: Vec<(&str, String)> = form
.order
.iter()
.map(|f| match f {
CreateField::Name => ("Name", form.name.clone()),
CreateField::Description => ("Description", form.description.clone()),
CreateField::AllowMode => (
"Allow agent",
allow_label(form.allow_mode, &form.allow_list),
),
CreateField::AllowList => (
"Agent list",
if form.allow_list.is_empty() {
"—".into()
} else {
form.allow_list.clone()
},
),
CreateField::RateLimit => ("Rate limit", form.rate_limit.clone()),
CreateField::Autolock => ("Auto-lock", yes_no(form.autolock).to_string()),
CreateField::AutolockTimer => ("Auto-lock timer", form.autolock_timer.clone()),
CreateField::DefaultTier => ("Default tier", tier_label(form.default_tier).to_string()),
CreateField::Judge => ("AI judge", yes_no(form.judge).to_string()),
CreateField::JudgeName => ("Assigned judge", judge_name_label(&form.judge_name)),
CreateField::MasterNew => ("Master passphrase", mask(&form.passphrase)),
CreateField::MasterConfirm => ("Confirm master passphrase", mask(&form.confirm)),
CreateField::MasterUnlock => ("Master passphrase", mask(&form.passphrase)),
})
.collect();
let mut lines = field_lines(&fields, form.focus, form.focus_is_text());
lines.push(Line::from(""));
let note = match form.master_step {
MasterStep::Set => " First run: set a master passphrase — one secret unlocks every vault.",
MasterStep::Unlock => " Enter your master passphrase to create this vault under it.",
MasterStep::Ready => " Storage: local Unlock: master passphrase",
};
lines.push(Line::from(Span::styled(note, Style::default().fg(DIM))));
lines.push(Line::from(Span::styled(
" (space/←→ cycles tier, judge & assigned judge)",
Style::default().fg(DIM),
)));
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Create vault ")
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_settings(frame: &mut Frame, area: Rect, form: &SettingsForm) {
let fields = [
("Description", form.description.clone()),
(
"Allow agent",
allow_label(form.allow_mode, &form.allow_list),
),
(
"Agent list",
if form.allow_list.is_empty() {
"—".into()
} else {
form.allow_list.clone()
},
),
("Rate limit", form.rate_limit.clone()),
("Auto-lock", yes_no(form.autolock).to_string()),
("Auto-lock timer", form.autolock_timer.clone()),
("Default tier", tier_label(form.default_tier).to_string()),
("AI judge", yes_no(form.judge).to_string()),
("Assigned judge", judge_name_label(&form.judge_name)),
];
let mut lines = field_lines(&fields, form.focus, form.focus_is_text());
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Login: passphrase (space/←→ cycles tier, judge & assigned judge)",
Style::default().fg(DIM),
)));
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Settings · {} ", form.name))
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_secret_add(frame: &mut Frame, area: Rect, form: &SecretAddForm) {
let fields = [
("Name", form.name.clone()),
("Value", mask(&form.value)),
("Scope", form.scope.clone()),
("Description", form.description.clone()),
("Tier", tier_label(form.tier).to_string()),
("Require reason", yes_no(form.require_reason).to_string()),
];
let mut lines = field_lines(&fields, form.focus, form.focus_is_text());
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" space/←→ cycles tier & toggles require-reason",
Style::default().fg(DIM),
)));
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Add secret · {} ", form.vault_name))
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_classify(frame: &mut Frame, area: Rect, form: &ClassifyForm) {
let fields = [
("Scope", form.scope.clone()),
("Description", form.description.clone()),
("Windows", form.windows.clone()),
("Require callers", form.require_callers.clone()),
("Tier", tier_label(form.tier).to_string()),
("Require reason", yes_no(form.require_reason).to_string()),
];
let mut lines = field_lines(&fields, form.focus, form.focus_is_text());
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Edits the encrypted policy for this secret — the value is not touched.",
Style::default().fg(DIM),
)));
lines.push(Line::from(Span::styled(
" Windows: e.g. 'mon-fri 09:00-18:00', comma-separated; callers comma-separated.",
Style::default().fg(DIM),
)));
lines.push(Line::from(Span::styled(
" space/←→ cycles tier & toggles require-reason",
Style::default().fg(DIM),
)));
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Classify · {} ", form.secret))
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_mcp(frame: &mut Frame, area: Rect, daemon_running: bool, vaults: &[VaultRow]) {
let unlocked: Vec<&str> = vaults
.iter()
.filter(|v| v.unlocked)
.map(|v| v.name.as_str())
.collect();
let mut lines: Vec<Line> = vec![
Line::from(""),
Line::from(Span::styled(
" Local MCP server — gated secret access for AI agents (Claude Code, Cursor, …).",
Style::default().fg(DIM),
)),
Line::from(Span::styled(
" The agent platform launches `svault mcp`; you keep a vault unlocked here.",
Style::default().fg(DIM),
)),
Line::from(""),
];
let (dlabel, dcolor) = if daemon_running {
("running", theme::OK)
} else {
(
"off (optional — keys then live in the session file)",
theme::WARN,
)
};
lines.push(Line::from(vec![
Span::styled(" Daemon: ", Style::default().fg(DIM)),
Span::styled(dlabel, Style::default().fg(dcolor)),
]));
let (uvalue, ucolor) = if unlocked.is_empty() {
("(none — unlock a vault first)".to_string(), theme::WARN)
} else {
(unlocked.join(", "), theme::OK)
};
lines.push(Line::from(vec![
Span::styled(" Unlocked vaults: ", Style::default().fg(DIM)),
Span::styled(uvalue, Style::default().fg(ucolor)),
]));
lines.push(Line::from(""));
if unlocked.is_empty() {
lines.push(Line::from(Span::styled(
" Not ready — press esc, then u to unlock a vault so the server has something to serve.",
Style::default().fg(theme::WARN),
)));
} else {
lines.push(Line::from(Span::styled(
" Ready — agents can fetch from the unlocked vault(s) through the policy + judge gate.",
Style::default().fg(theme::OK),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Claude Code / Cursor config — add to .mcp.json (press w to write it here):",
Style::default().fg(DIM),
)));
for s in [
" {",
" \"mcpServers\": {",
" \"svault\": {",
" \"command\": \"svault\",",
" \"args\": [\"mcp\"],",
" \"env\": { \"SVAULT_CALLER\": \"claude-code\" }",
" }",
" }",
" }",
] {
lines.push(Line::from(Span::styled(s, Style::default().fg(CYAN))));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" w write .mcp.json into this folder d toggle daemon esc back",
Style::default().fg(DIM),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(" MCP ")
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_judge(frame: &mut Frame, area: Rect, form: &JudgeForm) {
let mut lines: Vec<Line> = Vec::new();
if !form.created {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" No keyring yet.",
Style::default().fg(theme::WARN),
)));
lines.push(Line::from(Span::styled(
" Press enter to create it — your master passphrase encrypts your judges + keys.",
Style::default().fg(DIM),
)));
} else if !form.unlocked {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Keyring is locked.",
Style::default().fg(theme::WARN),
)));
lines.push(Line::from(Span::styled(
" Press enter to unlock and manage judges.",
Style::default().fg(DIM),
)));
} else {
let env_present = std::env::var(crate::core::keyring::KEY_ENV)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let sel = |i: usize| if form.focus == i { ">" } else { " " };
lines.push(Line::from(vec![
Span::raw(format!(" {} ", sel(0))),
Span::styled("AI judge (global) ", Style::default().fg(theme::ACCENT)),
Span::styled(
yes_no(form.enabled).to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(Span::styled(
format!(
" default judge: {}",
form.default_judge.as_deref().unwrap_or("(none)")
),
Style::default().fg(DIM),
)));
let default_has_key = form
.default_judge
.as_ref()
.and_then(|dn| form.judges.iter().find(|j| &j.name == dn))
.map(|j| j.has_key)
.unwrap_or(false);
if form.enabled && !default_has_key && !env_present {
lines.push(Line::from(Span::styled(
" ! judge is ON but has no API key — set one with k, or export $SVAULT_OPENROUTER_KEY (else medium/high requests fail)",
Style::default().fg(theme::WARN),
)));
}
lines.push(Line::from(""));
if form.judges.is_empty() {
lines.push(Line::from(Span::styled(
" No judges yet — press a to add one.",
Style::default().fg(DIM),
)));
} else {
lines.push(Line::from(Span::styled(
format!(
" {:<16} {:<24} {:>6} {:>5} KEY",
"NAME", "MODEL", "ALLOW", "HIGH"
),
Style::default().fg(DIM),
)));
for (i, j) in form.judges.iter().enumerate() {
let focused = form.focus == i + 1;
let mark = if focused { ">" } else { " " };
let def = if form.default_judge.as_deref() == Some(j.name.as_str()) {
"*"
} else {
" "
};
let (key_label, key_color) = if j.has_key {
("key set", theme::OK)
} else if env_present {
("env key", theme::WARN)
} else {
("no key", theme::ERR)
};
let row_style = if focused {
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let key_style = if focused {
Style::default().fg(key_color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(key_color)
};
lines.push(Line::from(vec![
Span::styled(
format!(
" {}{}{:<15} {:<24} {:>6} {:>5} ",
mark, def, j.name, j.model, j.allow, j.high
),
row_style,
),
Span::styled(key_label, key_style),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" key: 'key set' = stored · 'env key' = $SVAULT_OPENROUTER_KEY · 'no key' = none (press k to set)",
Style::default().fg(DIM),
)));
lines.push(Line::from(Span::styled(
" space on/off a add e edit v view k key d default t test x remove",
Style::default().fg(DIM),
)));
}
if let Some((kind, msg)) = &form.test_result {
let color = match kind {
MsgKind::Ok => theme::OK,
MsgKind::Warn => theme::WARN,
MsgKind::Error => theme::ERR,
MsgKind::Info => theme::ACCENT,
};
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" test: {msg}"),
Style::default().fg(color),
)));
}
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" AI judge ")
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
match &form.entry {
Some(JudgeEntry::Passphrase(b)) => {
draw_masked_popup(frame, area, " Unlock keyring ", " Master passphrase", b)
}
Some(JudgeEntry::Key { judge, buf }) => draw_masked_popup(
frame,
area,
" Set judge key ",
&format!(" OpenRouter key for '{judge}' (sk-or-…, blank clears)"),
buf,
),
Some(JudgeEntry::Init(init)) => draw_judge_init(frame, area, init),
Some(JudgeEntry::Edit(ed)) => draw_judge_edit(frame, area, ed),
Some(JudgeEntry::View(name)) => draw_judge_view(frame, area, form, name),
None => {}
}
}
fn draw_masked_popup(frame: &mut Frame, area: Rect, title: &str, label: &str, buf: &str) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
label.to_string(),
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::raw(" > "),
Span::styled(mask(buf), Style::default().add_modifier(Modifier::BOLD)),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
]),
Line::from(""),
Line::from(Span::styled(
" enter confirm esc cancel",
Style::default().fg(DIM),
)),
];
let popup = centered_rect(64, 40, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(title.to_string())
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn entry_row(label: &str, value: String, focused: bool, masked: bool) -> Line<'static> {
let cursor = if focused { ">" } else { " " };
let shown = if masked { mask(&value) } else { value };
let label_style = if focused {
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(DIM)
};
let mut spans = vec![
Span::raw(format!(" {cursor} ")),
Span::styled(format!("{label:<11}"), label_style),
Span::styled(shown, Style::default().add_modifier(Modifier::BOLD)),
];
if focused {
spans.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
}
Line::from(spans)
}
fn draw_judge_init(frame: &mut Frame, area: Rect, init: &InitForm) {
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(
" No master passphrase yet — set one. It unlocks the keyring and every vault.",
Style::default().fg(DIM),
)),
Line::from(""),
entry_row("Passphrase", init.pass.clone(), init.focus == 0, true),
entry_row("Confirm", init.confirm.clone(), init.focus == 1, true),
Line::from(""),
];
if let Some(err) = &init.error {
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(Span::styled(
" tab switch enter create esc cancel",
Style::default().fg(DIM),
)));
let popup = centered_rect(66, 46, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Set master passphrase ")
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn draw_judge_edit(frame: &mut Frame, area: Rect, ed: &JudgeEditForm) {
let title = if ed.original.is_some() {
" Edit judge "
} else {
" Add judge "
};
let mut lines = vec![
Line::from(""),
entry_row("Name", ed.name.clone(), ed.focus == 0, false),
entry_row("Model", ed.model.clone(), ed.focus == 1, false),
entry_row("Base URL", ed.base_url.clone(), ed.focus == 2, false),
entry_row("Timeout s", ed.timeout.clone(), ed.focus == 3, false),
entry_row("Allow ≥", ed.allow.clone(), ed.focus == 4, false),
entry_row("High ≥", ed.high.clone(), ed.focus == 5, false),
entry_row("Criteria", ed.criteria.clone(), ed.focus == 6, false),
Line::from(""),
Line::from(Span::styled(
" Criteria: extra rules added to this judge's prompt (optional).",
Style::default().fg(DIM),
)),
Line::from(Span::styled(
" API key: set it with k after saving, or export $SVAULT_OPENROUTER_KEY.",
Style::default().fg(DIM),
)),
Line::from(""),
];
if let Some(err) = &ed.error {
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(Span::styled(
" tab/↑↓ move enter save esc cancel",
Style::default().fg(DIM),
)));
let popup = centered_rect(72, 70, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn draw_judge_view(frame: &mut Frame, area: Rect, form: &JudgeForm, name: &str) {
let row = form.judges.iter().find(|j| j.name == name);
let mut lines = vec![Line::from("")];
if let Some(j) = row {
let field = |k: &str, v: String| {
Line::from(vec![
Span::styled(format!(" {k:<11}"), Style::default().fg(DIM)),
Span::styled(v, Style::default().add_modifier(Modifier::BOLD)),
])
};
let is_default = form.default_judge.as_deref() == Some(j.name.as_str());
lines.push(field("name", j.name.clone()));
lines.push(field(
"default",
if is_default { "yes" } else { "no" }.to_string(),
));
lines.push(field("model", j.model.clone()));
lines.push(field("base url", j.base_url.clone()));
lines.push(field("timeout", format!("{}s", j.timeout_secs)));
lines.push(field("allow ≥", j.allow.to_string()));
lines.push(field("high ≥", j.high.to_string()));
let env_present = std::env::var(crate::core::keyring::KEY_ENV)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let key_desc = if j.has_key {
"set (stored, encrypted)".to_string()
} else if env_present {
format!("from ${} (env)", crate::core::keyring::KEY_ENV)
} else {
format!(
"not set — press k, or export ${}",
crate::core::keyring::KEY_ENV
)
};
lines.push(field("api key", key_desc));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" criteria",
Style::default().fg(DIM),
)));
let criteria = if j.criteria.trim().is_empty() {
"(none)".to_string()
} else {
j.criteria.clone()
};
lines.push(Line::from(Span::styled(
format!(" {criteria}"),
Style::default(),
)));
} else {
lines.push(Line::from(Span::styled(
format!(" no judge named '{name}'"),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" e edit any other key to close",
Style::default().fg(DIM),
)));
let popup = centered_rect(72, 70, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Judge: {name} "))
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
fn draw_recovery_code(frame: &mut Frame, area: Rect, show: &super::RecoveryShow) {
let mut lines = vec![Line::from("")];
let plural = if show.codes.len() > 1 {
"codes"
} else {
"code"
};
lines.push(Line::from(Span::styled(
format!(" Recovery {plural}"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
for (label, code) in &show.codes {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {label}"),
Style::default().fg(DIM),
)));
lines.push(Line::from(Span::styled(
format!(" {code}"),
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
)));
}
lines.extend([
Line::from(""),
Line::from(Span::styled(
" This is the ONLY time these are shown — they are not stored in plaintext.",
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
" Save them in a password manager (or on paper, offline). They are the only",
Style::default().fg(DIM),
)),
Line::from(Span::styled(
" way back in if you forget your passphrase — 'svault recover' (a vault) or",
Style::default().fg(DIM),
)),
Line::from(Span::styled(
" 'svault master recover' (the master).",
Style::default().fg(DIM),
)),
Line::from(""),
Line::from(Span::styled(
" Press 'y' to confirm you have saved them.",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
]);
let title = if show.codes.len() > 1 {
" Save your recovery codes "
} else {
" Save your recovery code "
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_import(frame: &mut Frame, area: Rect, form: &super::ImportForm) {
let fields = [("Bundle path", form.path.clone())];
let mut lines = field_lines(&fields, 0, true);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Path to a .svault-export.json file created by 'svault export'.",
Style::default().fg(DIM),
)));
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Import vault ")
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_recover(frame: &mut Frame, area: Rect, form: &super::RecoverForm) {
let fields = [
("Recovery code", form.code.clone()),
("New passphrase", mask(&form.new_pass)),
("Confirm passphrase", mask(&form.confirm)),
];
let mut lines = field_lines(&fields, form.focus, true);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Resets a lost passphrase. The recovery code stays the same.",
Style::default().fg(DIM),
)));
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" error: {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Recover · {} ", form.name))
.border_style(Style::default().fg(DIM));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_unlock(frame: &mut Frame, area: Rect, form: &UnlockForm) {
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" Master passphrase to unlock "),
Span::styled(
form.name.clone(),
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::raw(" > "),
Span::styled(
mask(&form.passphrase),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
]),
];
if form.yubikey {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" YubiKey enrolled — type the PIN (if any), then Ctrl+Y to unlock by touch.",
Style::default().fg(CYAN),
)));
}
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Unlock ")
.border_style(Style::default().fg(DIM));
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_login(frame: &mut Frame, area: Rect, form: &LoginForm) {
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(
" Sign in to Svault",
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" Enter your master passphrase to continue.",
Style::default().fg(DIM),
)),
Line::from(""),
Line::from(vec![
Span::raw(" Master passphrase > "),
Span::styled(
mask(&form.passphrase),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
]),
];
if form.yubikey {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" YubiKey enrolled — type the PIN (if any), then Ctrl+Y to sign in by touch.",
Style::default().fg(CYAN),
)));
}
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Sign in ")
.border_style(Style::default().fg(CYAN));
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_onboard(frame: &mut Frame, area: Rect, form: &OnboardForm) {
let bold = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().fg(DIM);
let accent = Style::default().fg(CYAN).add_modifier(Modifier::BOLD);
let (title, mut lines): (&str, Vec<Line>) = match form.step {
OnboardStep::Disclaimer => (
" Welcome to Svault — Step 1 of 3 ",
vec![
Line::from(""),
Line::from(Span::styled(
" Svault gives cooperative AI agents structured, policy-gated, audited",
Style::default(),
)),
Line::from(Span::styled(
" access to your secrets, and encrypts everything at rest.",
Style::default(),
)),
Line::from(""),
Line::from(Span::styled(" Be honest about the boundary:", accent)),
Line::from(Span::styled(
" Svault is NOT a sandbox against a hostile process running as your own",
Style::default(),
)),
Line::from(Span::styled(
" user — such a process can read an unlocked session directly. It raises",
Style::default(),
)),
Line::from(Span::styled(
" the bar for agents that mostly play by the rules and gives you an audit",
Style::default(),
)),
Line::from(Span::styled(
" trail when one doesn't. There are no accounts and no cloud — everything",
Style::default(),
)),
Line::from(Span::styled(
" stays on this machine, and your master passphrase is the root of trust.",
Style::default(),
)),
Line::from(""),
Line::from(Span::styled(
" Press Enter to acknowledge this and continue.",
dim,
)),
],
),
OnboardStep::Passphrase => {
let caret = |focused: bool| if focused { "> " } else { " " };
(
" Set your master passphrase — Step 2 of 3 ",
vec![
Line::from(""),
Line::from(Span::styled(
" One passphrase unlocks every vault. Choose a strong one — it is the",
Style::default(),
)),
Line::from(Span::styled(
" root of trust and cannot be recovered except via the recovery code.",
dim,
)),
Line::from(""),
Line::from(vec![
Span::styled(format!(" {}Passphrase ", caret(form.focus == 0)), dim),
Span::styled(mask(&form.passphrase), bold),
Span::styled(
if form.focus == 0 { " " } else { "" },
Style::default().add_modifier(Modifier::REVERSED),
),
]),
Line::from(vec![
Span::styled(format!(" {}Confirm ", caret(form.focus == 1)), dim),
Span::styled(mask(&form.confirm), bold),
Span::styled(
if form.focus == 1 { " " } else { "" },
Style::default().add_modifier(Modifier::REVERSED),
),
]),
],
)
}
OnboardStep::Recovery => {
let code = form.recovery_code.as_deref().unwrap_or("(unavailable)");
(
" Save your recovery code ",
vec![
Line::from(""),
Line::from(Span::styled(
" This one-time code is the ONLY way back in if you forget the master",
Style::default(),
)),
Line::from(Span::styled(
" passphrase. It is shown once. Write it down and store it offline.",
Style::default(),
)),
Line::from(""),
Line::from(vec![
Span::styled(" Master recovery code ", dim),
Span::styled(code.to_string(), accent),
]),
Line::from(""),
Line::from(Span::styled(
" Press 'y' once you have saved it to continue.",
dim,
)),
],
)
}
OnboardStep::Yubikey => {
let device = if form.yubikey_present {
Span::styled("connected", Style::default().fg(CYAN))
} else {
Span::styled("not connected", dim)
};
(
" Optional: enroll a YubiKey — Step 3 of 3 ",
vec![
Line::from(""),
Line::from(Span::styled(
" Optionally add a YubiKey as an alternative way to unlock (a touch",
Style::default(),
)),
Line::from(Span::styled(
" instead of typing the passphrase). It's passphrase OR touch — never",
Style::default(),
)),
Line::from(Span::styled(
" required, and the passphrase always still works.",
dim,
)),
Line::from(""),
Line::from(vec![Span::styled(" Device ", dim), device]),
Line::from(vec![
Span::styled(" PIN ", dim),
Span::styled(mask(&form.pin), bold),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
Span::styled(" (leave blank if your key has no PIN)", dim),
]),
Line::from(""),
Line::from(Span::styled(
" Enter to enroll (you'll touch the key twice), or Esc to skip.",
dim,
)),
],
)
}
};
if let Some(err) = &form.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(Color::Red),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_secrets(frame: &mut Frame, area: Rect, scr: &mut SecretScreen) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Secrets · {} (unlocked) ", scr.name))
.border_style(Style::default().fg(DIM));
if scr.secrets.is_empty() {
let p = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
" No secrets yet.",
Style::default()
.fg(theme::WARN)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" Press 'a' to add one.",
Style::default().fg(theme::TEXT),
)),
])
.block(block);
frame.render_widget(p, area);
} else {
let header =
Row::new(["SECRET", "TIER", "SCOPE", "REASON?", "DESCRIPTION"]).style(theme::header());
let rows: Vec<Row> = scr
.secrets
.iter()
.map(|n| {
let rule = scr.classifications.get(n);
let (tier, tier_style) = match rule.map(|r| r.tier) {
Some(crate::core::policy::Tier::High) => {
("high", Style::default().fg(theme::ERR))
}
Some(crate::core::policy::Tier::Medium) => {
("medium", Style::default().fg(theme::WARN))
}
Some(crate::core::policy::Tier::Low) => ("low", Style::default().fg(theme::OK)),
None => ("unset", Style::default().fg(theme::MUTED)),
};
let scope = rule
.map(|r| r.scope.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "-".to_string());
let reason = match rule.map(|r| r.require_reason) {
Some(true) => "yes",
_ => "-",
};
let sealed = scr.seals.contains_key(n);
let desc = rule
.map(|r| r.description.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "-".to_string());
let (desc, desc_style) = if sealed {
(
"SEALED — press A to approve".to_string(),
Style::default().fg(theme::ERR).add_modifier(Modifier::BOLD),
)
} else {
(desc, Style::default().fg(theme::MUTED))
};
let name_style = if sealed {
Style::default().fg(theme::ERR)
} else {
Style::default().fg(CYAN)
};
Row::new(vec![
Cell::from(n.clone()).style(name_style),
Cell::from(tier).style(tier_style),
Cell::from(scope).style(Style::default().fg(theme::TEXT)),
Cell::from(reason).style(Style::default().fg(theme::MUTED)),
Cell::from(desc).style(desc_style),
])
})
.collect();
let widths = [
Constraint::Length(22),
Constraint::Length(8),
Constraint::Length(12),
Constraint::Length(8),
Constraint::Min(10),
];
let table = Table::new(rows, widths)
.header(header)
.block(block)
.column_spacing(2)
.row_highlight_style(theme::selected_row())
.highlight_symbol("> ");
frame.render_stateful_widget(table, area, &mut scr.list_state);
}
if let Some(reveal) = &scr.reveal {
let value = if reveal.masked {
mask(&reveal.value)
} else {
reveal.value.to_string()
};
let lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
reveal.name.clone(),
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(Span::styled(
format!(" {value}"),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
if reveal.masked {
" (hidden — press space to reveal)"
} else {
" (press space to hide)"
},
Style::default().fg(DIM),
)),
];
let popup = centered_rect(60, 40, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Secret value ")
.border_style(Style::default().fg(CYAN));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
popup,
);
}
if let Some(name) = &scr.pending_delete {
let lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" Delete secret "),
Span::styled(
name.clone(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("?"),
]),
Line::from(""),
Line::from(Span::styled(
" y = delete n / esc = cancel",
Style::default().fg(DIM),
)),
];
let popup = centered_rect(50, 30, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Confirm delete ")
.border_style(Style::default().fg(Color::Red));
frame.render_widget(
Paragraph::new(lines)
.block(block)
.alignment(Alignment::Left),
popup,
);
}
}
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(vertical[1])[1]
}