Files
signer/plans/tui_flow_redesign.md
T

32 KiB
Raw Blame History

Plan: TUI Flow Redesign

Goal

Simplify the signer TUI from a multi-screen setup wizard into a single main screen with full-screen overlays. The seed phrase is entered once at startup in a popup; all subsequent editing (roles, transport, lock) is done from full-screen overlays reached from the main screen. Borders are collapsed for a cleaner look. A dedicated Commands screen provides keyboard navigation through all available actions with a visible cursor. Command hints show only the word with the key letter underlined (e.g. "Quit" with Q underlined, not "Q quit").

Current state

  • src/tui.rs — 1398 lines. Seven screens: Unlock, GenerateMnemonic, RoleWizard, TransportSelection, Main, Connections, Lock. Setup is a linear wizard: Unlock → (Generate) → RoleWizard → TransportSelection → Main. The main screen has a title bar reading signer v0.0.1 > Main Menu, a two-column body (Information + Roles on the left, Activity on the right), and a Commands bar at the bottom. Connections and Lock are rendered as sub-panels inside the main screen's right column.
  • src/main.rs — server_main constructs App and calls run(). Non-interactive mode uses run_headless().
  • src/server.rs — ServerContext supports Unix, Qrexec, Tcp, Http, Stdio listen modes. Only one mode active at a time (the start_server method picks the first toggled transport).

Key design decisions

1. Rename "client name" → "signer name"

The user asked whether "client name" should be renamed since the signer is more a server than a client. Decision: rename to "signer name". The field shows the socket name (e.g. signer01), which is the name clients use to connect. Calling it "signer name" is clearer than "client name" and consistent with the program name.

2. Single main screen, no setup wizard

The current linear wizard (Unlock → RoleWizard → TransportSelection → Main) is replaced by:

  1. Startup popup — seed phrase entry (and optional generation). This is the only popup in the entire TUI.
  2. Main screen — shows Information, Roles, Activity, and a Commands bar. From here the user can:
    • Open the Roles screen (add/remove roles) — full-screen overlay
    • Open the Transport screen (toggle transports on/off at any time) — full-screen overlay
    • Lock the session — full-screen overlay
    • Open the Commands screen — full-screen overlay
    • Quit

3. Collapsed borders

Use ratatui's MergeStrategy::Exact with Spacing::Overlap(1) so adjacent blocks share borders instead of drawing double lines. The selected/focused pane gets a thick border for visual distinction. See ratatui/ratatui-widgets/examples/collapsed-borders.rs for the reference implementation.

4. Command hint style

Command hints at the bottom of each screen show only the word with the key command letter underlined — not "Q quit" but "Quit" with the Q underlined. The underlined letter is the actual key binding, which may not be the first letter. For example, if b is the key for "Qube bridge", it would be rendered as "Qube bridge" with the b underlined. This is achieved with Span::raw("Qube ") + Span::styled("b", Style::default().add_modifier(Modifier::UNDERLINED))

  • Span::raw("ridge").

Each command hint is a word (or short phrase) with exactly one letter underlined — the letter the user presses to activate that command.

Target layout

Startup popup — seed phrase entry

A centered popup (not full screen) over a blank terminal. This is the only popup in the entire TUI:

                              ┌──────────────────────────────────────────────┐
                              │  Signer v0.0.1                               │
                              │                                              │
                              │  Enter seed phrase or G to generate new:     │
                              │  > abandon abandon abandon abandon abandon   │
                              │  abandon abandon abandon abandon abandon     │
                              │  abandon abandon                              │
                              │                                              │
                              │  Invalid mnemonic. Attempts: 1/10            │
                              └──────────────────────────────────────────────┘
  • If the user types g and presses Enter, generate a 12-word mnemonic, display it in the same popup, then press Enter to continue.
  • On successful load, derive keys for any pre-registered roles (the default main role is auto-registered), start the server with default transport (Unix), and transition to the main screen.
  • Invalid mnemonic shows an error line and retries (max 10 attempts).

Main screen — full mockup

Title is Signer v0.0.1 centered on its own line — no border box around it, no "Main Menu" text. Use ratatui's Line::from(...).centered() to center the title. Collapsed borders between the body sections. Left column has three sections: Information (top), Transport (middle), Roles (bottom). Right column has Activity (scrollable, newest first). Commands bar at the bottom with key command letters underlined.

Below is the full main screen showing all sections in detail. The ▸ cursor in the Transport section shows the currently selected transport line. Active transports are shown in bold (rendered as reversed video or bold in the actual TUI). The Activity column shows the C-format log entries (newest first).

                              Signer v0.0.1
├──────────────────────────────────────────┬──────────────────────────────────────────┤
│ Information                              │ Activity                                 │
│                                          │                                          │
│  signer name: signer01                  │  2026-08-18 15:05:42 unix:1000           │
│  Unix address:                           │    secp256k1 m/44'/1237'/0'/0/0         │
│    signer01                             │  2026-08-18 15:05:30 unix:1000           │
│  Qube address:                           │    - -                                    │
│    (inactive)                            │  2026-08-18 15:04:55 unix:1000           │
│  FIPS address:                           │    secp256k1 m/44'/1237'/0'/0/0         │
│    (inactive)                            │                                          │
│  HTTP address:                           │                                          │
│    (inactive)                            │                                          │
│  OTP pad: chksum=a1b2c3 offset=128/4096  │                                          │
│                                          │  2026-08-18 15:03:12 unix:1000           │
├──────────────────────────────────────────┤    secp256k1 m/44'/1237'/*'/0/0         │
│ Transport                                │  2026-08-18 15:02:00 unix:1000           │
│                                          │    - -                                    │
│  ▸ [x] U̲nix Socket                      │  2026-08-18 15:01:30 unix:1000           │
│    [ ] Qube b̲ridge                      │    secp256k1 m/44'/1237'/0'/0/0         │
│    [ ] F̲IPS                             │  2026-08-18 15:00:22 unix:1000           │
│    [ ] H̲TTP                             │    secp256k1 m/44'/1237'/0'/0/0         │
│                                          │  2026-08-18 15:00:10 signer started     │
├──────────────────────────────────────────┤                                          │
│ Roles                                    │                                          │
│                                          │                                          │
│  Role           Purpose   Curve          │                                          │
│  ─────────────  ────────  ────────────   │                                          │
│  main           nostr     secp256k1      │                                          │
│  nostr_range    nostr     secp256k1      │                                          │
│  ssh            ssh       ed25519        │                                          │
│                                          │                                          │
│  A̲dd  D̲elete                             │  Cl̲ear                                ▲ │
├──────────────────────────────────────────┴──────────────────────────────────────────┤
│  He̲lp  Q̲uit                                                                         │
└─────────────────────────────────────────────────────────────────────────────────────┘

Each section has its own commands on the bottom line, left-aligned:

  • Information: no commands (display only)
  • Transport: no separate command — each transport line is a toggle button. The [x] / [ ] indicator shows on/off state. The key command letter is underlined in each label (U̲nix Socket, Q̲ube bridge, F̲IPS, H̲TTP). Tab or Up/Down moves between lines, Enter or the underlined key toggles that transport on/off (independent checkbox: toggling one only flips itself; the last active transport cannot be disabled; server restarts immediately). Active transport is also shown in bold/reversed.
  • Roles: A̲dd D̲elete — add a new role, delete the selected role
  • Activity: Cl̲ear — clear the activity log (with a blank row above the command)
  • Bottom bar: He̲lp Q̲uit — global navigation (Help opens a help screen, Quit exits)

Section details:

Information — each transport address is shown as a label row followed by an indented value row (since addresses can be long):

Label row Indented value row Source
signer name: <socket_name> (same line) self.socket_name
Unix address: <socket_name> (without @) if Unix active, else (inactive)
Qube address: (one request per invocation) if Qrexec active, else (inactive)
FIPS address: <bind_addr> if TCP active, else (inactive)
HTTP address: <bind_addr> if HTTP active, else (inactive)
OTP pad: chksum=… offset=N/M (same line) otp_pad::global_status() (only if bound)

Note: Unix addresses are always displayed without the @ symbol. The indented value row uses 2-space indentation.

Transport — 4 toggle-button lines, each showing [x] or [ ] indicator plus the transport name with the key letter underlined (U̲nix Socket, Qube b̲ridge, F̲IPS, H̲TTP). No separate command line — each line is its own toggle. Tab or Up/Down moves between lines, Enter or the underlined key letter toggles that transport on/off (independent checkbox: toggling one only flips itself; the last active transport cannot be disabled; server restarts immediately). Active transport also shown in bold/reversed.

Key assignments (all unique across the main screen — this is the canonical set shown in the mockup):

  • U — Unix Socket
  • B — Qube bridge
  • F — FIPS
  • H — HTTP
  • A — Add role
  • D — Delete role
  • C — Clear activity log
  • L — Help screen
  • Q — Quit

Roles — table with columns: Role, Purpose, Curve. (Derivation path is omitted from the main screen to save space — it's visible on the Roles overlay screen.) Shows all registered roles. A blank row separates the table from the commands at the bottom: A̲dd D̲elete.

Activity — scrollable, newest first. Each entry is a timestamped log line in the C format (see "Activity log format" below). Scrollbar on the right. A blank row separates the log from the commands at the bottom: Cl̲ear.

Bottom bar — He̲lp Q̲uit with key letters underlined.

Activity log format

Each activity entry shows four fields: time uid curve path. The timestamp is added by ActivityLog::add(); the message itself is uid curve path.

<caller_id> <curve> <key_path>

Examples:

  • unix:1000 secp256k1 m/44'/1237'/0'/0/0
  • unix:1000 ed25519 m/44'/102001'/0'/0'/0'
  • unix:1000 - - (get_info / algorithm verbs — no role)

Implementation: ServerContext::process_request returns (String, String) — the response and the activity log message. The activity message is constructed from:

  • caller.caller_id — e.g. unix:1000 or tcp:[::1]:12345
  • curve — the role's curve string (e.g. secp256k1, ed25519)
  • key_path — the role's derivation path via RoleEntry::display_path() (e.g. m/44'/1237'/0'/0/0 or m/44'/1237'/*'/0/0 [0-99])
  • For requests without a role (get_info, algorithm verbs, OTP), the curve and path are -.

The service_server() method in App passes this message to activity_log.add() instead of the generic "request handled".

Roles section (on main screen)

Roles are managed directly in the Roles section on the main screen — there is no separate Roles overlay screen. The ▸ cursor shows the selected role.

  • Add (A): opens the AddRole popup showing the role preset menu (same 1–10 presets as current wizard). Select a preset (or custom), then enter role name and path template via InputField with pre-filled defaults. On confirm, register the role, derive its key immediately, and return to the main screen automatically — no extra Enter needed.
  • Delete (D): deletes the currently selected role immediately — no confirmation overlay. The role is removed from the table and its derived key is wiped. The selection moves to the next role.
  • Select: Up/Down arrows or Tab move selection through the role list. The ▸ cursor shows the selected role.
  • Columns: Role, Purpose, Curve, Key path (derivation path via RoleEntry::display_path(), e.g. m/44'/1237'/0'/0/0 or m/44'/1237'/*'/0/0 [0-99]).

Help screen

Opened by pressing L from the main screen. Full-screen overlay that describes what the app does, what transports are, what roles are, and lists the key commands at the end. It is a scrollable screen (the content can exceed the visible height). Commands at the bottom:

                              Signer v0.0.1
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                      │
│  Signer is an attended Nostr signing daemon. It holds your keys and signs            │
│  requests from clients over one or more transports.                                  │
│                                                                                      │
│  Transports                                                                          │
│  ──────────                                                                          │
│  A transport is a way for clients to reach the signer.                               │
│  - Unix Socket: local same-machine access via an abstract socket.                    │
│  - Qube bridge: access from other Qubes via qrexec.                                  │
│  - FIPS: TCP listener for framed JSON over a network.                                │
│  - HTTP: HTTP listener for curl-friendly requests.                                   │
│                                                                                      │
│  Roles                                                                               │
│  ─────                                                                               │
│  A role binds a name to a derivation path and curve. Clients address                 │
│  requests by role name, which also serves as the password.                           │
│                                                                                      │
│  Key commands                                                                        │
│  ─────────────                                                                       │
│  U  Toggle Unix Socket transport                                                     │
│  B  Toggle Qube bridge transport                                                     │
│  F  Toggle FIPS transport                                                            │
│  H  Toggle HTTP transport                                                            │
│  A  Add a role                                                                       │
│  D  Delete the selected role                                                         │
│  C  Clear the activity log                                                           │
│  L  Open the Help screen                                                             │
│  Q  Quit                                                                             │
│                                                                                      │
├──────────────────────────────────────────────────────────────────────────────────────┤
│  B̲ack                                                                                │
└──────────────────────────────────────────────────────────────────────────────────────┘
  • The content is scrollable — Up/Down arrows (or Page Up/Down) scroll through the help text. A scrollbar is shown on the right when the content overflows.
  • ESC / B (Back): return to main screen.
  • This is a reference screen — no actions are executed from here.

Screen enum (updated)

pub enum Screen {
    /// Startup popup — seed phrase entry
    SeedEntry,
    /// Startup popup — showing generated mnemonic
    SeedDisplay,
    /// Main status screen (Information + Transport + Roles + Activity + bottom bar)
    Main,
    /// Add-role popup — role preset selection (over the Main screen)
    AddRole,
    /// Help — full-screen overlay describing the app and key commands
    Help,
}

Connections, Transport, Lock, and Commands screens are removed. Transport is now a section on the main screen (between Information and Roles). Connection info is shown inline in the Information section (transport addresses). Lock functionality is removed entirely. The Commands screen is replaced by a Help screen. Only SeedEntry and SeedDisplay are popups; all other screens are full-screen overlays.

App struct changes

pub struct App {
    // ... existing fields ...

    /// Scroll offset for the Help screen content
    pub help_scroll: usize,
    /// Currently selected role index in the Roles section
    pub role_cursor: usize,
    /// Currently selected transport line index in the Transport section
    pub transport_cursor: usize,
    /// Whether the seed entry popup is in "generate" mode
    pub seed_generate_mode: bool,

    // Remove: wizard_stage, wizard_choice, wizard_default_*,
    //         wizard_role_name, wizard_path, wizard_otp_dir,
    //         wizard_otp_name, wizard_roles_created
    // Add:   role_add_stage (for the add-role popup flow)
    pub role_add_stage: RoleAddStage,
    pub role_add_input: InputField,
    pub role_add_choice: i32,
}

pub enum RoleAddStage {
    PresetMenu,
    NameEntry,
    CurveSelect,
    PathEntry,
    OtpDir,
    OtpName,
    Confirm,
}

Server changes

Currently ServerContext supports only one listen mode at a time. The transport toggles are independent checkboxes — toggling one only flips itself, and multiple transports can be checked at once. The server listens on the first active transport by priority (Unix > Qrexec > TCP

HTTP). The last active transport cannot be disabled. This avoids server architecture changes while allowing the user to select which transport is active.

Additionally, process_request must return an activity log message alongside the JSON response (see "Activity log format" above). Change the return type from String to (String, String) where the first element is the JSON response and the second is the activity description. handle_one returns this message to the caller so the TUI can log it.

Event loop changes

The run() loop stays the same structure (draw → poll key → service server). The key change is that service_server() is called only when the screen is Main. The overlay screens (AddRole, Help) pause server processing while the user is actively configuring. The startup popups (SeedEntry, SeedDisplay) also pause server processing since the server hasn't started yet.

flowchart TD
  Start[Start] --> SeedEntry[Popup: Seed Entry]
  SeedEntry -->|g| SeedDisplay[Popup: Show Generated Mnemonic]
  SeedDisplay -->|Enter| Main
  SeedEntry -->|Enter valid phrase| Main[Main Screen]
  Main -->|A| AddRole[Popup: Add Role preset menu]
  AddRole -->|confirm| Main
  Main -->|L| Help[Help Screen - full screen]
  Help -->|ESC/B| Main
  Main -->|Q| Quit[Quit]

Note: Transport and Roles are not separate screens — they are sections on the main screen. The user tabs/arrow-keys between the 4 transport lines and toggles them directly on the main screen. Roles are added and deleted directly in the Roles section.

Key bindings summary

Main screen — global commands

Key Action
L Open Help screen
Q / ESC Quit
↑ / ↓ Scroll activity log

Transport section (on main screen)

Key Action
TAB / ↑ / ↓ Move between transport lines
ENTER Toggle selected transport on/off
U Toggle Unix Socket on/off
B Toggle Qube bridge on/off
F Toggle FIPS on/off
H Toggle HTTP on/off

Roles section (on main screen)

Key Action
A Add role (opens AddRole popup with preset menu)
D Delete the currently selected role (immediate, no confirmation)
↑ / ↓ / TAB Select role

Activity section (on main screen)

Key Action
C Clear the activity log
↑ / ↓ Scroll activity log

Help screen

Key Action
↑ / ↓ Scroll help content
PAGE UP / PAGE DOWN Scroll help content by page
ESC / B Back to main

Files to change

File Change
src/tui.rs Major rewrite: new Screen enum, startup popup for seed entry, AddRole popup, Help overlay, Transport and Roles as inline sections on main screen with toggle buttons, collapsed borders, underlined-key-letter command hints at bottom of each section, centered title. Remove WizardStage/wizard flow, Connections screen, Lock screen, Commands screen, Roles overlay screen. Remove session/derived from Information. Unix addresses without @.
src/main.rs Minor: App::new call stays the same. The listen_override path may need adjustment since transport is now chosen from the main screen, not a setup screen.
src/server.rs Change process_request return type to (String, String) — JSON response + activity log message. handle_one returns the activity message to the caller. Construct the activity message from caller_id, method, role_name, concrete_path, verdict, and source_label (matching C format).

Implementation steps

  1. Update Screen enum — replace the seven screens with the new set: SeedEntry, SeedDisplay, Main, AddRole, Help. (No separate Transport, Roles, Lock, or Commands screen — Transport and Roles are sections on Main, Lock is removed entirely, Commands replaced by Help.)

  2. Update App struct — remove wizard fields, add help_scroll, role_cursor, transport_cursor, seed_generate_mode, RoleAddStage enum and fields. Update App::new to start on Screen::SeedEntry.

  3. Implement seed entry popup — draw_seed_entry() renders a centered popup. handle_seed_key() processes input: g → generate, Enter → load mnemonic, derive keys, start server, transition to Main. draw_seed_display() shows the generated phrase.

  4. Rewrite draw_main — title is Signer v0.0.1 centered on its own line (use Line::from(...).centered(), no border box, no "Main Menu"). Use MergeStrategy::Exact + Spacing::Overlap(1) for collapsed borders. Left column has three sections: Information (top), Transport (middle), Roles (bottom). Right column has Activity (scrollable, newest first). Information section shows "signer name" (renamed from "client name") + transport addresses (each on an indented row beneath the label, without @ for Unix). Remove the Connections sub-panel. Roles and Activity sections have a blank row above their command lines at the bottom.

  5. Implement Transport section on main screen — renders 4 toggle-button lines, each showing [x] or [ ] indicator plus the transport name with the key letter underlined (U̲nix Socket, Qube b̲ridge, F̲IPS, H̲TTP). The ▸ cursor shows the selected line (transport_cursor). Active transport is also shown in bold/reversed. Tab/Up/Down moves between lines, Enter or the underlined key letter (U/B/F/H) toggles that transport (independent checkbox: only flips itself; last active cannot be disabled; toggling restarts the server). No separate command line for this section. All key commands on the main screen must be unique: U, B, F, H (transport), A, D (roles), C (clear activity), L (help), Q (quit).

  6. Implement Roles section on main screen — renders the role table with a selection cursor (role_cursor). A opens the AddRole popup (preset menu → name → path → confirm), D deletes the currently selected role immediately (no confirmation), ↑/↓/Tab moves the cursor. On add/delete, re-derive keys.

  7. Implement AddRole popup — draw_add_role() renders a centered popup over the Main screen showing the role preset menu (same 1–10 presets as current wizard). handle_add_role_key() processes the multi-stage flow: PresetMenu → NameEntry (InputField with pre-filled default) → CurveSelect (custom only) → PathEntry (InputField with pre-filled default) → OtpDir/OtpName (OTP only) → Confirm. On confirm, register the role, derive its key, and return to Screen::Main. ESC cancels and returns to Main.

  8. Implement Help screen — draw_help() renders a scrollable Paragraph describing what the app does, what transports are, what roles are, and listing the key commands at the end. Track a help_scroll offset. handle_help_key(): Up/Down (and Page Up/Down) scroll the content, ESC/B returns to main. A scrollbar is shown when content overflows. This is a reference screen — no actions executed from here.

  9. Update key command bars — each section's bottom line shows the relevant key bindings for that section. Use underlined-key-letter word hints (e.g. Q̲uit, He̲lp, A̲dd, D̲elete, Cl̲ear) instead of "Q quit" style. Replace the old key_span helper with a new cmd_hint helper that produces a Span with the key command letter underlined (which may not be the first letter of the word).

  10. Update activity log format — change ServerContext::process_request to return (String, String) (response + activity message). Construct the activity message from caller_id, method, role_name, concrete_path, verdict, and source_label matching the C format: <caller_id> <method>(<role>[,<path>]) <verdict>:<source>. Update handle_one to return the activity message. Update service_server() in App to log this message instead of "request handled".

  11. Update run() loop — service server only on Main screen (overlay screens AddRole and Help pause server processing). Update the handle_key dispatch for the new screen enum.

  12. Update main.rs — adjust App::new call if needed. The listen_override path: if --listen is given, skip seed entry popup and go straight to main with the specified transport. But still need a mnemonic — so --listen with interactive mode should still show the seed entry popup, then go to main with the transport pre-selected.

  13. Test — cargo test (unit tests unaffected). Manual test: start signer, verify seed entry popup, verify main screen with collapsed borders and centered title, verify Roles add/delete, verify AddRole popup preset menu flow, verify Transport toggle (4 lines, tab navigation), verify Help screen is scrollable and shows app description + key commands, verify activity log shows detailed request info, connect with signer_client.

What stays the same

  • ActivityLog struct and ring-buffer logic.
  • InputField widget and edit_key helper.
  • All non-TUI code: dispatcher.rs, role_table.rs, mnemonic.rs, key_store.rs, etc. (server.rs gets a return-type change but its logic stays the same).
  • The event loop structure (draw → poll → service).
  • Non-interactive / headless mode (run_headless).

What gets removed

  • Screen::Unlock, Screen::GenerateMnemonic, Screen::RoleWizard, Screen::TransportSelection, Screen::Connections, Screen::Lock, Screen::Commands, Screen::Roles — replaced by the startup popup, the AddRole popup, the Help overlay, and inline sections. Lock functionality is removed entirely. Commands screen replaced by Help screen. Roles screen replaced by an inline Roles section.
  • WizardStage enum and all wizard-related fields/methods.
  • draw_unlock, draw_generate, draw_wizard, draw_transport (old full-screen versions), draw_connections_in, draw_lock_in, handle_lock_key, draw_roles (old overlay version).
  • The > Main Menu text in the title bar.
  • The bordered title bar box — title is now a plain line.
  • The old key_span helper that produced "Q quit" style hints — replaced by underlined-key-letter word hints.
  • Session and derived count rows from the Information section.
  • The @ symbol prefix from Unix address display.
  • The R (open Roles screen) and E (clear) key commands — replaced by inline Roles management and C for clear.