diff --git a/PLANS.md b/PLANS.md index 05d185a..fc1b372 100644 --- a/PLANS.md +++ b/PLANS.md @@ -118,7 +118,8 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo | 6 | Client configuration delivery and reissue lifecycle | 5 | accepted | | 7 | Complete terminal management workflow | 6 | accepted | | 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted | -| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | in_progress | +| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | retired | +| 10 | Visible styled keyboard quick reference in bottom panel | 8; existing M7 TUI | in_progress | ### Milestone 1 — Rust foundation, settings, and domain states @@ -2977,12 +2978,14 @@ review closed all actionable findings and the host and repeated real-container gates passed on one source fingerprint. M8-A1–A10 are satisfied within that scope. IPv6 capability and traffic were excluded by user direction; real systemd/D-Bus behavior remains unverified and belongs to M9. -Milestone 9 remains pending. +Milestone 9 was later retired by user direction; this historical M8 acceptance +does not establish real systemd/D-Bus behavior. ### Milestone 9 — Real systemd acceptance and release readiness -Status: paused (2026-10-26T20:30:15Z; user explicitly stopped Milestone 9 as -over-engineering). No VM acceptance result exists. +Status: retired (2026-10-26T20:52:01Z; user judged the milestone goal +unnecessary over-engineering and removed it from the development sequence). +No M9 acceptance result exists; real systemd/D-Bus behavior remains unverified. Prerequisites: milestones 1–8 accepted; explicitly selected disposable Linux VM with its own kernel, systemd as PID 1, system D-Bus, WireGuard and synthetic data; compatible pinned-toolchain artifacts and a bounded management/console channel. @@ -3261,22 +3264,119 @@ service callback is not systemd evidence. Carry forward that distinction. Initia M9 evidence is unavailable until actual guest execution; record missing historical inputs as unavailable rather than reconstructing them. -Next action: none. User stopped Milestone 9. Do not restart the VM or resume -M9.1–M9.3 without a new explicit request. No M9-A1–A9 check is complete. +Next action: none for M9. The user retired it and authorized development to +continue with M10. Do not restart the VM or resume M9 work without a new explicit +request. No M9-A1–A9 check is complete. #### Outcome -Paused at the user's request. The separate disposable Debian VM +Retired at the user's request. The separate disposable Debian VM `b14c850b-a21e-4a56-acca-785590f0c4ac` was powered off; a read-only running-VM inventory was empty afterward. M9.1 remains incomplete, M9.2–M9.3 have not run, and M9-A1–A9 remain unchecked. Prior host gates and M9.4 documentation passed; no release acceptance or deployment is claimed. Real IPv6 remains separately unverified by the previously accepted test scope. +### Milestone 10 — Visible styled keyboard quick reference in bottom panel + +Status: in_progress (2026-10-26T20:52:01Z). The user retired M9 and explicitly +directed development to continue. M1–M8 are accepted; M9 is no longer a +prerequisite. M10 implementation, review, and validation are starting now. + +#### Outcome and scope + +Display discoverable keyboard controls inside the bordered panel at the very +bottom of the terminal. Users should be able to find routine operations without +opening Help. Render each hint as a distinct key label followed by its action, +with bold/color emphasis on keys and readable action text. Keep UI text in +English, following repository conventions. These are keyboard hints; adding +mouse interaction is outside this milestone. + +The present `src/tui/render.rs::draw` reserves only two rows for a fully bordered +footer. Its top and bottom borders leave no interior row, hiding the existing +plain shortcut text. Repair layout as well as styling; merely recoloring that +paragraph cannot meet the requirement. + +#### Context and implementation steps + +| Task | Owner | Scope and expected result | State | +| --- | --- | --- | --- | +| M10.1 | implementer | Inspect `src/tui/render.rs`, `state.rs`, `forms.rs` and `terminal.rs`; derive hints from actual supported keys and screen/form state. Implement the bottom panel with sufficient interior height, styled key/action groups and width-aware packing. | needs_changes: initial 7 render tests passed; independent R1/R2 found compact/form key-hint mismatches; targeted repair pending | +| M10.2 | implementer | Cover resize, narrow/minimum terminals, list/details, forms, progress and QR disclosure with synthetic render/navigation checks. Update README shortcut guidance if needed. | pending | +| M10.3 | reviewer / repairer | Independent review of actual layout, hints versus handlers, readability, clipping and state correctness; repair and re-review actionable findings. | pending | +| M10.4 | coordinator | Run integrated gates, record source/artifact identity and actual results, maintain this plan and make the scoped acceptance decision. | pending | + +List/details quick reference must include navigation (`Up/Down`, `Enter`, `Esc` +as applicable), `?` Help and `q` Exit, plus the implemented management keys: +`n` New, `r` Rename, `d` Delete, `p` Preview, `c` Commit, `x` Discard, +`R` Restart, `o` First file, `O` Reissue, `b` Backups, `B` Backup, +`s` Setup and `i` Import. Distinguish uppercase from lowercase visibly. +Context-dependent actions must be omitted or explicitly marked unavailable +when their real prerequisites are not met. Form hints must reflect their actual +Enter/Tab/Esc behavior; busy screens must not advertise blocked submissions. +QR disclosure must retain a visible hint for clearing it with Esc. + +Use the existing Ratatui styles and dependencies. Select contrasting styles for +navigation, ordinary actions and destructive actions, while keeping key/action +text sufficient without color. Adapt panel height to packed rows and terminal +height, preserve usable main content, and retain the existing too-small-terminal +fallback. At narrow widths prioritize navigation and Help, showing an explicit +Help/overflow hint for omitted commands rather than silently clipping them. +Keep Help as the complete reference and preserve all existing operation semantics. + +#### Acceptance and validation + +- [ ] M10-A1: the lowest bordered panel has visible interior text; at 80x24 all + applicable list actions are displayed in readable packed rows. +- [ ] M10-A2: key labels are visually distinct with bold/color styling; action + text is readable, and meaning remains understandable without color. +- [ ] M10-A3: hints match actual key handlers, uppercase/lowercase and active + screen/form prerequisites; no hint implies a disabled operation can run. +- [ ] M10-A4: resizing through 120x30, 80x24, 40x8 and the supported minimum + 34x8 causes no panic, overlapping borders or invisible footer text; compact + layouts retain navigation/Help and indicate omitted commands. Below-minimum + sizes retain a readable resize message. +- [ ] M10-A5: list/detail navigation, forms, progress and QR remain usable; + footer rendering exposes no secrets and performs no application mutations. +- [ ] M10-A6: focused synthetic rendering checks and all required Rust gates + pass; independent review closes actionable findings with recorded evidence. + +Planned validation: `cargo test --locked tui::`, +`cargo test --locked --test tui_workflow`, then `cargo fmt --all -- --check`, +`cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, +`cargo build --locked --release` and `git diff --check`. Use synthetic fixtures +and Ratatui test buffers; inspect styles as well as rendered text and geometry. +No production or VM operations are needed for this presentation change. These +checks have not been run for M10; no implementation or acceptance is claimed. + +#### Review, evidence and handoff + +M10.1 initial implementation added visible styled footer rows and passed +`cargo test --locked tui::render` (7/7), `cargo fmt --all -- --check`, and +`git diff --check`. Independent review found **M10.1-R1 (P2, open)**: compact +rows advertise list navigation/Help during forms, QR, and pending work; +**M10.1-R2 (P2, open)**: generic form Enter is false for draft review and Tab +is missing from tabbed forms. Targeted repair/re-review is next; no acceptance +item is closed yet. + +The user removed M9 as +a prerequisite without accepting its unchecked M9-A1–A9 criteria. The project +configuration specifies GPT-6-Sol/medium for the coordinator and GPT-6-Luna/high +for all four custom roles. Start with M10.1, one writer at a time. The +coordinator owns this plan; record detailed evidence in +`docs/evidence/milestone-10.md` only after actual checks exist. No new +dependencies are planned. + +#### Outcome + +Milestone in progress; no M10 acceptance is claimed. Next action: implement +M10.1, then independent review/repair and M10.2–M10.4 checks. M9 stays retired. + ## Current progress and resume point - Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change. -- Milestones 1–8 are accepted; milestone 9 is paused by the user and unaccepted. +- Milestones 1–8 are accepted; milestone 9 is retired by user direction and unaccepted. Its real systemd/D-Bus checks remain unverified and are not a prerequisite to M10. +- Milestone 10 is in progress: implement the styled keyboard quick reference in the bottom bordered panel, then review and validate it. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. @@ -3288,8 +3388,8 @@ Real IPv6 remains separately unverified by the previously accepted test scope. - Milestone 7 delivered the native terminal workflow, safe startup/session projection, bounded single-owner worker, setup/import/draft/Commit/restart/delivery/backup/restore forms, synthetic PTY and workflow tests, and documentation. Independent R2 closed its list-status finding; all planned coordinator gates passed. Real WireGuard and systemd remain M8/M9. - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. -- Milestone 9 is paused and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. -- Next action: none unless the user explicitly requests more Milestone 9 work. Do not restart the VM, access production, commit, or deploy. +- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. +- Next action: execute M10.1. Do not restart the VM, access production, commit, or deploy. ## Plan maintenance diff --git a/src/tui/forms.rs b/src/tui/forms.rs index 93a95c8..a560c2d 100644 --- a/src/tui/forms.rs +++ b/src/tui/forms.rs @@ -39,6 +39,14 @@ pub enum FormAction { Feedback(String), } +/// Public, secret-free summary of keyboard capabilities for the active form. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FormKeyContext { + pub enter_action: Option<&'static str>, + pub supports_tab: bool, + pub supports_selection: bool, +} + enum Form { Setup { password: SecretInput, @@ -153,6 +161,67 @@ impl FormController { matches!(self.active, Some(Form::DraftReview)) } + /// Describes only keys handled by the active form; never exposes form data. + pub fn key_context(&self) -> Option { + self.active.as_ref().map(|form| match form { + Form::Setup { .. } => FormKeyContext { + enter_action: Some("next / submit"), + supports_tab: true, + supports_selection: false, + }, + Form::Create { .. } => FormKeyContext { + enter_action: Some("next / submit"), + supports_tab: true, + supports_selection: false, + }, + Form::Rename { .. } => FormKeyContext { + enter_action: Some("submit"), + supports_tab: false, + supports_selection: false, + }, + Form::Import { .. } => FormKeyContext { + enter_action: Some("submit"), + supports_tab: false, + supports_selection: false, + }, + Form::Confirm(_) => FormKeyContext { + enter_action: Some("confirm"), + supports_tab: false, + supports_selection: false, + }, + Form::DraftReview => FormKeyContext { + enter_action: None, + supports_tab: false, + supports_selection: false, + }, + Form::DeliveryPath { .. } => FormKeyContext { + enter_action: Some("prepare file"), + supports_tab: true, + supports_selection: false, + }, + Form::ReissuePassword { .. } => FormKeyContext { + enter_action: Some("submit password"), + supports_tab: false, + supports_selection: false, + }, + Form::Backups { items, .. } => FormKeyContext { + enter_action: (!items.is_empty()).then_some("preview restore"), + supports_tab: false, + supports_selection: !items.is_empty(), + }, + Form::Restore(_) => FormKeyContext { + enter_action: Some("confirm restore"), + supports_tab: false, + supports_selection: false, + }, + Form::Disclosure { .. } => FormKeyContext { + enter_action: None, + supports_tab: false, + supports_selection: false, + }, + }) + } + pub fn qr_output(&self) -> Option<&QrCodeOutput> { match self.active.as_ref() { Some(Form::Disclosure { qr, .. }) => Some(qr), @@ -888,6 +957,64 @@ mod tests { use super::*; use crate::backup::{BackupReason, HistoricalApplyStatus}; + #[test] + fn key_context_matches_tabbed_forms_and_inert_draft_review() { + let mut form = FormController::default(); + form.open_setup(); + assert_eq!( + form.key_context(), + Some(FormKeyContext { + enter_action: Some("next / submit"), + supports_tab: true, + supports_selection: false, + }) + ); + form.open_create(); + assert!(form.key_context().unwrap().supports_tab); + assert_eq!( + form.key_context().unwrap().enter_action, + Some("next / submit") + ); + form.open_draft_review(); + assert_eq!( + form.key_context(), + Some(FormKeyContext { + enter_action: None, + supports_tab: false, + supports_selection: false, + }) + ); + assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None)); + } + + #[test] + fn delivery_path_and_backup_selection_expose_their_actual_keys() { + let identity = PublicKey::from_bytes([0x61; 32]); + let mut form = FormController::default(); + form.open_delivery(identity, false); + assert!(form.key_context().unwrap().supports_tab); + assert_eq!( + form.key_context().unwrap().enter_action, + Some("prepare file") + ); + form.open_backups(vec![ArchiveSummary { + file_name: "backup.zip".to_owned(), + created_at_utc: "2026-01-01T00:00:00Z".to_owned(), + reason: BackupReason::Manual, + historical_apply: HistoricalApplyStatus::Unknown, + }]); + assert_eq!( + form.key_context(), + Some(FormKeyContext { + enter_action: Some("preview restore"), + supports_tab: false, + supports_selection: true, + }) + ); + form.open_backups(Vec::new()); + assert_eq!(form.key_context().unwrap().enter_action, None); + } + #[test] fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() { let mut form = FormController::default(); diff --git a/src/tui/render.rs b/src/tui/render.rs index e2efbe2..bfc16a8 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -1,7 +1,7 @@ //! Responsive, secret-free rendering for the client list and detail screens. use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; @@ -33,12 +33,27 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) { ); return; } + let compact_height = area.height <= 8; + let header_height = if compact_height { 2 } else { 3 }; + let footer_hints = keyboard_hints(state, forms); + let packed = pack_hints(&footer_hints, area.width.saturating_sub(2) as usize); + let max_footer_rows = area.height.saturating_sub(header_height + 3 + 2).max(1) as usize; + let (footer_lines, omitted) = fit_hint_rows( + packed, + max_footer_rows, + area.width.saturating_sub(2) as usize, + ); + let footer_height = footer_lines + .len() + .saturating_add(2) + .min(area.height.saturating_sub(header_height + 3) as usize) + .max(3) as u16; let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), + Constraint::Length(header_height), Constraint::Min(3), - Constraint::Length(2), + Constraint::Length(footer_height), ]) .split(area); let status = state @@ -108,10 +123,257 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) { Screen::ClientList => render_list(frame, rows[1], state), } } + render_keyboard_panel(frame, rows[2], &footer_lines, omitted); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HintKind { + Navigation, + Action, + Destructive, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Hint { + key: &'static str, + action: &'static str, + kind: HintKind, +} + +fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec { + let mut hints = Vec::new(); + let mut add = |key, action, kind| hints.push(Hint { key, action, kind }); + if forms.is_active() { + if forms.qr_output().is_some() { + add("Esc", "clear QR", HintKind::Destructive); + } else { + add("Esc", "cancel", HintKind::Destructive); + if let Some(context) = forms.key_context() { + if let Some(action) = context.enter_action { + add("Enter", action, HintKind::Action); + } + if context.supports_tab { + add("Tab", "change field", HintKind::Navigation); + } + if context.supports_selection { + add("↑/↓", "select archive", HintKind::Navigation); + } + } + } + return hints; + } + if state.pending.is_some() || state.screen == Screen::Progress { + add("…", "busy", HintKind::Navigation); + return hints; + } + match state.screen { + Screen::Help => { + add("Esc", "return to list", HintKind::Navigation); + add("?", "Help", HintKind::Navigation); + } + Screen::Message => { + add("Esc", "return to list", HintKind::Navigation); + add("↑/↓", "scroll", HintKind::Navigation); + } + Screen::ClientDetails => { + add("Esc", "client list", HintKind::Navigation); + add("↑/↓", "scroll", HintKind::Navigation); + add("?", "Help", HintKind::Navigation); + add("n", "new", HintKind::Action); + add("i", "import", HintKind::Action); + add("p", "preview", HintKind::Action); + add("c", "Commit", HintKind::Action); + add("x", "discard", HintKind::Destructive); + add("R", "restart", HintKind::Destructive); + add("b", "backups / restore", HintKind::Action); + add("B", "backup", HintKind::Action); + add("!", "issues", HintKind::Action); + if state + .view + .as_ref() + .is_some_and(|view| !view.vault_initialized) + { + add("s", "setup", HintKind::Action); + } + if state.selected_client_mutable() { + add("r", "rename", HintKind::Action); + add("d", "delete", HintKind::Destructive); + let saved = state + .selected_identity + .and_then(|id| state.view.as_ref()?.client_states.get(&id)) + .is_some_and(|row| row.saved.is_some()); + if saved { + add("o", "first file", HintKind::Action); + add("O", "password reissue", HintKind::Action); + } + } + } + Screen::ClientList => { + let has_view = state.view.is_some(); + let selected = state.selected_client_mutable(); + add("↑/↓", "select", HintKind::Navigation); + if state + .view + .as_ref() + .is_some_and(|view| !view.clients.is_empty()) + { + add("Enter", "details", HintKind::Navigation); + } + add("?", "Help", HintKind::Navigation); + if has_view { + add("n", "new", HintKind::Action); + if selected { + add("r", "rename", HintKind::Action); + add("d", "delete", HintKind::Destructive); + } + add("p", "preview", HintKind::Action); + add("c", "Commit", HintKind::Action); + add("x", "discard", HintKind::Destructive); + add("R", "restart", HintKind::Destructive); + if let Some(id) = state.selected_identity { + if state + .view + .as_ref() + .and_then(|view| view.client_states.get(&id)) + .is_some_and(|row| row.saved.is_some()) + && selected + { + add("o", "first file", HintKind::Action); + add("O", "password reissue", HintKind::Action); + } + } + add("b", "backups / restore", HintKind::Action); + add("B", "backup", HintKind::Action); + if state + .view + .as_ref() + .is_some_and(|view| !view.vault_initialized) + { + add("s", "setup", HintKind::Action); + } + } + add("i", "import", HintKind::Action); + if has_view { + add("!", "issues", HintKind::Action); + } + } + Screen::Progress => unreachable!(), + } + add("q", "exit", HintKind::Destructive); + hints +} + +fn pack_hints(hints: &[Hint], width: usize) -> Vec> { + let mut rows = Vec::>::new(); + let mut used = 0; + for hint in hints { + let cost = hint.key.len() + hint.action.len() + 3; + if rows.is_empty() || used + cost > width { + rows.push(Vec::new()); + used = 0; + } + if cost > width { + continue; + } + if !rows.last().is_some_and(Vec::is_empty) { + used += 2; + } + used += cost; + rows.last_mut().expect("row was created").push(hint.clone()); + } + rows.retain(|row| !row.is_empty()); + rows +} + +fn fit_hint_rows( + mut rows: Vec>, + max_rows: usize, + width: usize, +) -> (Vec>, bool) { + if max_rows == 1 && width < 40 { + let hints = rows.into_iter().flatten().collect::>(); + let hint_count = hints.len(); + let list_navigation = hints.iter().find(|hint| hint.key == "↑/↓"); + let help = hints.iter().find(|hint| hint.key == "?"); + let mut compact = Vec::new(); + if let Some(navigation) = list_navigation { + compact.push(navigation.clone()); + } + if let Some(help) = help { + compact.push(help.clone()); + } + if compact.is_empty() { + let candidates = hints + .into_iter() + .map(|mut hint| { + hint.action = match hint.key { + "Enter" => "continue", + "Tab" => "field", + _ => hint.action, + }; + hint + }) + .collect::>(); + let total_width = candidates + .iter() + .map(|hint| hint.key.len() + hint.action.len() + 1) + .sum::() + + candidates.len().saturating_sub(1) * 2; + if total_width <= width { + return (vec![candidates], false); + } + let available = width.saturating_sub(" … more".len()); + let mut used = 0; + for hint in candidates { + let cost = hint.key.len() + hint.action.len() + 1; + let with_gap = cost + usize::from(!compact.is_empty()) * 2; + if used + with_gap <= available { + used += with_gap; + compact.push(hint); + } + } + } + let omitted = compact.len() < hint_count; + return (vec![compact], omitted); + } + if rows.len() <= max_rows { + return (rows, false); + } + rows.truncate(max_rows); + (rows, true) +} + +fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec], omitted: bool) { + let lines = rows + .iter() + .map(|row| { + let mut spans = Vec::new(); + for (index, hint) in row.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + let color = match hint.kind { + HintKind::Navigation => Color::Cyan, + HintKind::Action => Color::Green, + HintKind::Destructive => Color::Red, + }; + spans.push(Span::styled( + hint.key, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::raw(hint.action)); + } + if omitted { + spans.push(Span::raw(" … more")); + } + Line::from(spans) + }) + .collect::>(); frame.render_widget( - Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit") - .block(Block::default().borders(Borders::ALL)), - rows[2], + Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title("Keys")), + area, ); } @@ -545,6 +807,178 @@ mod tests { use std::collections::BTreeMap; use std::net::Ipv4Addr; + fn rendered_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn empty_view() -> SessionView { + SessionView { + generation: 0, + clients: Vec::new(), + client_states: BTreeMap::new(), + document_issues: Vec::new(), + applied: super::super::state::AppliedState::Unknown, + reserved_addresses: Default::default(), + statistics: RuntimeObservation::unknown(), + has_draft: true, + vault_initialized: false, + } + } + + #[test] + fn bottom_keyboard_panel_has_visible_interior_and_styled_key_labels() { + let state = UiState { + view: Some(empty_view()), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("Keys")); + assert!(text.contains("new")); + assert!(text.contains("Commit")); + for hint in [ + "R restart", + "b backups / restore", + "B backup", + "s setup", + "i import", + "x discard", + ] { + assert!(text.contains(hint), "missing list hint: {hint}"); + } + let buffer = terminal.backend().buffer(); + assert!(buffer[(0, 23)].symbol().contains('└')); + let key = (0..buffer.area.height) + .flat_map(|y| (0..buffer.area.width).map(move |x| (x, y))) + .find(|(x, y)| { + let style = buffer[(*x, *y)].style(); + style.fg == Some(Color::Green) && style.add_modifier.contains(Modifier::BOLD) + }) + .unwrap(); + let style = buffer[key].style(); + assert!(style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(style.fg, Some(Color::Green)); + } + + #[test] + fn compact_layout_keeps_footer_text_and_navigation_help_priority() { + let state = UiState { + view: Some(empty_view()), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("Keys")); + assert!(text.contains("select")); + assert!(text.contains("? Help")); + assert!(text.contains("… more")); + assert!(text.lines().last().unwrap().contains('└')); + } + + #[test] + fn compact_form_hints_follow_the_active_form_without_leaking_input() { + let state = UiState::default(); + let mut forms = FormController::default(); + forms.open_setup(); + for character in "secret-value".chars() { + forms.handle_key(crossterm::event::KeyCode::Char(character)); + } + let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap(); + terminal.draw(|frame| draw(frame, &state, &forms)).unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("Esc cancel")); + assert!(text.contains("Enter continue")); + assert!(text.contains("Tab field")); + assert!(!text.contains("secret-value")); + assert!(!text.contains("↑/↓")); + assert!(!text.contains("? Help")); + } + + #[test] + fn compact_pending_panel_does_not_offer_commands() { + let state = UiState { + screen: Screen::Progress, + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let text = rendered_text(&terminal); + assert!(text.contains("busy")); + assert!(!text.contains("↑/↓")); + assert!(!text.contains("? Help")); + assert!(!text.contains("Enter")); + } + + #[test] + fn detail_hints_only_offer_saved_delivery_for_mutable_saved_selection() { + let identity = PublicKey::from_bytes([3; 32]); + let client = crate::domain::Client::new( + identity, + "client", + crate::domain::ClientAddresses { + ipv4: Ipv4Addr::new(10, 0, 0, 2), + ipv6: None, + }, + ) + .unwrap(); + let view = SessionView { + generation: 0, + clients: vec![ClientDisplay { + peer_index: 0, + line: 1, + identity: Some(identity), + label: "client".to_owned(), + allowed_ips: vec![], + issues: vec![], + mutable_identity: Some(identity), + }], + client_states: BTreeMap::from([( + identity, + super::super::state::ClientStateRow { + saved: Some(client.clone()), + draft: Some(client), + }, + )]), + document_issues: Vec::new(), + applied: super::super::state::AppliedState::Unknown, + reserved_addresses: Default::default(), + statistics: RuntimeObservation::unknown(), + has_draft: false, + vault_initialized: true, + }; + let state = UiState { + screen: Screen::ClientDetails, + selected_identity: Some(identity), + view: Some(view), + ..UiState::default() + }; + let labels = keyboard_hints(&state, &FormController::default()) + .iter() + .map(|hint| hint.key) + .collect::>(); + assert!(labels.contains(&"Esc")); + assert!(labels.contains(&"o")); + assert!(labels.contains(&"O")); + assert!(labels.contains(&"r")); + assert!(labels.contains(&"d")); + } + #[test] fn terminal_controls_are_escaped_for_display() { assert_eq!(