Improve application help

This commit is contained in:
2026-10-29 00:01:35 +02:00
parent 4960c65f65
commit 23d6180f24
6 changed files with 1126 additions and 48 deletions
+73 -2
View File
@@ -126,7 +126,7 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
| 14 | Restart verification for valid legacy peers | 13 | accepted | | 14 | Restart verification for valid legacy peers | 13 | accepted |
| 15 | Diagnosable systemd restart and unloaded-unit resolution | 14 | accepted | | 15 | Diagnosable systemd restart and unloaded-unit resolution | 14 | accepted |
| 16 | Root systemd private peer restart transport | 15 | accepted | | 16 | Root systemd private peer restart transport | 15 | accepted |
| 17 | Adaptive client list, persistent details and clear states | 16 accepted; existing M7/M12 TUI | accepted | | 17 | Adaptive client list, persistent details and clear states | 16 accepted; existing M7/M12 TUI | accepted || 18 | Readable, complete in-app help for implemented workflows | 17 | accepted |
### Milestone 1 — Rust foundation, settings, and domain states ### Milestone 1 — Rust foundation, settings, and domain states
@@ -3796,8 +3796,79 @@ The user's requested subset is adaptive columns, persistent details and clear st
Accepted for the presentation and synthetic fixture scope after independent review, R1/R2 closure and pinned Rust gates. No real WireGuard/systemd, Alpine, disposable-VM or manual terminal-emulator behavior is claimed. Stop after M17; do not commit, deploy, resume retired M9 VM work or access production. Accepted for the presentation and synthetic fixture scope after independent review, R1/R2 closure and pinned Rust gates. No real WireGuard/systemd, Alpine, disposable-VM or manual terminal-emulator behavior is claimed. Stop after M17; do not commit, deploy, resume retired M9 VM work or access production.
### Milestone 18 — Readable, complete in-app help for implemented workflows
Status: accepted (2026-10-29T00:00:18+02:00) after independent review, R1/R2 repair and re-review, and pinned Rust gates on final inputs. Evidence: [M18](docs/evidence/milestone-18.md).
Prerequisites: M17 accepted; existing TUI and pinned Rust environment. The user previously confirmed this coordinator session is GPT-6-Sol/medium; project configuration and role definitions specify GPT-6-Luna/high for implementer, reviewer and repairer. M9 remains retired by user direction.
#### Outcome and scope
Replace the `?` screen's dense prose with a readable English guide to the application, its implemented functions, and exact keyboard workflows. The guide must describe actual shipped behavior, including M17 adaptive list/details and existing footer, rather than planned features. Follow [SPEC](docs/SPEC.md), [CONTEXT](CONTEXT.md), all three [ADRs](docs/adr/), and development/testing/execution-record rules. Do not change product requirements or claim real-system validation from fixture evidence.
Scope: help content, styling, responsive layout, help navigation, focused tests, and synchronization of relevant user-facing documentation. Preserve actions, authorization, drafts, saved/applied semantics, forms, QR privacy and the stable footer. No new management commands, dependencies, runtime transport work, search/sorting, localization system or application log/history feature. CLI `--help` is not the target of this milestone.
#### Context and implementation steps
At the start of M18, `src/tui/render.rs::render_help` rendered a single literal containing long paragraphs and an inline shortcut list. The draw branch called it without UiState; `src/tui/terminal.rs` changed `scroll_offset` for Help but the renderer did not consume it, and page keys moved one line. Help footer hints omitted scrolling. The implementation corrected these behaviors after tracing current terminal/form handlers and guards.
Read before dispatch: AGENTS.md, docs/SPEC.md, CONTEXT.md, docs/adr/0001-explicit-restart.md, docs/adr/0002-client-secret-storage.md, docs/adr/0003-server-config-authority.md, docs/IMPLEMENTATION.md, docs/EXECUTION_RECORDS.md, docs/DEVELOPMENT.md, docs/TESTING.md, docs/ENVIRONMENT.md, docs/DEPENDENCIES.md, docs/PROJECT_LAYOUT.md and docs/CODEX.md. Coordinator owns plan and acceptance. Use one implementation writer, configured GPT-6-Luna/high implementer, a separate reviewer, and repairer for actionable findings; verify actual model prerequisites without silent substitution.
| Task | Owner | State | Owned scope and deliverable |
| --- | --- | --- | --- |
| M18.1 | implementer | complete | Read-only handler/form/guard audit; shortcut inventory in [evidence](docs/evidence/milestone-18.md) |
| M18.2 | implementer | complete | `src/tui/render.rs`: nine structured sections, styled/wrapped visual rows, row-range title, Help footer and shared navigation metrics |
| M18.3 | implementer | complete | `src/tui/terminal.rs`, tests and README: bounded visual-row/page scrolling, resize, reset/Esc; synthetic PTY Help flow |
| M18.4 | independent reviewer; repairer | complete | M18-R1 and R2 repaired by separate repairer and independently re-reviewed closed |
| M18.5 | coordinator | complete | Integrated gates passed on R2 repair; final source/binary identities and limits in [M18 evidence](docs/evidence/milestone-18.md); accepted |
1. Inventory actual global, screen-specific and form-specific shortcuts and their availability conditions. Cover `?`, arrows, Enter, Esc, q/handled Ctrl-C; n/r/d; p/c/x/R; o/O; b/B; s/i/!. Verify exact setup/import/delivery/confirmation behavior in forms instead of guessing field navigation. Explicitly distinguish lowercase/uppercase pairs. A help entry documents existing availability; it must not enable an action where it was previously unavailable.
2. Organize content with a short application introduction and these clearly separated sections: quick start; list and selected-client details; Draft/Saved/Applied states and statistics; client creation/rename/deletion; preview/Commit/discard/explicit restart; first delivery/password reissue/file and optional QR; backup/previewed restore; vault setup/import/anomalies; navigation, cancellation and exit. Each action gets a visible key, short purpose, prerequisites where applicable, and expected result. Include concise walkthroughs: create -> preview -> Commit -> first delivery -> explicit restart (delivery warns until verification), change/delete -> Commit -> restart, reissue with password, and backup selection -> restore preview -> confirmation -> separate restart when desired.
3. Explain that aliases may repeat and public keys identify clients; inspection-only restrictions differ from malformed configuration. Explain unknown/unavailable/verified state without implying that Saved or a handshake means verified access. Handshakes and RX/TX are current read-only observations, not online status or historical totals. Commit and restore save files only; mandatory backups stop protected actions on failure; deleted addresses remain reserved until verified restart. Explain per-client password reissue, first-delivery retry within its creation session, original imported-file preservation, masked password entry and Esc clearing explicit QR disclosure. Use concise practical language, not internal Rust enums, architecture details or promises about unsupported features.
4. Render section headings, whitespace, aligned key/action entries and wrapped explanatory text with theme-safe styles. Colors supplement structure. Prefer a single ordered vertical document, avoiding dense multi-column prose on wide screens. Use display-cell widths and the actual inner viewport; long explanations must wrap without key labels colliding. Keep essential help navigation visible in the existing fixed-height footer, with a position/continuation indicator when content exceeds the viewport. Do not expand the footer or move the list layout based on help length.
5. Wire Help to its scroll position. Calculate wrapped visual rows consistently for rendering, maximum offset and page movement; clamp at both ends, prevent scrolling to a blank page, and clamp after resize. Up/Down move one visual row, PageUp/PageDown move a viewport page with useful overlap if chosen and documented. Opening `?` starts at the top; Esc returns to the client list preserving the selected client and drafts. Reset/clamp help position without leaking it into other screens. Avoid unsolicited changes to non-Help scrolling or action dispatch. Every section must remain reachable on supported small terminals; retain the existing terminal-too-small guard.
#### Acceptance and validation
- [x] M18-A1: content accurately describes the implemented application and all listed shortcut families; inventory maps entries to current handlers/guards. R2 completed required state/password/delivery explanations and independent re-review closed it.
- [x] M18-A2: introduction, section headings, key/action entries, workflow steps and warnings are visually distinguishable in wide and narrow synthetic buffers; key case is unambiguous and text is neither overlapped nor silently inaccessible.
- [x] M18-A3: scrolling visibly changes the rendered Help, reaches the last section, stops at valid bounds, pages by viewport rather than one line, and remains valid after shrinking/growing the terminal. Footer accurately advertises available Help navigation.
- [x] M18-A4: `?` resets Help to its beginning and Esc restores the list with the same selected identity and unchanged draft/saved/applied state. Opening, reading and closing Help performs no file, vault, service or runtime mutation. Existing action/form/QR behavior and fixed two-row footer remain intact.
- [x] M18-A5: cover 34x8, 40x9, 60x16, 80x24, 100x30 and 180x50 with synthetic buffers, plus focused navigation/workflow checks. Verify all help sections can be reached; prefer semantic/geometry assertions over brittle full-document snapshots. Fixtures contain no production keys, payloads or QR matrices.
- [x] M18-A6: independent content/implementation review closed R1/R2; focused tests and required integrated Rust gates passed on final inputs, with evidence and user documentation updated.
Validation: pinned Rust/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97; `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release` and `git diff --check` all exited 0 on final inputs. Focused `cargo test --locked tui::` passed 79; `cargo test --locked --test tui_workflow` passed 8. Full suite passed 392 with 3 explicitly ignored. Exact source/artifact identities and synthetic/PTY limits are in [M18 evidence](docs/evidence/milestone-18.md). No Docker, root, VM or production checks were run for this presentation milestone.
#### Progress
- [x] M18.1: current shortcut/form/guard audit completed without edits; content inventory recorded in [evidence](docs/evidence/milestone-18.md).
- [x] M18.2: structured Help rendering and focused buffer tests completed in `src/tui/render.rs`; coordinator inspected changed-file scope and the navigation-metrics interface.
- [x] M18.3: visual-row/page scrolling, bounds, resize, `?` reset, Esc preservation and synthetic PTY Help flow completed; coordinator inspected terminal/README/workflow diff.
- [x] M18.4: independent review found R1 in the narrow footer; coordinator audit and reviewer confirmed R2 content gaps. A separate repairer fixed both, and independent re-review closed both with focused regressions.
- [x] M18.5: final integrated pinned Rust gates and release build passed; refreshed source/binary identity and limitations are in [M18 evidence](docs/evidence/milestone-18.md). Milestone accepted.
#### Review and repair
| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State |
| --- | --- | --- | --- | --- | --- |
| M18-R1 (P2) | R1 | `src/tui/render.rs`: 34×8 compact Help footer omitted Esc return key | repairer | Help-specific narrow packing keeps scroll/Esc and honest overflow; 34×8/40×9 regression passed; independent re-review closed | closed |
| M18-R2 (P2) | coordinator audit | `src/tui/render.rs::help_document`: required password, observation and delivery-warning explanations were incomplete | reviewer; repairer | Four claims and 34×8 reachability test added; focused test passed and independent re-review closed | closed |
#### Evidence and handoff
Final [M18 evidence](docs/evidence/milestone-18.md) records shortcut audit, fingerprinted source/release binary, 79 focused TUI tests, 8 workflow tests, 392 full-suite passes with 3 ignored, all pinned Rust gates and independent R1/R2 closure. Synthetic PTY coverage is distinct from manual terminal-emulator or real-system acceptance.
#### Decisions and discoveries
A more detailed guide requires functional scrolling as well as rewritten text. Keep application UI/help in English, as required by AGENTS.md. Reuse current public presentation facts and existing widgets rather than introducing a documentation framework. Describe actual action guards and cancellation semantics; help is guidance, not a second implementation of authorization rules.
#### Outcome
Accepted for presentation and synthetic fixture scope after independent review, R1/R2 closure and pinned Rust gates. No manual terminal-emulator, Alpine, disposable-VM, live WireGuard or production result is claimed. Stop after M18; do not commit, publish, deploy, access production or resume VM work.
## Current progress and resume point ## Current progress and resume point
- M18 is accepted (2026-10-29): structured, accurate in-app Help, responsive wrapping and bounded scrolling passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M18 evidence](docs/evidence/milestone-18.md) records final inputs and limits. Stop here; no next milestone is started.
- M17 is accepted (2026-10-28): adaptive columns, persistent selected-client details and explicit state/count presentation passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M17 evidence](docs/evidence/milestone-17.md) records final inputs and limits. Stop here; no next milestone is started. - M17 is accepted (2026-10-28): adaptive columns, persistent selected-client details and explicit state/count presentation passed independent R1/R2 closure, synthetic/PTY checks and pinned Rust completion gates. [M17 evidence](docs/evidence/milestone-17.md) records final inputs and limits. Stop here; no next milestone is started.
- TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs. - TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs.
@@ -3823,7 +3894,7 @@ Accepted for the presentation and synthetic fixture scope after independent revi
- 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 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 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. - 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: none for M17. Stop after acceptance. Keep the undocumented M16 manual check and absent disposable-VM evidence separate from M17 fixture evidence. Do not access production, commit, or deploy. - Next action: none for M18. Stop after acceptance. Keep M16 validation limitations separate from TUI evidence. Do not access production, commit, deploy or resume VM work.
## Plan maintenance ## Plan maintenance
+6
View File
@@ -90,6 +90,12 @@ not proof that the saved configuration was applied. Details distinguish
document or peer problems from valid configuration shapes that are currently document or peer problems from valid configuration shapes that are currently
outside the management model. outside the management model.
In Help, Up/Down scroll by one displayed row and PageUp/PageDown move by the
visible page. The Help panel shows the current row range and clamps navigation
when the terminal is resized. Press `?` to return to the beginning or Esc to
return to the client list without changing the selected peer or application
state.
Forms use Enter to advance or submit, Tab to change fields/options, and Esc to Forms use Enter to advance or submit, Tab to change fields/options, and Esc to
cancel. Backup restore previews identify the selected archive and require an cancel. Backup restore previews identify the selected archive and require an
explicit confirmation. Restore does not prompt for the vault password and does explicit confirmation. Restore does not prompt for the vault password and does
+90
View File
@@ -0,0 +1,90 @@
# Milestone 18 evidence: in-app Help
Recorded 2026-10-28T23:59:51+02:00 on `x86_64-unknown-linux-gnu` with
`rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97. Base commit:
`4960c65f6511da50bac9d35e2b4a625adbaf86c3`. The working tree is dirty;
the M18 Rust/README inputs at final validation have SHA256:
| Input | SHA256 |
| --- | --- |
| `src/tui/render.rs` | `dbf00298f1bf466f99688f790be5ae34e9a7434aa374bc61c7df31e41c8d842b` |
| `src/tui/terminal.rs` | `4658404e856c1acced9c115f1cfb4c9f87e714b2343c3996df7ee63504368b81` |
| `tests/tui_workflow.rs` | `d7bc72c20eb9b4d3ba160e599d83f83111e418a9c3056b4e430aa17a555baacd` |
| `README.md` | `7352da8e13850b1b13e11bf2f828c8368dbd1b6b95320d6838ac26b7fa0532d6` |
`PLANS.md` is also dirty, recording the prior M18 plan and this milestone's
progress. No dependency manifests or application adapters changed. Release
`target/release/wireguard-admin` SHA256 is
`d179b2934565e12e0d90ad716730418a40b215c5ca68058fd76566c267e98488`;
`file` identified an x86-64 dynamically linked GNU/Linux PIE.
## Shortcut audit and content basis
The implementer audited current `src/tui/terminal.rs::handle_key` and
`handle_navigation_key`, `src/tui/forms.rs::handle_key` and
`src/tui/events.rs` before writing Help. The guide's shortcut families map to
the existing handlers and guards:
| Keys | Handler and actual availability |
| --- | --- |
| `?`, `Up/Down`, `PageUp/PageDown`, `Enter`, `Esc` | `terminal.rs`: Help resets to top; Help scrolls by visual row/page; list arrows select and Enter opens details; Esc returns to list or cancels an active form/clears QR. |
| `q`, handled Ctrl-C | `terminal.rs`: outside forms, exit is immediate without a draft or asks about draft discard; Ctrl-C is translated to `q` and is form-specific while a form is active. |
| `n`, `r`, `d` | `terminal.rs` and `forms.rs`: create requires a loaded view; rename/delete require a supported unambiguous selected peer and confirmation. Blank paired addresses select automatic allocation. |
| `p`, `c`, `x`, `R` | `terminal.rs`, `forms.rs`, `events.rs`: preview draft; confirmed Commit saves after backup; confirmed discard drops draft; uppercase restart previews saved state and requires confirmation, backup, service completion and verification. |
| `o`, `O` | `terminal.rs`, `forms.rs`, `events.rs`: supported selected saved client only; first delivery after Commit is session-bound and retryable after file failure, while uppercase reissue requires a vault password per action; file precedes optional QR. |
| `b`, `B` | `terminal.rs` and `forms.rs`: list/select backup and preview confirmed full restore versus create manual backup. Restore changes saved state only. |
| `s`, `i`, `!` | `terminal.rs` and `forms.rs`: setup only before vault initialization; import submits one configured-directory filename and preserves its source; issue summary is available after view load. |
Actions remain guarded by the existing handlers. The Help navigation keys
themselves do not submit application work. Existing action letters can still
dispatch while Help is visible; the milestone did not change action dispatch.
The guide says how to return to the list and accurately distinguishes lowercase
and uppercase keys.
## Implementation, review and acceptance mapping
The Help screen is one ordered document with an introduction and nine sections:
quick start; list/details; state/observations; client changes; save/restart;
delivery/reissue; backups/restore; vault/import/issues; navigation/exit. It
uses styled headings, key labels, workflows and warnings, with display-cell
wrapping at the actual inner viewport width. A visible row-range title and the
fixed-height footer show navigation and overflow. Renderer and terminal share
the same wrapped-row metrics: one-row arrows, viewport-page keys, bounded
scrolling, resize clamping, `?` reset and Esc return. These changes cover
M18-A1–A5; synthetic buffers cover 34×8, 40×9, 60×16, 80×24, 100×30 and
180×50, including reachability of the last section. README Help instructions
were updated.
One GPT-6-Luna/high implementer completed M18.1–M18.3 sequentially. A separate
fresh GPT-6-Luna/high read-only reviewer compared content with the handlers and
tested Help rendering/navigation. It found M18-R1 (P2): at 34×8, the Help
footer omitted Esc return. A separate GPT-6-Luna/high repairer changed only
Help's narrow hint packing and added 34×8/40×9 assertions. The reviewer
rechecked the actual repair, ran the focused test and closed R1. No actionable
finding remained in that pass. A coordinator content audit then found M18-R2:
four missing explanations about Applied status, current observations, the
first-delivery activation warning and masked password entry. The reviewer
independently confirmed those gaps. The separate repairer amended Help and
added a 34×8 semantic/reachability regression; independent re-review checked
the claims against status/form code and SPEC.md and closed R2. No actionable
finding remains, satisfying M18-A6. A proposed change to action-key dispatch
was withdrawn after checking the explicit preserve-actions boundary.
## Final checks and limits
| Command | Result |
| --- | --- |
| `cargo fmt --all -- --check` | exit 0 |
| `cargo test --locked tui::` | 79 passed, 0 failed |
| `cargo test --locked --test tui_workflow` | 8 passed, 0 failed; includes synthetic PTY Help/resize flow |
| `cargo clippy --locked --all-targets -- -D warnings` | exit 0 |
| `cargo test --locked` | 392 passed, 0 failed, 3 explicitly ignored; 2 doctests passed |
| `cargo build --locked --release` | exit 0; binary hash above |
| `git diff --check` | exit 0 |
Tests use synthetic fixtures and an isolated PTY. That PTY run checks emitted
terminal behavior, not manual readability in a specific terminal emulator.
Alpine Testcontainers, disposable-VM systemd, live WireGuard and production
configuration were not used for this presentation milestone. The user-directed
M16 manual check has no recorded environment/transport detail and is not M18
evidence. No host service was restarted and no Git commit was made.
+711 -41
View File
@@ -32,35 +32,8 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
); );
return; return;
} }
let compact_height = area.height <= 9;
let header_height = if compact_height { 2 } else { 3 };
let footer_hints = keyboard_hints(state, forms); let footer_hints = keyboard_hints(state, forms);
let hint_width = area.width.saturating_sub(2) as usize; let areas = screen_areas(area, &footer_hints);
let (footer_lines, omitted, footer_height) = if compact_height {
let packed = pack_hints(&footer_hints, hint_width);
let (lines, omitted) = fit_hint_rows(
packed,
area.height.saturating_sub(header_height + 3 + 2).max(1) as usize,
hint_width,
);
let height = lines
.len()
.saturating_add(2)
.min(area.height.saturating_sub(header_height + 3) as usize)
.max(3) as u16;
(lines, omitted, height)
} else {
let (lines, omitted) = standard_hint_rows(&footer_hints, hint_width);
(lines, omitted, 4)
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height),
Constraint::Min(1),
Constraint::Length(footer_height),
])
.split(area);
let status = state let status = state
.view .view
.as_ref() .as_ref()
@@ -73,7 +46,7 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
.title("WireGuard Admin"), .title("WireGuard Admin"),
) )
.wrap(Wrap { trim: true }), .wrap(Wrap { trim: true }),
rows[0], areas.header,
); );
if forms.is_active() { if forms.is_active() {
@@ -93,12 +66,17 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
) )
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)), .scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1], areas.content,
); );
} else { } else {
match state.screen { match state.screen {
Screen::Help => render_help(frame, rows[1]), Screen::Help => render_help(
Screen::ClientDetails => render_details(frame, rows[1], state), frame,
areas.content,
state.scroll_offset,
help_navigation_metrics(area.width, area.height),
),
Screen::ClientDetails => render_details(frame, areas.content, state),
Screen::Progress => { Screen::Progress => {
let text = state let text = state
.status_message .status_message
@@ -108,7 +86,7 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
Paragraph::new(safe_text(text)) Paragraph::new(safe_text(text))
.block(Block::default().borders(Borders::ALL).title("Progress")) .block(Block::default().borders(Borders::ALL).title("Progress"))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
rows[1], areas.content,
); );
} }
Screen::Message => { Screen::Message => {
@@ -118,13 +96,59 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
.block(Block::default().borders(Borders::ALL).title("Status")) .block(Block::default().borders(Borders::ALL).title("Status"))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)), .scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1], areas.content,
); );
} }
Screen::ClientList => render_client_screen(frame, rows[1], state), Screen::ClientList => render_client_screen(frame, areas.content, state),
} }
} }
render_keyboard_panel(frame, rows[2], &footer_lines, omitted); render_keyboard_panel(frame, areas.footer, &areas.footer_lines, areas.omitted);
}
struct ScreenAreas {
header: Rect,
content: Rect,
footer: Rect,
footer_lines: Vec<Vec<Hint>>,
omitted: bool,
}
fn screen_areas(area: Rect, footer_hints: &[Hint]) -> ScreenAreas {
let compact_height = area.height <= 9;
let header_height = if compact_height { 2 } else { 3 };
let hint_width = area.width.saturating_sub(2) as usize;
let (footer_lines, omitted, footer_height) = if compact_height {
let packed = pack_hints(footer_hints, hint_width);
let (lines, omitted) = fit_hint_rows(
packed,
area.height.saturating_sub(header_height + 3 + 2).max(1) as usize,
hint_width,
);
let height = lines
.len()
.saturating_add(2)
.min(area.height.saturating_sub(header_height + 3) as usize)
.max(3) as u16;
(lines, omitted, height)
} else {
let (lines, omitted) = standard_hint_rows(footer_hints, hint_width);
(lines, omitted, 4)
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height),
Constraint::Min(1),
Constraint::Length(footer_height),
])
.split(area);
ScreenAreas {
header: rows[0],
content: rows[1],
footer: rows[2],
footer_lines,
omitted,
}
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -169,8 +193,7 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
} }
match state.screen { match state.screen {
Screen::Help => { Screen::Help => {
add("Esc", "return to list", HintKind::Navigation); return help_hints();
add("?", "Help", HintKind::Navigation);
} }
Screen::Message => { Screen::Message => {
add("Esc", "return to list", HintKind::Navigation); add("Esc", "return to list", HintKind::Navigation);
@@ -293,6 +316,40 @@ fn fit_hint_rows(
if max_rows == 1 && width < 40 { if max_rows == 1 && width < 40 {
let hints = rows.into_iter().flatten().collect::<Vec<_>>(); let hints = rows.into_iter().flatten().collect::<Vec<_>>();
let hint_count = hints.len(); let hint_count = hints.len();
// Help needs an explicit way back to the client list even when the
// compact footer can show only one short row. Preserve scroll guidance
// too, and use the existing overflow marker for the remaining keys.
if hints.iter().any(|hint| hint.key == "PgUp/PgDn") {
let mut compact = ["↑/↓", "Esc", "?"]
.into_iter()
.filter_map(|key| hints.iter().find(|hint| hint.key == key).cloned())
.collect::<Vec<_>>();
let mut omitted = compact.len() < hint_count;
let row_width = |hints: &[Hint], omitted: bool| {
let entries = hints
.iter()
.map(|hint| Span::raw(hint.key).width() + 1 + Span::raw(hint.action).width())
.sum::<usize>();
let gaps = hints.len().saturating_sub(1) * 2;
let more = if omitted {
Span::raw(" … more").width()
} else {
0
};
entries + gaps + more
};
while row_width(&compact, omitted) > width && compact.len() > 1 {
compact.pop();
omitted = true;
}
if row_width(&compact, omitted) > width {
compact.truncate(1);
omitted = true;
}
return (vec![compact], omitted);
}
let list_navigation = hints.iter().find(|hint| hint.key == "↑/↓"); let list_navigation = hints.iter().find(|hint| hint.key == "↑/↓");
let help = hints.iter().find(|hint| hint.key == "?"); let help = hints.iter().find(|hint| hint.key == "?");
let mut compact = Vec::new(); let mut compact = Vec::new();
@@ -1776,10 +1833,433 @@ fn issue_summary(view: &SessionView) -> String {
) )
} }
fn render_help(frame: &mut Frame<'_>, area: ratatui::layout::Rect) { #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HelpRowKind {
Body,
Heading,
Key,
Workflow,
Warning,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct HelpRow {
text: String,
kind: HelpRowKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct HelpNavigationMetrics {
pub(crate) viewport_rows: usize,
pub(crate) total_rows: usize,
pub(crate) max_offset: usize,
pub(crate) page_rows: usize,
}
fn help_hints() -> Vec<Hint> {
[
("↑/↓", "scroll", HintKind::Navigation),
("PgUp/PgDn", "page", HintKind::Navigation),
("Esc", "list", HintKind::Navigation),
("?", "top", HintKind::Navigation),
("q", "exit", HintKind::Destructive),
]
.into_iter()
.map(|(key, action, kind)| Hint { key, action, kind })
.collect()
}
/// Return the exact wrapped row counts used by Help and by terminal scrolling.
pub(crate) fn help_navigation_metrics(width: u16, height: u16) -> HelpNavigationMetrics {
let area = Rect::new(0, 0, width, height);
let areas = screen_areas(area, &help_hints());
let inner_width = areas.content.width.saturating_sub(2) as usize;
let viewport_rows = areas.content.height.saturating_sub(2) as usize;
let total_rows = help_document(inner_width).len();
let page_rows = viewport_rows.max(1);
let max_offset = total_rows.saturating_sub(page_rows);
HelpNavigationMetrics {
viewport_rows,
total_rows,
max_offset,
page_rows,
}
}
fn help_document(width: usize) -> Vec<HelpRow> {
let mut rows = Vec::new();
let width = width.max(1);
let body = |rows: &mut Vec<HelpRow>, text: &str, kind| {
let indent = text.len() - text.trim_start().len();
let prefix = &text[..indent];
let wrap_width = width.saturating_sub(Span::raw(prefix).width()).max(1);
rows.extend(
wrap_help_text(text.trim_start(), wrap_width)
.into_iter()
.map(|line| HelpRow {
text: format!("{prefix}{line}"),
kind,
}),
);
};
let blank = |rows: &mut Vec<HelpRow>| {
rows.push(HelpRow {
text: String::new(),
kind: HelpRowKind::Body,
});
};
let heading = |rows: &mut Vec<HelpRow>, text: &str| {
blank(rows);
body(rows, text, HelpRowKind::Heading);
};
let key = |rows: &mut Vec<HelpRow>, shortcut: &str, action: &str, explanation: &str| {
body(rows, &format!("{shortcut} {action}"), HelpRowKind::Key);
body(rows, &format!(" {explanation}"), HelpRowKind::Body);
};
body(&mut rows, "WireGuard Admin — Help", HelpRowKind::Heading);
body(
&mut rows,
"Use this guide to inspect peers, prepare changes, save them, and apply saved configuration only through an explicit verified restart. Shortcuts are case-sensitive. Help navigation never changes client or server state.",
HelpRowKind::Body,
);
heading(&mut rows, "1. Quick start");
key(
&mut rows,
"↑/↓",
"Select a peer",
"The list selection wraps at either end.",
);
key(
&mut rows,
"Enter",
"Open details",
"Inspect a selected peer and its configuration state.",
);
key(
&mut rows,
"?",
"Open Help",
"From Help, ? returns to the beginning.",
);
body(&mut rows, "Typical new-client path:", HelpRowKind::Workflow);
body(
&mut rows,
"1. Press n, enter an alias, and leave both address fields blank for automatic allocation.",
HelpRowKind::Workflow,
);
body(
&mut rows,
"2. Review the draft with p, then save with c. Commit makes a backup and saves configuration and vault files only.",
HelpRowKind::Workflow,
);
body(
&mut rows,
"3. Use o to deliver the first client file. Applying saved changes is a separate, explicit R operation.",
HelpRowKind::Workflow,
);
heading(&mut rows, "2. Client list and selected details");
key(
&mut rows,
"↑/↓",
"Move selection",
"Only the visible selection changes; it does not edit a peer.",
);
key(
&mut rows,
"Enter",
"Show details",
"Esc returns to the list and retains the selected identity.",
);
key(
&mut rows,
"!",
"Show issues",
"Review configuration and peer issues when the saved view is loaded.",
);
body(
&mut rows,
"Rows remain available for inspection when configuration is malformed, duplicated, or outside supported management capabilities. Mutations require an unambiguous supported public key. Aliases are labels and may repeat.",
HelpRowKind::Body,
);
heading(&mut rows, "3. Draft, saved, applied, and observations");
body(
&mut rows,
"Draft is the in-memory working copy. Saved is the configuration and vault on disk. Applied is the saved peer set whose restart completed and whose runtime interface and peers were verified. These are separate states.",
HelpRowKind::Body,
);
body(
&mut rows,
"A draft is not applied. Commit updates saved files but does not change the running VPN. Restore replaces saved files but does not restart. Only a successful explicit restart plus complete verification records the saved generation as applied.",
HelpRowKind::Body,
);
body(
&mut rows,
"Applied: not verified means there is no current successful verification record for the saved generation. Observation unavailable means the current runtime observation could not be obtained. Neither label alone proves a saved/runtime mismatch or that restart is pending.",
HelpRowKind::Body,
);
body(
&mut rows,
"Handshake time and current RX/TX counters are present-time, read-only observations. They do not indicate online status, provide historical traffic totals, or prove that the saved generation was applied. Deleted peer addresses remain reserved until restart completion and full runtime verification.",
HelpRowKind::Warning,
);
heading(&mut rows, "4. Create, rename, and delete clients");
key(
&mut rows,
"n",
"New client",
"Available after the session view is loaded. Enter an alias; leave both IPv4 and IPv6 blank for automatic allocation, or enter both addresses.",
);
key(
&mut rows,
"r",
"Rename client",
"Available for a selected supported peer; submitting opens a confirmation before preparing the draft change.",
);
key(
&mut rows,
"d",
"Delete client",
"Available for a selected supported peer; requires confirmation and changes the draft only.",
);
body(
&mut rows,
"Create, rename, and delete prepare draft changes. Use p to review, c to save, and R separately to apply saved configuration.",
HelpRowKind::Body,
);
heading(&mut rows, "5. Preview, Commit, discard, and restart");
key(
&mut rows,
"p",
"Preview draft",
"Review proposed changes before saving.",
);
key(
&mut rows,
"c",
"Commit",
"After confirmation, creates a mandatory backup and saves configuration and vault files only. Backup failure stops before protected files change.",
);
key(
&mut rows,
"x",
"Discard draft",
"Opens a confirmation before dropping unsaved changes.",
);
key(
&mut rows,
"R",
"Preview and restart",
"Previews the saved generation and asks for explicit confirmation. After confirmation, creates a mandatory backup before calling the configured service.",
);
body(
&mut rows,
"Restart does not Commit a draft. If the draft must be discarded to proceed, that choice is explicit. Applied state is recorded only after successful service completion and complete runtime verification; failures remain unapplied.",
HelpRowKind::Warning,
);
heading(&mut rows, "6. File delivery, password reissue, and QR");
key(
&mut rows,
"o",
"First file delivery",
"Available for a selected saved client that has not yet completed delivery in this creation session. Enter a destination path; retry is available after a file failure in that session. First delivery follows Commit.",
);
key(
&mut rows,
"O",
"Password reissue",
"Available for a selected saved client. Authorizes this reissue with the vault password each time.",
);
body(
&mut rows,
"Delivery writes the client configuration file. QR display is optional and follows successful file delivery. It is an explicit disclosure of that configuration; Esc clears the QR. If it cannot fit completely, use the saved client file. A newly delivered client's access is not active until its saved generation is restarted and fully verified. For delivery, o needs no password in the creation session; O asks for the vault password for that reissue only. Password entry is masked while typing during setup and reissue.",
HelpRowKind::Warning,
);
heading(&mut rows, "7. Backups and previewed restore");
key(
&mut rows,
"b",
"List backups",
"Choose a backup to preview a full saved-state replacement.",
);
key(
&mut rows,
"B",
"Create backup",
"Creates a manual backup without changing saved or running configuration.",
);
body(
&mut rows,
"Restore shows a preview and requires confirmation. A mandatory backup is completed before replacement. Restore changes saved files and vault only; it requires no vault password and never restarts the service. Use R separately if you intend to apply the restored saved generation.",
HelpRowKind::Warning,
);
heading(
&mut rows,
"8. Vault setup, import, and configuration issues",
);
key(
&mut rows,
"s",
"Initial vault setup",
"Available only when the vault is not initialized. Setup protects its secret key with a password.",
);
key(
&mut rows,
"i",
"Import one file",
"Selects one file from the configured import directory. The original imported file is preserved.",
);
body(
&mut rows,
"Import reads client material; it does not silently repair server configuration. Enter submits the selected filename; review the scan report and any listed issues. The original source file is preserved.",
HelpRowKind::Body,
);
heading(&mut rows, "9. Navigation, cancellation, and exit");
key(
&mut rows,
"↑/↓",
"Scroll Help",
"Move one displayed row at a time.",
);
key(
&mut rows,
"PgUp/PgDn",
"Page Help",
"Move by the visible Help page.",
);
key(
&mut rows,
"Esc",
"Return to list",
"In an active form, Esc cancels that form; on the QR screen, it clears the disclosure.",
);
key(
&mut rows,
"Tab",
"Move form field",
"Changes fields in forms and toggles the optional QR choice in delivery.",
);
key(
&mut rows,
"y / Enter",
"Confirm",
"Accepts the action shown in a confirmation. Read the prompt before confirming.",
);
key(
&mut rows,
"n / Esc",
"Cancel confirmation",
"Declines the pending action and keeps the current draft unless the prompt says otherwise.",
);
key(
&mut rows,
"q / Ctrl-C",
"Request exit outside forms",
"Outside a form, exit is immediate with no draft and asks whether to discard when a draft exists. In forms, Ctrl-C is translated to q and handled by that form; it may be input, ignored, or form-specific. Esc cancels a form.",
);
body(
&mut rows,
"Canceling a form preserves existing draft changes. Leaving the application never silently Commits. Confirmation prompts identify actions that change or discard state.",
HelpRowKind::Warning,
);
rows
}
fn wrap_help_text(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines = Vec::new();
let mut line = String::new();
let mut line_width = 0;
for word in text.split_whitespace() {
let word_width = Span::raw(word).width();
let gap = usize::from(!line.is_empty());
if line_width + gap + word_width <= width {
if gap != 0 {
line.push(' ');
line_width += 1;
}
line.push_str(word);
line_width += word_width;
continue;
}
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
if word_width <= width {
line.push_str(word);
line_width = word_width;
} else {
let mut chunk = String::new();
let mut chunk_width = 0;
for character in word.chars() {
let char_width = Span::raw(character.to_string()).width();
if chunk_width + char_width > width && !chunk.is_empty() {
lines.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(character);
chunk_width += char_width;
}
line = chunk;
line_width = chunk_width;
}
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
fn render_help(
frame: &mut Frame<'_>,
area: Rect,
scroll_offset: usize,
metrics: HelpNavigationMetrics,
) {
let document = help_document(area.width.saturating_sub(2) as usize);
let start = scroll_offset.min(metrics.max_offset);
let end = start
.saturating_add(metrics.viewport_rows)
.min(metrics.total_rows);
let lines = document[start..end]
.iter()
.map(|row| {
let style = match row.kind {
HelpRowKind::Body => Style::default(),
HelpRowKind::Heading => {
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
HelpRowKind::Key => Style::default().add_modifier(Modifier::BOLD),
HelpRowKind::Workflow => Style::default().add_modifier(Modifier::ITALIC),
HelpRowKind::Warning => {
Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC)
}
};
Line::from(Span::styled(row.text.as_str(), style))
})
.collect::<Vec<_>>();
let title = if metrics.total_rows == 0 {
"Help 0/0".to_owned()
} else {
format!(
"Help {}-{}/{}",
start + 1,
end.max(start + 1),
metrics.total_rows
)
};
frame.render_widget( frame.render_widget(
Paragraph::new("↑/↓ selects a peer. Enter opens details. ! opens document and peer issues. Esc cancels a form or returns to the list. ? opens help. q requests exit and warns about a draft.\n\nn new client, r rename, d delete, p preview draft, c Commit, x discard draft, R preview explicit restart, o first file delivery, O password reissue, b list backups/restore, B create manual backup, s initial vault setup, i import one selected source file.\n\nPeer rows with malformed, duplicate, or unsupported configuration remain visible for inspection. Mutation requires an unambiguous supported full public key.\n\nCommit and restore only change saved files after backup. Restart is explicit, previewed, backed up, and verified. Statistics are read-only observations; a refresh never marks changes applied.") Paragraph::new(Text::from(lines))
.block(Block::default().borders(Borders::ALL).title("Help")) .block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -1829,6 +2309,19 @@ mod tests {
.join("\n") .join("\n")
} }
fn render_help_buffer(width: u16, height: u16, scroll_offset: usize) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let state = UiState {
screen: Screen::Help,
scroll_offset,
..UiState::default()
};
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
rendered_text(&terminal)
}
fn empty_view() -> SessionView { fn empty_view() -> SessionView {
SessionView { SessionView {
generation: 0, generation: 0,
@@ -1844,6 +2337,183 @@ mod tests {
} }
} }
#[test]
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
let rows = help_document(178);
let headings = rows
.iter()
.filter(|row| row.kind == HelpRowKind::Heading)
.map(|row| row.text.as_str())
.collect::<Vec<_>>();
assert!(headings.iter().any(|line| line.contains("Quick start")));
for section in [
"Client list and selected details",
"Draft, saved, applied, and observations",
"Create, rename, and delete clients",
"Preview, Commit, discard, and restart",
"File delivery, password reissue, and QR",
"Backups and previewed restore",
"Vault setup, import, and configuration issues",
"Navigation, cancellation, and exit",
] {
assert!(
headings.iter().any(|line| line.contains(section)),
"missing {section}"
);
}
let document = rows
.iter()
.map(|row| row.text.as_str())
.collect::<Vec<_>>()
.join("\n");
for entry in [
"n New client",
"r Rename client",
"d Delete client",
"p Preview draft",
"c Commit",
"x Discard draft",
"R Preview and restart",
"o First file delivery",
"O Password reissue",
"b List backups",
"B Create backup",
"s Initial vault setup",
"i Import one file",
"! Show issues",
"? Open Help",
"↑/↓ Select a peer",
"Enter Open details",
"Esc Return to list",
"q / Ctrl-C Request exit outside forms",
] {
assert!(document.contains(entry), "missing key entry {entry}");
}
assert!(document.contains("Commit updates saved files but does not change"));
assert!(document.contains("Handshake time and current RX/TX counters"));
assert!(!document.contains("clients are online"));
}
#[test]
fn help_explains_applied_observation_delivery_and_masked_password_semantics_at_narrow_width() {
let metrics = help_navigation_metrics(34, 8);
let visible_rows = (0..=metrics.max_offset)
.map(|offset| render_help_buffer(34, 8, offset))
.flat_map(|buffer| buffer.lines().map(str::to_owned).collect::<Vec<_>>())
.collect::<Vec<_>>();
let content_width = 32;
let help_rows = help_document(content_width);
let document = help_rows
.iter()
.map(|row| row.text.as_str())
.collect::<Vec<_>>()
.join(" ");
for claim in [
"no current successful verification record for the saved generation",
"current runtime observation could not be obtained",
"Neither label alone proves a saved/runtime mismatch or that restart is pending",
"current RX/TX counters are present-time, read-only observations",
"do not indicate online status, provide historical traffic totals",
"or prove that the saved generation was applied",
"access is not active until its saved generation is restarted and fully verified",
"Password entry is masked while typing during setup and reissue",
] {
assert!(document.contains(claim), "Help lacks claim {claim:?}");
}
assert!(!document.contains("restart is always pending"));
assert!(!document.contains("clients are online"));
for row in help_rows.iter().filter(|row| !row.text.is_empty()) {
assert!(
visible_rows
.iter()
.any(|visible| visible.contains(&row.text)),
"Help row is unreachable at 34×8: {:?}",
row.text
);
}
}
#[test]
fn help_rows_fit_display_cells_at_supported_content_widths() {
for width in [32, 38, 58, 78, 98, 178] {
for row in help_document(width) {
assert!(
Span::raw(row.text.as_str()).width() <= width,
"row exceeds {width} cells: {:?}",
row.text
);
}
}
}
#[test]
fn help_draws_at_planned_sizes_and_resize_clamps_without_state_mutation() {
for (width, height) in [(34, 8), (40, 9), (60, 16), (80, 24), (100, 30), (180, 50)] {
let metrics = help_navigation_metrics(width, height);
assert!(
metrics.viewport_rows > 0,
"no Help viewport at {width}x{height}"
);
assert!(metrics.total_rows >= 10);
assert_eq!(
metrics.max_offset,
metrics.total_rows.saturating_sub(metrics.viewport_rows)
);
let text = render_help_buffer(width, height, usize::MAX);
assert!(
text.contains("Help "),
"missing position title at {width}x{height}: {text}"
);
let section_index = help_document(width.saturating_sub(2) as usize)
.iter()
.position(|row| row.text.starts_with("9. Navigation"))
.unwrap();
let section_text = render_help_buffer(width, height, section_index);
assert!(
section_text.contains("9. Navigation"),
"last section unreachable at {width}x{height}: {section_text}"
);
}
let small = help_navigation_metrics(60, 16);
let large = help_navigation_metrics(100, 30);
assert!(small.max_offset > large.max_offset);
let final_small = render_help_buffer(60, 16, usize::MAX);
assert!(final_small.contains(&format!("Help {}-", small.max_offset + 1)));
let final_large = render_help_buffer(100, 30, usize::MAX);
assert!(final_large.contains(&format!("Help {}-", large.max_offset + 1)));
}
#[test]
fn help_footer_advertises_scroll_page_return_and_reset() {
let text = render_help_buffer(100, 30, 0);
for hint in ["↑/↓ scroll", "PgUp/PgDn page", "Esc list", "? top"] {
assert!(text.contains(hint), "missing {hint}: {text}");
}
let narrow = render_help_buffer(34, 8, 0);
assert!(narrow.contains("↑/↓ scroll"));
assert!(
narrow.contains("Esc list"),
"missing Help return key: {narrow}"
);
assert!(
narrow.contains("… more"),
"omitted Help navigation must be indicated: {narrow}"
);
assert_eq!(narrow.lines().count(), 8, "Help stays within 34×8 buffer");
let compact = screen_areas(Rect::new(0, 0, 34, 8), &help_hints());
assert_eq!(compact.footer_lines.len(), 1, "one reserved hint row");
assert_eq!(compact.footer.height, 3, "footer height stays fixed");
let short = render_help_buffer(40, 9, 0);
assert!(
short.contains("Esc list"),
"missing Help return key: {short}"
);
assert_eq!(short.lines().count(), 9, "Help stays within 40×9 buffer");
}
fn saved_client_view(identity: PublicKey) -> SessionView { fn saved_client_view(identity: PublicKey) -> SessionView {
let client = crate::domain::Client::new( let client = crate::domain::Client::new(
identity, identity,
+220 -1
View File
@@ -284,7 +284,21 @@ fn run_screen_loop(
&mut exiting, &mut exiting,
); );
} else { } else {
handle_key(code, worker, state, forms, &mut exiting); let (terminal_width, terminal_height) = if state.screen == Screen::Help {
let size = terminal.size().map_err(|_| TerminalError::Io)?;
(size.width, size.height)
} else {
(0, 0)
};
handle_key(
code,
worker,
state,
forms,
&mut exiting,
terminal_width,
terminal_height,
);
} }
} }
Event::Resize(_, _) => {} Event::Resize(_, _) => {}
@@ -726,10 +740,15 @@ fn handle_key(
state: &mut UiState, state: &mut UiState,
forms: &mut FormController, forms: &mut FormController,
exiting: &mut bool, exiting: &mut bool,
terminal_width: u16,
terminal_height: u16,
) { ) {
if state.pending.is_some() { if state.pending.is_some() {
return; return;
} }
if handle_help_navigation_key(code, state, terminal_width, terminal_height) {
return;
}
if handle_navigation_key(code, state) { if handle_navigation_key(code, state) {
return; return;
} }
@@ -838,6 +857,40 @@ fn handle_key(
} }
} }
fn handle_help_navigation_key(
code: KeyCode,
state: &mut UiState,
terminal_width: u16,
terminal_height: u16,
) -> bool {
if state.screen != Screen::Help {
return false;
}
let metrics = render::help_navigation_metrics(terminal_width, terminal_height);
state.scroll_offset = state.scroll_offset.min(metrics.max_offset);
match code {
KeyCode::Up => state.scroll_offset = state.scroll_offset.saturating_sub(1),
KeyCode::Down => {
state.scroll_offset = state
.scroll_offset
.saturating_add(1)
.min(metrics.max_offset)
}
KeyCode::PageUp => {
state.scroll_offset = state.scroll_offset.saturating_sub(metrics.page_rows)
}
KeyCode::PageDown => {
state.scroll_offset = state
.scroll_offset
.saturating_add(metrics.page_rows)
.min(metrics.max_offset)
}
KeyCode::Char('?') => state.scroll_offset = 0,
_ => return false,
}
true
}
fn issue_summary(view: &SessionView) -> String { fn issue_summary(view: &SessionView) -> String {
let mut issues = Vec::new(); let mut issues = Vec::new();
issues.extend( issues.extend(
@@ -903,6 +956,9 @@ mod tests {
use std::process::Command; use std::process::Command;
use std::sync::Mutex; use std::sync::Mutex;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::app::ClientDisplay; use crate::app::ClientDisplay;
use crate::runtime::RuntimeObservation; use crate::runtime::RuntimeObservation;
use crate::tui::events::OperationError; use crate::tui::events::OperationError;
@@ -984,6 +1040,169 @@ mod tests {
} }
} }
fn rendered_help(width: u16, height: u16, state: &UiState) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| render::draw(frame, state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn help_navigation_uses_visual_rows_and_viewport_pages_with_bounded_edges() {
for (width, height) in [(34, 8), (40, 9), (60, 16), (80, 24), (100, 30), (180, 50)] {
let metrics = render::help_navigation_metrics(width, height);
let mut state = UiState {
screen: Screen::Help,
..UiState::default()
};
assert!(handle_help_navigation_key(
KeyCode::Up,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, 0, "top bound at {width}x{height}");
assert!(handle_help_navigation_key(
KeyCode::Down,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, usize::from(metrics.max_offset > 0));
assert!(handle_help_navigation_key(
KeyCode::PageUp,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, 0);
for _ in 0..metrics.max_offset.div_ceil(metrics.page_rows) {
assert!(handle_help_navigation_key(
KeyCode::PageDown,
&mut state,
width,
height
));
}
assert_eq!(state.scroll_offset, metrics.max_offset);
assert!(handle_help_navigation_key(
KeyCode::PageDown,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, metrics.max_offset);
assert!(handle_help_navigation_key(
KeyCode::Up,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, metrics.max_offset.saturating_sub(1));
}
}
#[test]
fn help_navigation_reaches_final_section_and_resize_clamps_current_position() {
let mut state = UiState {
screen: Screen::Help,
..UiState::default()
};
let (width, height) = (60, 16);
let metrics = render::help_navigation_metrics(width, height);
let mut reached_last_section = false;
loop {
let text = rendered_help(width, height, &state);
reached_last_section |= text.contains("Navigation, cancellation");
if state.scroll_offset == metrics.max_offset {
break;
}
handle_help_navigation_key(KeyCode::Down, &mut state, width, height);
}
assert_eq!(state.scroll_offset, metrics.max_offset);
assert!(
reached_last_section,
"one-row scrolling reaches the last section"
);
let text = rendered_help(width, height, &state);
assert!(text.contains(&format!("Help {}-", metrics.max_offset + 1)));
let small = render::help_navigation_metrics(34, 8);
let large = render::help_navigation_metrics(180, 50);
state.scroll_offset = small.max_offset;
assert!(handle_help_navigation_key(
KeyCode::PageDown,
&mut state,
180,
50
));
assert_eq!(state.scroll_offset, large.max_offset);
assert!(rendered_help(180, 50, &state).contains("Help "));
state.scroll_offset = large.max_offset;
assert!(handle_help_navigation_key(KeyCode::Down, &mut state, 34, 8));
assert_eq!(state.scroll_offset, large.max_offset + 1);
let small_text = rendered_help(34, 8, &state);
assert!(small_text.contains("Help "));
assert!(!small_text.contains("Terminal too small"));
assert!(state.scroll_offset <= small.max_offset);
}
#[test]
fn help_reset_and_escape_preserve_selection_and_application_view_without_worker_actions() {
let identity = crate::domain::PublicKey::from_bytes([1; 32]);
let mut view = navigation_view();
view.has_draft = true;
view.applied = AppliedState::Verified;
let mut state = UiState {
screen: Screen::Help,
selected_identity: Some(identity),
selected_peer_index: Some(2),
scroll_offset: 42,
view: Some(view),
..UiState::default()
};
assert!(handle_help_navigation_key(
KeyCode::Char('?'),
&mut state,
80,
24
));
assert_eq!(state.screen, Screen::Help);
assert_eq!(state.scroll_offset, 0);
handle_help_navigation_key(KeyCode::PageDown, &mut state, 80, 24);
let selected_identity = state.selected_identity;
let selected_peer_index = state.selected_peer_index;
let view_before = state.view.clone();
assert!(handle_navigation_key(KeyCode::Esc, &mut state));
assert_eq!(state.screen, Screen::ClientList);
assert_eq!(state.scroll_offset, 0);
assert_eq!(state.selected_identity, selected_identity);
assert_eq!(state.selected_peer_index, selected_peer_index);
assert_eq!(state.view, view_before);
assert!(state.view.as_ref().unwrap().has_draft);
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Verified);
// The scrolling helper accepts no worker or command sink, so Help input
// cannot submit work or mutate saved/applied application state.
assert!(!handle_help_navigation_key(
KeyCode::Down,
&mut state,
80,
24
));
}
#[test] #[test]
fn arrows_highlight_only_enter_opens_selected_peer_and_escape_returns_to_list() { fn arrows_highlight_only_enter_opens_selected_peer_and_escape_returns_to_list() {
let identity = crate::domain::PublicKey::from_bytes([1; 32]); let identity = crate::domain::PublicKey::from_bytes([1; 32]);
+26 -4
View File
@@ -171,7 +171,7 @@ fn terminal_pty_smoke() {
let app = wireguard_admin::app::Application::open(fixture.store, states) let app = wireguard_admin::app::Application::open(fixture.store, states)
.expect("synthetic application opens"); .expect("synthetic application opens");
let resize = thread::spawn(|| { let resize = thread::spawn(|| {
thread::sleep(Duration::from_millis(40)); thread::sleep(Duration::from_millis(230));
assert!( assert!(
Command::new("stty") Command::new("stty")
.args(["cols", "40", "rows", "8"]) .args(["cols", "40", "rows", "8"])
@@ -219,14 +219,28 @@ fn terminal_pty_smoke() {
.expect("start isolated pseudo-terminal"); .expect("start isolated pseudo-terminal");
use std::io::Write as _; use std::io::Write as _;
let mut input = child.stdin.take().expect("script stdin"); let mut input = child.stdin.take().expect("script stdin");
let exit_key = thread::spawn(move || { let help_workflow = thread::spawn(move || {
thread::sleep(Duration::from_millis(180)); thread::sleep(Duration::from_millis(180));
for key in [
b"?".as_slice(),
b"\x1b[B",
b"\x1b[6~",
b"\x1b[A",
b"\x1b[5~",
b"?",
b"\x1b",
] {
input.write_all(key).expect("send Help navigation key");
input.flush().expect("flush Help navigation key");
thread::sleep(Duration::from_millis(35));
}
thread::sleep(Duration::from_millis(70));
input input
.write_all(b"\x03") .write_all(b"\x03")
.expect("send handled Ctrl-C interruption to synthetic TUI"); .expect("send handled Ctrl-C exit to synthetic TUI");
}); });
let output = child.wait_with_output().expect("collect PTY result"); let output = child.wait_with_output().expect("collect PTY result");
exit_key.join().expect("exit input completes"); help_workflow.join().expect("Help input completes");
assert!( assert!(
output.status.success(), output.status.success(),
"synthetic PTY child exits successfully" "synthetic PTY child exits successfully"
@@ -248,6 +262,14 @@ fn terminal_pty_smoke() {
transcript.contains("\u{1b}[?25h"), transcript.contains("\u{1b}[?25h"),
"cursor restored after TUI" "cursor restored after TUI"
); );
assert!(
transcript.contains("Help "),
"synthetic PTY entered the Help panel"
);
assert!(
transcript.contains("PgUp/PgDn"),
"Help footer advertised page navigation"
);
} }
fn shell_quote(value: &str) -> String { fn shell_quote(value: &str) -> String {