Skip to content

Commit af15447

Browse files
Merge pull request #18 from Spacecraft-Software/tui-about
feat(vault-tui): About overlay (Standard §13.2)
2 parents b2768b1 + b0c39a4 commit af15447

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

13+
- **TUI About overlay.** `vault-tui` gains a read-only About screen (open with
14+
`?` or `:about`, dismiss with `Esc`/`q`) showing the version, maintainer,
15+
copyright/license, and canonical URL — rendered from the same
16+
`ATTRIBUTION`/`PKG_VERSION` constants as `vault-tui --version`, so it can't
17+
drift. This was the last unfilled v0.1 success criterion that the Standard
18+
§13.2 block appears "in `--version`, `--help`, README, **and the TUI About
19+
screen**" (PRD §14). New `InputMode::About` + `render_about` (modeled on the
20+
generator overlay); a `TestBackend` test asserts the §13.2 content reaches the
21+
screen.
22+
1323
- **`clipboard.backend` selection.** New config key choosing how the TUI's copy
1424
keys reach a clipboard: `auto` (default — native `arboard`, else the TUI falls
1525
back to OSC52), `arboard` (force native; warns if unavailable), or `osc52`

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ agent: `status` / `unlock` / `lock` / `sync` / `list` / `get` / `add` /
2020
`edit` / `remove` / `generate` / `stop-agent` on the CLI (with `--json`
2121
everywhere), and a three-pane `vault-tui` with search, reveal/copy
2222
(agent-side clipboard, 30 s auto-clear), a generator overlay, a `:` command
23-
line, and add/edit/delete. The CLI auto-starts the agent when needed, and
23+
line, add/edit/delete, and an About overlay (`?` / `:about`). The CLI
24+
auto-starts the agent when needed, and
2425
once you've unlocked online at least once, `unlock` also works **offline**
2526
from an encrypted local cache (read/copy from cache; sync and edits need the
2627
network). See [PRD §12](./PRD.md#12-milestones) for the roadmap (M0 → v0.1)

crates/vault-tui/src/app.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ pub enum InputMode {
210210
ConfirmDelete,
211211
/// The agent is locked — keys edit the master-password / PIN entry.
212212
Unlock,
213+
/// `?` pressed — the read-only About overlay is open.
214+
About,
213215
}
214216

215217
/// Which pane currently takes navigation keys.
@@ -1051,7 +1053,10 @@ impl App {
10511053
.and_then(FormState::focused_field_mut)
10521054
.map(|f| &mut f.value),
10531055
InputMode::Unlock => self.unlock.as_mut().map(|u| &mut u.secret),
1054-
InputMode::Normal | InputMode::Generate | InputMode::ConfirmDelete => None,
1056+
InputMode::Normal
1057+
| InputMode::Generate
1058+
| InputMode::ConfirmDelete
1059+
| InputMode::About => None,
10551060
}
10561061
}
10571062

@@ -1180,6 +1185,16 @@ impl App {
11801185
self.mode = InputMode::Normal;
11811186
}
11821187

1188+
/// Open the read-only About overlay (`?` / `:about`).
1189+
pub const fn open_about(&mut self) {
1190+
self.mode = InputMode::About;
1191+
}
1192+
1193+
/// Close the About overlay, back to browsing.
1194+
pub const fn close_about(&mut self) {
1195+
self.mode = InputMode::Normal;
1196+
}
1197+
11831198
/// Replace the overlay's password with a fresh one under the same options.
11841199
pub fn regenerate(&mut self) {
11851200
if let Some(g) = self.generator.as_mut() {
@@ -1777,6 +1792,15 @@ mod tests {
17771792
assert!(app.command.is_empty());
17781793
}
17791794

1795+
#[test]
1796+
fn about_overlay_open_and_close() {
1797+
let mut app = App::browsing(status(), vec![entry("a", None)]);
1798+
app.open_about();
1799+
assert_eq!(app.mode, InputMode::About);
1800+
app.close_about();
1801+
assert_eq!(app.mode, InputMode::Normal);
1802+
}
1803+
17801804
#[test]
17811805
fn generator_opens_with_defaults_and_regenerates() {
17821806
let mut app = App::browsing(status(), vec![entry("a", None)]);

crates/vault-tui/src/main.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,17 @@ use vault_ipc::{default_socket_path, sanitize_socket_path};
4343

4444
use app::{App, FormKind, FormSubmit, InputMode, RevealedSecret, UnlockState};
4545

46-
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
46+
pub(crate) const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
4747

4848
/// Seconds before the TUI's own OSC52 fallback clear fires. Agent-side copies
4949
/// use the agent's configured default (`--clipboard-clear-secs`, reported back
5050
/// in `Response::Copied`); this constant only governs the terminal-clipboard
5151
/// path, where the TUI runs the timer itself.
5252
const COPY_CLEAR_SECS: u64 = 30;
5353

54-
/// Standard §13.2 attribution block — surfaced via `--version` and `--help`.
55-
const ATTRIBUTION: &str = "\
54+
/// Standard §13.2 attribution block — surfaced via `--version`, `--help`, and
55+
/// the TUI About overlay (`?` / `:about`).
56+
pub(crate) const ATTRIBUTION: &str = "\
5657
Maintained by Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
5758
Copyright (C) 2026 Mohamed Hammad & Spacecraft Software | License: GPL-3.0-or-later
5859
https://Vault.SpacecraftSoftware.org/";
@@ -248,6 +249,7 @@ async fn handle_key(state: &mut App, key: KeyEvent, socket: &Path) {
248249
InputMode::Form => handle_form_key(state, key, socket).await,
249250
InputMode::ConfirmDelete => handle_confirm_key(state, key, socket).await,
250251
InputMode::Unlock => handle_unlock_key(state, key, socket).await,
252+
InputMode::About => handle_about_key(state, key),
251253
}
252254
}
253255

@@ -319,6 +321,15 @@ async fn handle_normal_key(state: &mut App, key: KeyEvent, socket: &Path) {
319321
KeyCode::Char('a') => state.open_add_form(),
320322
KeyCode::Char('e') => state.open_edit_form(),
321323
KeyCode::Char('d') => state.open_confirm_delete(),
324+
KeyCode::Char('?') => state.open_about(),
325+
_ => {}
326+
}
327+
}
328+
329+
/// About-overlay keys — read-only; any of these dismiss it.
330+
const fn handle_about_key(state: &mut App, key: KeyEvent) {
331+
match key.code {
332+
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | '?') => state.close_about(),
322333
_ => {}
323334
}
324335
}
@@ -540,7 +551,10 @@ async fn execute_command(state: &mut App, socket: &Path, cmd: &str) {
540551
Err(e) => state.set_toast(e.to_string()),
541552
}
542553
}
543-
other => state.set_toast(format!("unknown command: {other} (q · r · sync · lock)")),
554+
"about" => state.open_about(),
555+
other => state.set_toast(format!(
556+
"unknown command: {other} (q · r · sync · lock · about)"
557+
)),
544558
}
545559
}
546560

crates/vault-tui/src/ui.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ pub fn render(frame: &mut Frame, app: &App) {
7777
InputMode::Generate => render_generator(frame, app, body),
7878
InputMode::Form => render_form(frame, app, body),
7979
InputMode::ConfirmDelete => render_confirm(frame, app, body),
80+
InputMode::About => render_about(frame, body),
8081
InputMode::Normal | InputMode::Search | InputMode::Command | InputMode::Unlock => {}
8182
}
8283
render_status_bar(frame, app, status_bar);
@@ -310,6 +311,49 @@ const fn onoff(b: bool) -> &'static str {
310311
if b { "on" } else { "off" }
311312
}
312313

314+
/// Centered read-only About overlay (Standard §13.2), drawn over the browser.
315+
/// Renders from `crate::PKG_VERSION` + `crate::ATTRIBUTION` so it can't drift
316+
/// from `vault-tui --version`.
317+
fn render_about(frame: &mut Frame, area: Rect) {
318+
let amber = hex(steelbore::MOLTEN_AMBER);
319+
let steel = hex(steelbore::STEEL_BLUE);
320+
let info = hex(steelbore::INFO);
321+
let mut lines = vec![
322+
Line::from(""),
323+
Line::from(Span::styled(
324+
format!("Vault v{}", crate::PKG_VERSION),
325+
Style::default().fg(amber).add_modifier(Modifier::BOLD),
326+
)),
327+
Line::from(""),
328+
];
329+
lines.extend(
330+
crate::ATTRIBUTION
331+
.lines()
332+
.map(|l| Line::from(Span::styled(l.to_owned(), Style::default().fg(info)))),
333+
);
334+
lines.push(Line::from(""));
335+
lines.push(Line::from(Span::styled(
336+
"Esc close",
337+
Style::default().fg(steel).add_modifier(Modifier::ITALIC),
338+
)));
339+
let block = Block::default()
340+
.borders(Borders::ALL)
341+
.border_style(Style::default().fg(amber))
342+
.title(" About ")
343+
.style(Style::default().bg(hex(steelbore::VOID_NAVY)));
344+
// Wider/taller than the generator overlay so the long §13.2 lines fit
345+
// without clipping the URL or hint.
346+
let overlay = centered(area, 80, 60);
347+
frame.render_widget(ratatui::widgets::Clear, overlay);
348+
frame.render_widget(
349+
Paragraph::new(lines)
350+
.alignment(Alignment::Center)
351+
.wrap(Wrap { trim: true })
352+
.block(block),
353+
overlay,
354+
);
355+
}
356+
313357
/// Centered add/edit form overlay, drawn over the browser.
314358
fn render_form(frame: &mut Frame, app: &App, area: Rect) {
315359
let Some(form) = app.form.as_ref() else {
@@ -450,7 +494,8 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) {
450494
| InputMode::Generate
451495
| InputMode::Form
452496
| InputMode::ConfirmDelete
453-
| InputMode::Unlock => None,
497+
| InputMode::Unlock
498+
| InputMode::About => None,
454499
};
455500
if let Some(input) = editing {
456501
spans.push(Span::styled(
@@ -770,4 +815,28 @@ mod tests {
770815
assert!(text.contains(&pw), "generated password missing:\n{text}");
771816
assert!(text.contains("Length 20"), "options line missing:\n{text}");
772817
}
818+
819+
#[test]
820+
fn about_overlay_renders_attribution() {
821+
let mut app = App::browsing(status(), vec![login_entry()]);
822+
app.open_about();
823+
let text = draw(&app);
824+
assert!(text.contains("About"), "overlay title missing:\n{text}");
825+
assert!(
826+
text.contains(&format!("v{}", crate::PKG_VERSION)),
827+
"version missing:\n{text}"
828+
);
829+
assert!(
830+
text.contains("Mohamed Hammad"),
831+
"maintainer missing:\n{text}"
832+
);
833+
assert!(
834+
text.contains("GPL-3.0-or-later"),
835+
"license missing:\n{text}"
836+
);
837+
assert!(
838+
text.contains("SpacecraftSoftware.org"),
839+
"URL missing:\n{text}"
840+
);
841+
}
773842
}

0 commit comments

Comments
 (0)