Skip to content

Add interactive TUI mode for grin-wallet - #771

Open
iho wants to merge 31 commits into
mimblewimble:stagingfrom
iho:add-wallet-tui
Open

Add interactive TUI mode for grin-wallet#771
iho wants to merge 31 commits into
mimblewimble:stagingfrom
iho:add-wallet-tui

Conversation

@iho

@iho iho commented Jul 10, 2026

Copy link
Copy Markdown

Summary

  • Adds a grin-wallet tui subcommand: a ratatui-based live dashboard (account status, accounts, outputs, transactions, settings, logs) refreshed from the Owner API.
  • Actions run in-process on background worker threads for send/receive/finalize/invoice/pay/post/scan/etc., with slatepack paste input, scrollable output + QR, and structured result modals — so Tor round-trips and chain scans never freeze the UI.
  • Open / close / recover stay inside the alternate screen via a masked password modal (and an output modal for the recovery phrase). The only normal-terminal path is first-run init before the dashboard starts.
  • Form validation (amounts, addresses, id/txid exclusivity) with inline errors before submit.
  • Context menus: Enter on a transaction row for cancel/repost/export proof; Enter on an account row to switch the active account.
  • Editable Settings tab writes persisted values back to grin-wallet.toml; balance min-confirmations is session-configurable.
  • Help overlay (?), OSC 52 clipboard copy for address/slatepacks, and an optional tui Cargo feature (on by default).
  • Rebased/merged onto current staging (Tor send_tor / slatepack sync API, HTTP client timeout, grin submodule path deps).

Implements #751.

Test plan

  • cargo build / cargo check -p grin_wallet against merged staging
  • cargo test --lib tui:: (form validation unit tests)
  • cargo check --no-default-features (TUI feature-gated off)
  • Manual usernet smoke: unlock, help, address modal, create account, form required-field validation, settings min_conf edit, account switch, clean quit
  • In-TUI close → title shows (locked) without leaving alternate screen
  • In-TUI open via masked password modal → unlock notice, no screen suspend
  • In-TUI recover → password modal → recovery phrase in output modal
  • End-to-end slatepack send/receive/finalize with a local node + second wallet
  • Manual Tor send (non-manual) falls back to slatepack output when peer offline
  • Long-running scan progress via Logs / Status busy indicator
  • listen / owner_api start as background listeners
  • Settings persist to grin-wallet.toml and survive restart
  • CI green on this branch against staging

ardocrat and others added 20 commits May 14, 2026 12:58
* build: update rust edition 2021

* cargo: format all

* build: fix tests and warnings
* scan: save last scanned block info for initial wallet scanning

* scan: save start height at scanned block info on outputs collecting

* scan: 10k blocks batches

* fix: include last height into batch on scan

* scan: save hash of last block

* scan: add larger window (2880 blocks) to scan outputs when updating wallet state

* add test batched scan and resume, add scan doc

* rename corrupted restore test

---------

Co-authored-by: Joerg <wiesche89@googlemail.com>
* wallet: update lmdb from store crate, migrate single backend from trait to struct, update tests cargo deps and design doc

* backend: do not panic on iter and directory creation

* return  iterator errors

* build: update to last node revision

* fix: height selection for scan, remove unused mutability for wallet

* wallet: remove unused mutability conversion

* tx: store slate state at database

* build: cargo fmt

* fix: do not return error when slate not found on tx slate state update

* lmdb: do not collect all tx log records at iterator, show read errors at log

* fix: save slate state, return error if slate not found

* tx: specify amount of bad txs on slate state update error

---------

Co-authored-by: Joerg <wiesche89@googlemail.com>
* build: use node submodule as dependency

* ci: checkout submodules

* docs: add contributing and build instruction

* ci: update release build

* git: remove old submodule

* git: grin staging submodule

* git: add submodule dir
* tor: integrated client for listener, update tokio to last version

* tor: send transaction with integrated client

* build: code format

* tor: optimize bootstrap percent check

* tor: handle config build error

* tor: do not show slatepack address if tor not launched

* tor: handle keystore errors

* tor: keep service launched

* tor: bootstrap timeout, clean cache before client launch

* tor: post request timeout

* fix: post timeout

* build: update deps after merge

* tor: store arti runtime

* tor: do not send over tor if `manual` arg presents or `skip_send_attempt` set at config

* fix: arg name

* tor: handle arti runtime creation error

* tor: handle connection error

* tor: handle bootstrap error

* tor: handle start service name error

* tor: handle service config creation error

* tor: service proxy errors

* tor: handle service key creation emptiness

* build: format code

* build: update arti to 0.43, hyper to 0.10.1

* api: start foreign listener before tor connection

* tor: handle json parse errors

* tor: prevent to create several client runtimes, ability to recreate

* tor: return bootstrap error

* tor: handle host parse error

* tor: restart proxy on error

* tor: do not use integrated client when config value use_integrated is missing, add logging

* build: update lock file
* tor: global client state

* tor: optimize init client thread-safety

* tor: handle config change on client init

* tor: add logging for client reuse or reinit

* tor: do not reuse arti client for requests
… slate state after tor finalization on receive
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	api/Cargo.toml
#	api/src/foreign.rs
#	api/src/owner.rs
#	config/Cargo.toml
#	controller/Cargo.toml
#	controller/tests/common/mod.rs
#	impls/Cargo.toml
#	libwallet/Cargo.toml
#	util/Cargo.toml
Adds a `grin-wallet tui` subcommand: a ratatui-based dashboard (account
status, accounts, outputs, transactions, settings, logs) refreshed live
from the Owner API, plus an Actions menu covering every wallet
subcommand. Submitting an action form builds the equivalent CLI
argument vector and runs it through the same wallet_args::parse_and_execute
dispatch the plain CLI uses, so existing behavior (password prompts,
slatepack exchange, Tor sending, payment proofs, invoices, etc.) works
unchanged with no duplicated logic.

Closes mimblewimble#751
@iho
iho marked this pull request as draft July 10, 2026 16:10
iho added 2 commits July 10, 2026 21:27
Most Actions (send/receive/finalize/invoice/scan/etc.) now execute on
background threads with structured result modals instead of suspending
the alternate screen and shelling out to the CLI. Adds slatepack paste
input, QR/output modals, form validation, row context menus, editable
settings written back to grin-wallet.toml, help overlay, clipboard
copy, and a feature-gated `tui` Cargo feature.
Actions that previously suspended the dashboard (and broke bordered
terminal windows) now run in-process: close locks immediately, open and
recover use a masked password modal, and the recovery phrase is shown
in the existing output modal. The only remaining normal-terminal path
is first-run init before the dashboard starts.
@iho
iho changed the base branch from master to staging July 10, 2026 18:42
Resolve conflicts in grin-wallet.rs (keep TUI logs channel and staging
API timeout on HTTPNodeClient) and regenerate Cargo.lock. Adapt the
TUI worker to staging's try_slatepack_sync_workflow / Tor send_tor API
and DefaultWalletImpl lifetime change.
@iho
iho marked this pull request as ready for review July 12, 2026 08:04
* build: update grin submodule to last staging

* build: update grin submodule

* build: remove remove_dir_all dependency

* build: update rpassword

* build: update semver

* build: remove unused deps

* build: update ring

* build: update lock file deps

* build: update rustyline

* build: update ed25519-dalek

* build: update curve25519-dalek

* build: update age

* build: update uuid

* fix: uuid test

* build: update x25519-dalek

* fix: x25519-dalek build

* build: update arti

* build: update grin submodule

* build: update hyper

* fix: initialize rustls provider for tests

* build: update grin submodule

* fix: rustls install provider

* tor: share getting private key

* fix: show an error on node version parse

* cli: renamed prompt password method

* fix: replace into_boxed_slice with try_from

* fix: dalek tests for onion

* config: comment expanded secret key

* fix: import api body for controller

* password: handle prompt error

* wallet: parse node error message

* tests: do not use random values for sign messages

* build: make hook executable

* build: cargo format all

* rustls: do not install provider at tests and globally

* build: update grin submodule

* build: fix api channel from grin submodule

* args: use read_password for piped prompt

* build: remove unused dep

* test: fixed signature

* preserve piped pw input

---------

Co-authored-by: wiesche <wiesche89@googlemail.com>

@wiesche89 wiesche89 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please rebase, merge conflicts

Comment thread Cargo.toml Outdated
crossterm = { version = "0.28", features = ["bracketed-paste"], optional = true }
qr_code = { version = "1.1.0", optional = true }
serde_json = { version = "1", optional = true }
uuid = { version = "0.8", features = ["serde", "v4"], optional = true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still conflicts with current staging. I also tested the merged sources with a regenerated lockfile: prompt_password now returns a Result, and uuid 0.8 here causes type conflicts with the uuid 1.x types from staging. Could you rebase the PR and update these places as part of it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6f6ae3a.

  • Merged current origin/staging into this branch and resolved conflicts.
  • prompt_password now uses the staging Result API throughout the TUI open path.
  • TUI optional uuid bumped to 1.x to match staging (no more 0.8 type conflicts).
  • Owner::new / listeners / foreign_single_use updated for the new config-path APIs.

Comment thread src/tui/worker.rs Outdated
if test_mode {
return false;
}
tor_config.send_tor(Some(manual))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Manual is not selected, we pass Some(false) here. send_tor already treats this as an explicit override, so skip_send_attempt = true is ignored and the TUI still tries Tor. Could we pass Some(true) only for Manual and None otherwise?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

can_send_tor now passes Some(true) only when Manual is selected, and None otherwise so a configured skip_send_attempt is still respected. Unit tests cover Manual, config-skip, and test_mode.

Comment thread src/tui/worker.rs Outdated
for strategy in ["smallest", "all"] {
let init_args = InitTxArgs {
amount,
amount_includes_fee: Some(p.amount_includes_fee),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For max, the CLI automatically includes the fee in the amount. The TUI leaves this disabled by default, so it tries to spend the complete balance plus the fee. Could we use p.amount_includes_fee || use_max in both init paths?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Send (estimate + real init) now uses amount_includes_fee = p.amount_includes_fee || use_max, matching the CLI spend_max behavior.

Comment thread src/tui/ui.rs
}
}

fn switch_account(&mut self, label: String) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Account switching, wallet close and quit are still available while a worker is running. This can close the wallet or terminate the process between transaction creation, output locking and slatepack writing. Could we block these lifecycle actions while busy is set?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Added refuse_if_busy and block quit (q/Q), account switch, open, close, and recover while a worker is in progress, with a dialog naming the running operation.

Comment thread src/tui/worker.rs Outdated
if let Some(b) = bridge {
tor_config.bridge.bridge_line = Some(b);
}
let mask = Arc::new(Mutex::new(ctx.shared.mask.lock().clone()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The listener gets a snapshot of the mask instead of the shared state. After an in-TUI close/open, the old listener keeps running with the old mask and there is no stop path. Could we use the shared mask state and stop the listener cleanly when the wallet is closed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially fixed.

  • Foreign listener now uses the shared Arc<Mutex<Option<SecretKey>>> mask (same as the TUI open/close path), so re-open updates what the listener sees.
  • Not done: clean stop of the listener when the wallet is closed. That needs a shutdown/stop path in the controller (listeners currently run until process exit / join). Happy to follow up if we add a stop channel in a later PR.

Comment thread src/tui/logs.rs Outdated

/// Word-wraps `text` to `width` columns, hard-breaking words that don't fit
/// on their own. Empty input produces a single empty line.
fn wrap_text(text: &str, width: usize) -> Vec<String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ratatui's Paragraph already supports Wrap and line_count. This custom wrapper counts characters instead of terminal column widths, so wide Unicode characters can be wrapped incorrectly. Could we use Ratatui's existing reflow implementation and scroll the resulting paragraph to the bottom?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (with a small compromise).

Logs use ratatui Paragraph + Wrap instead of the custom character-based wrapper. line_count is private in ratatui 0.29, so bottom-anchoring uses an approximate scroll from source line count.

Comment thread src/tui/outputs.rs Outdated

fn output_row(o: &OutputCommitMapping) -> Row<'static> {
Row::new(vec![
Cell::from(short_commit(&o.output.commit)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OutputData.commit is only optionally stored, while OutputCommitMapping.commit contains the commitment reconstructed for this response. The existing CLI display also uses the mapping field. Could we format o.commit here so valid outputs do not appear as -?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Outputs view formats OutputCommitMapping.commit (hex), matching the CLI display path, instead of the optional OutputData.commit.

Comment thread src/tui/status.rs
}
lines.push(line(
"Data Refreshed:",
if app.view.validated { "Yes" } else { "Pending" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresher calls retrieve_summary_info with refresh_from_node = false, so the returned validated value is always false. Its newly created Owner also does not share the running updater flag, unlike the CLI path which uses validated || updater_running. This status therefore stays at Pending even after a successful sync. Could we derive it from the shared updater or node state?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Refresher sets validated = validated || shared.updater_running, with updater_running shared from the controller Owner after start_updater, matching the CLI idea of “validated or updater running”.

Comment thread src/lib.rs Outdated
mod cli;
pub mod cmd;
#[cfg(feature = "tui")]
pub mod tui;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the TUI need to be part of the public Rust API? It currently has only one internal caller from wallet_args, so keeping this module private would avoid exposing the controller entry point and committing to its generic API.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

tui is now a private mod tui (only used from wallet_args / the binary), not part of the public crate API.

Comment thread src/tui/menu.rs Outdated
Line::from("Esc : Back "),
Line::from("s : Spent "),
Line::from("? : Help "),
Line::from("Q : Quit "),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hint shows uppercase Q, but the key handler only accepts lowercase q. Could we either show q here or handle both characters?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Menu hint shows q, and the key handler accepts both q and Q. Quit is also blocked while a worker is busy.

* build: update lodash to 4.18.1

* fix: readme

* docs: add owner api authentication setup

* build: update package-lock.json to version 3

* fix: token result, inline some vars

---------

Co-authored-by: ardocrat <ardocrat@gri.mw>
ardocrat and others added 5 commits July 24, 2026 22:15
* config: global instance

* fix: cli tests

* fix: owner api tests

* config: save tor config

* fix: owner rpc tests

* fix: set config file path on init and owner api call

* tor: store launched services, get global config at command loop, remove unused listen args, check for empty global config to set default, do not save empty tor config at owner

* api: update config in memory after successful file write

* arti: restart proxy in loop

* fix: comment for tor config save

* config: set or init, update without error

* config: scoped tor config

* build: update lock file

* config: ability to store multiple configurations at memory from config path

* tor: restart service on config change

* fix: owner api test

* foreign: pass tor config from args, stop service, error handling

* arti: move runtime wrapper to single file

* config: read existing config from file at create_config

* config: handle error on read

* arti: single dir for cache

* foreign: register config listener before launch

* config: save temp

* config: remove unused error type

* config: make path and members not optional, return an error on config creation if it exists, pass default tor config to send if not set

* arti: non public runtime wrapper

* config: migrate comments

* fix: rpc tests

* config: return from cache if exists

* config: save disabled tor config when null provided at owner and tor section was not set

* config: update tor with args at listener

* config: pass pathbuf directly on creation

* config: unique tmp file name, setting permissions and providing config version

* fix global config handling

* fix config persistence

* fix tor service restart

* fix config synchronization

* require wallet close before changing data directory

* unify global config updates

---------

Co-authored-by: wiesche <wiesche89@googlemail.com>
* client: use native tls and disable default features for reqwest

* build: bring back reqwest default features except tls

* client: remove tls selection
Sync the TUI branch with current staging (Owner config path, GlobalWalletConfig,
prompt_password Result, foreign/owner listener APIs, uuid 1.x) and apply review
feedback from mimblewimble#771: Tor skip_arg handling, max-send fee inclusion, busy lifecycle
guards, shared keychain mask for listeners, TLS on Owner API, spawn failure
cleanup, staged atomic settings writes, private tui module, stable wallet_command
API with TUI-only log entry, typed form validation, recovery phrase clipboard
disabled, CLI password not retained after unlock, and unit tests for the fixed paths.
@iho

iho commented Aug 1, 2026

Copy link
Copy Markdown
Author

Thanks @wiesche89 — replied under each of your inline review threads on 6f6ae3a.

Summary

  • Merged current staging (uuid 1.x, prompt_password Result, Owner/listener config-path APIs).
  • Fixed: Tor Manual/skip_send_attempt, max-send fee inclusion, busy lifecycle guards, shared mask for listeners, Owner API TLS, spawn/busy guards, settings stage+atomic write, stable wallet_command + TUI-only log entry, typed u32/u16 validation, recovery clipboard off, drop CLI --pass after unlock, private tui module, validated status via shared updater flag, output commits, logs wrap, q/Q quit, fmt.
  • Partial / deferred (called out on the threads): clean listener stop on close, full shared CLI/TUI session + workflow extract, ui.rs split, full e2e regression matrix, end-to-end zeroizing password buffers. Happy to take those as follow-ups.

@iho
iho requested a review from wiesche89 August 1, 2026 10:31
Route Settings edits through update_global_config so multi-wallet caches
stay coherent and config listeners are notified (PR mimblewimble#769). Display and
edit prefill prefer get_global_config over local snapshots. Slatepack
send/receive/pay paths read live Tor settings from the global instance.
@wiesche89
wiesche89 force-pushed the staging branch 2 times, most recently from eccb1ee to 2743dff Compare August 9, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants