32 KiB
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 readingsigner 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_mainconstructsAppand callsrun(). Non-interactive mode usesrun_headless().src/server.rs—ServerContextsupportsUnix,Qrexec,Tcp,Http,Stdiolisten modes. Only one mode active at a time (thestart_servermethod 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:
- Startup popup — seed phrase entry (and optional generation). This is the only popup in the entire TUI.
- 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
gand 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
mainrole 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 SocketB— Qube bridgeF— FIPSH— HTTPA— Add roleD— Delete roleC— Clear activity logL— Help screenQ— 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/0unix: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:1000ortcp:[::1]:12345curve— the role's curve string (e.g.secp256k1,ed25519)key_path— the role's derivation path viaRoleEntry::display_path()(e.g.m/44'/1237'/0'/0/0orm/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 theAddRolepopup showing the role preset menu (same 1–10 presets as current wizard). Select a preset (or custom), then enter role name and path template viaInputFieldwith 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/0orm/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
-
Update
Screenenum — 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.) -
Update
Appstruct — remove wizard fields, addhelp_scroll,role_cursor,transport_cursor,seed_generate_mode,RoleAddStageenum and fields. UpdateApp::newto start onScreen::SeedEntry. -
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 toMain.draw_seed_display()shows the generated phrase. -
Rewrite
draw_main— title isSigner v0.0.1centered on its own line (useLine::from(...).centered(), no border box, no "Main Menu"). UseMergeStrategy::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. -
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). -
Implement Roles section on main screen — renders the role table with a selection cursor (
role_cursor).Aopens theAddRolepopup (preset menu → name → path → confirm),Ddeletes the currently selected role immediately (no confirmation),↑/↓/Tab moves the cursor. On add/delete, re-derive keys. -
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 toScreen::Main. ESC cancels and returns to Main. -
Implement Help screen —
draw_help()renders a scrollableParagraphdescribing what the app does, what transports are, what roles are, and listing the key commands at the end. Track ahelp_scrolloffset.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. -
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 oldkey_spanhelper with a newcmd_hinthelper that produces aSpanwith the key command letter underlined (which may not be the first letter of the word). -
Update activity log format — change
ServerContext::process_requestto return(String, String)(response + activity message). Construct the activity message fromcaller_id,method,role_name,concrete_path,verdict, andsource_labelmatching the C format:<caller_id> <method>(<role>[,<path>]) <verdict>:<source>. Updatehandle_oneto return the activity message. Updateservice_server()inAppto log this message instead of "request handled". -
Update
run()loop — service server only onMainscreen (overlay screensAddRoleandHelppause server processing). Update thehandle_keydispatch for the new screen enum. -
Update
main.rs— adjustApp::newcall if needed. Thelisten_overridepath: if--listenis given, skip seed entry popup and go straight to main with the specified transport. But still need a mnemonic — so--listenwith interactive mode should still show the seed entry popup, then go to main with the transport pre-selected. -
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 withsigner_client.
What stays the same
ActivityLogstruct and ring-buffer logic.InputFieldwidget andedit_keyhelper.- 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.WizardStageenum 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 Menutext in the title bar. - The bordered title bar box — title is now a plain line.
- The old
key_spanhelper 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) andE(clear) key commands — replaced by inline Roles management andCfor clear.