From ebd4365d64f0e78cd416c780d06333c504598520 Mon Sep 17 00:00:00 2001 From: Jonathan Gimeno Date: Fri, 20 Mar 2026 00:02:00 +0100 Subject: [PATCH 1/6] feat(ev-dev): add interactive TUI dashboard with --tui flag - Integrate ratatui for terminal UI with blocks, logs, and accounts panels - Implement custom tracing layer to capture real-time log events - Add keyboard navigation (Tab for panel switch, arrows for scroll, q to quit) - Support coexistence of TUI and plain log output modes - Add crossterm for terminal event handling --- Cargo.lock | 422 +++++++++++++++++++++++++++- bin/ev-dev/Cargo.toml | 9 + bin/ev-dev/src/main.rs | 218 ++++++++++---- bin/ev-dev/src/tui/app.rs | 156 ++++++++++ bin/ev-dev/src/tui/events.rs | 16 ++ bin/ev-dev/src/tui/mod.rs | 72 +++++ bin/ev-dev/src/tui/tracing_layer.rs | 83 ++++++ bin/ev-dev/src/tui/ui.rs | 288 +++++++++++++++++++ 8 files changed, 1202 insertions(+), 62 deletions(-) create mode 100644 bin/ev-dev/src/tui/app.rs create mode 100644 bin/ev-dev/src/tui/events.rs create mode 100644 bin/ev-dev/src/tui/mod.rs create mode 100644 bin/ev-dev/src/tui/tracing_layer.rs create mode 100644 bin/ev-dev/src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index c5d2199..608726b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1406,6 +1406,15 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1528,15 +1537,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -2189,6 +2213,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix", @@ -2235,6 +2260,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "ctr" version = "0.9.2" @@ -2435,6 +2470,12 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -2909,6 +2950,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "ev-common" version = "0.1.0" @@ -2937,16 +2987,21 @@ dependencies = [ "alloy-primitives", "alloy-signer-local", "clap", + "crossterm", "ev-deployer", "ev-node", "evolve-ev-reth", "eyre", + "futures", + "ratatui", "reth-cli-util", "reth-ethereum-cli", + "reth-tracing", "serde_json", "tempfile", "tokio", "tracing", + "tracing-subscriber 0.3.23", ] [[package]] @@ -3221,6 +3276,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -3275,6 +3340,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -3292,6 +3368,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "fixed-cache" version = "0.1.8" @@ -3336,6 +3418,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -4623,6 +4711,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -4844,6 +4938,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "mach2" version = "0.5.0" @@ -4905,6 +5009,21 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metrics" version = "0.24.3" @@ -5101,6 +5220,19 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -5195,6 +5327,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5541,6 +5684,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "p256" version = "0.13.2" @@ -5658,6 +5810,39 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pharos" version = "0.5.3" @@ -5668,17 +5853,47 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -5686,7 +5901,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5695,13 +5923,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -5926,8 +6163,8 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.11.0", "num-traits", "rand 0.9.2", @@ -6211,6 +6448,8 @@ dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", "ratatui-widgets", ] @@ -6246,6 +6485,26 @@ dependencies = [ "ratatui-core", ] +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + [[package]] name = "ratatui-widgets" version = "0.3.0" @@ -9125,7 +9384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74d1e5c1eaa44d39d537f668bc5c3409dc01e5c8be954da6c83370bbdf006457" dependencies = [ "bitvec", - "phf", + "phf 0.13.1", "revm-primitives", "serde", ] @@ -10243,6 +10502,69 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -11091,6 +11413,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "wasm-bindgen", @@ -11166,6 +11489,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -11394,6 +11726,78 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/bin/ev-dev/Cargo.toml b/bin/ev-dev/Cargo.toml index 5a10743..deed6ff 100644 --- a/bin/ev-dev/Cargo.toml +++ b/bin/ev-dev/Cargo.toml @@ -26,6 +26,9 @@ reth-ethereum-cli.workspace = true alloy-signer-local.workspace = true alloy-primitives.workspace = true +# Reth tracing (for Layers type) +reth-tracing.workspace = true + # Core dependencies eyre.workspace = true tracing.workspace = true @@ -33,6 +36,12 @@ tokio = { workspace = true, features = ["full"] } clap = { workspace = true, features = ["derive", "env"] } tempfile.workspace = true serde_json.workspace = true +futures.workspace = true + +# TUI +ratatui = "0.30" +crossterm = { version = "0.29", features = ["event-stream"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } [lints] workspace = true diff --git a/bin/ev-dev/src/main.rs b/bin/ev-dev/src/main.rs index 6eeef39..187f3a4 100644 --- a/bin/ev-dev/src/main.rs +++ b/bin/ev-dev/src/main.rs @@ -5,6 +5,8 @@ #![allow(missing_docs, rustdoc::missing_crate_level_docs)] +mod tui; + use alloy_signer_local::{coins_bip39::English, MnemonicBuilder}; use clap::Parser; use ev_deployer::{config::DeployConfig, genesis::merge_alloc, output::build_manifest}; @@ -15,6 +17,7 @@ use evolve_ev_reth::{ use reth_ethereum_cli::Cli; use std::{io::Write, path::PathBuf}; use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use ev_node::{EvolveArgs, EvolveChainSpecParser, EvolveNode}; @@ -60,6 +63,10 @@ struct EvDevArgs { /// Path to an ev-deployer TOML config to deploy contracts at genesis. #[arg(long, value_name = "PATH")] deploy_config: Option, + + /// Launch with terminal UI instead of plain log output + #[arg(long, default_value_t = false)] + tui: bool, } fn derive_keys(count: usize) -> Vec<(String, String)> { @@ -148,59 +155,11 @@ fn print_banner(args: &EvDevArgs, deploy_cfg: Option<&DeployConfig>) { println!(); } -fn main() { - reth_cli_util::sigsegv_handler::install(); - - if std::env::var_os("RUST_BACKTRACE").is_none() { - std::env::set_var("RUST_BACKTRACE", "1"); - } - - let dev_args = EvDevArgs::parse(); - - let deploy_cfg = dev_args.deploy_config.as_ref().map(|config_path| { - let mut cfg = DeployConfig::load(config_path) - .unwrap_or_else(|e| panic!("failed to load deploy config: {e}")); - - let genesis_chain_id = chain_id_from_genesis(); - if cfg.chain.chain_id != genesis_chain_id { - eprintln!( - "WARNING: deploy config chain_id ({}) differs from devnet genesis ({}), overriding to {}", - cfg.chain.chain_id, genesis_chain_id, genesis_chain_id - ); - cfg.chain.chain_id = genesis_chain_id; - } - cfg - }); - - if !dev_args.silent { - print_banner(&dev_args, deploy_cfg.as_ref()); - } - - let genesis_json = if let Some(ref cfg) = deploy_cfg { - let mut genesis: serde_json::Value = - serde_json::from_str(DEVNET_GENESIS).expect("valid genesis JSON"); - merge_alloc(cfg, &mut genesis, true).expect("failed to merge deploy config into genesis"); - serde_json::to_string(&genesis).expect("failed to serialize merged genesis") - } else { - DEVNET_GENESIS.to_string() - }; - - // Write genesis to a temp file that lives for the process duration - let mut genesis_file = - tempfile::NamedTempFile::new().expect("failed to create temp genesis file"); - genesis_file - .write_all(genesis_json.as_bytes()) - .expect("failed to write genesis"); - let genesis_path = genesis_file - .path() - .to_str() - .expect("valid path") - .to_string(); - - // Use a temp data directory so each run starts with clean state - let datadir = tempfile::TempDir::new().expect("failed to create temp data dir"); - let datadir_path = datadir.path().to_str().expect("valid path").to_string(); - +fn build_reth_args( + dev_args: &EvDevArgs, + genesis_path: String, + datadir_path: String, +) -> Vec { let mut args = vec![ "ev-dev".to_string(), "node".to_string(), @@ -236,6 +195,94 @@ fn main() { args.push(format!("{}s", dev_args.block_time)); } + args +} + +fn prepare_genesis( + deploy_cfg: &Option, +) -> (tempfile::NamedTempFile, tempfile::TempDir) { + let genesis_json = if let Some(ref cfg) = deploy_cfg { + let mut genesis: serde_json::Value = + serde_json::from_str(DEVNET_GENESIS).expect("valid genesis JSON"); + merge_alloc(cfg, &mut genesis, true).expect("failed to merge deploy config into genesis"); + serde_json::to_string(&genesis).expect("failed to serialize merged genesis") + } else { + DEVNET_GENESIS.to_string() + }; + + let mut genesis_file = + tempfile::NamedTempFile::new().expect("failed to create temp genesis file"); + genesis_file + .write_all(genesis_json.as_bytes()) + .expect("failed to write genesis"); + + let datadir = tempfile::TempDir::new().expect("failed to create temp data dir"); + + (genesis_file, datadir) +} + +fn load_deploy_config(dev_args: &EvDevArgs) -> Option { + dev_args.deploy_config.as_ref().map(|config_path| { + let mut cfg = DeployConfig::load(config_path) + .unwrap_or_else(|e| panic!("failed to load deploy config: {e}")); + + let genesis_chain_id = chain_id_from_genesis(); + if cfg.chain.chain_id != genesis_chain_id { + eprintln!( + "WARNING: deploy config chain_id ({}) differs from devnet genesis ({}), overriding to {}", + cfg.chain.chain_id, genesis_chain_id, genesis_chain_id + ); + cfg.chain.chain_id = genesis_chain_id; + } + cfg + }) +} + +fn deploy_contracts_list(deploy_cfg: &Option) -> Option> { + deploy_cfg.as_ref().map(|cfg| { + let manifest = build_manifest(cfg); + manifest + .as_object() + .map(|obj| { + obj.iter() + .map(|(name, addr)| (name.clone(), addr.as_str().unwrap_or("").to_string())) + .collect() + }) + .unwrap_or_default() + }) +} + +fn main() { + reth_cli_util::sigsegv_handler::install(); + + if std::env::var_os("RUST_BACKTRACE").is_none() { + std::env::set_var("RUST_BACKTRACE", "1"); + } + + let dev_args = EvDevArgs::parse(); + let deploy_cfg = load_deploy_config(&dev_args); + + if dev_args.tui { + run_with_tui(dev_args, deploy_cfg); + } else { + run_without_tui(dev_args, deploy_cfg); + } +} + +fn run_without_tui(dev_args: EvDevArgs, deploy_cfg: Option) { + if !dev_args.silent { + print_banner(&dev_args, deploy_cfg.as_ref()); + } + + let (genesis_file, datadir) = prepare_genesis(&deploy_cfg); + let genesis_path = genesis_file + .path() + .to_str() + .expect("valid path") + .to_string(); + let datadir_path = datadir.path().to_str().expect("valid path").to_string(); + let args = build_reth_args(&dev_args, genesis_path, datadir_path); + let cli = match Cli::::try_parse_from(args) { Ok(cli) => cli, Err(err) => { @@ -265,3 +312,68 @@ fn main() { std::process::exit(1); } } + +fn run_with_tui(dev_args: EvDevArgs, deploy_cfg: Option) { + let (log_tx, log_rx) = tokio::sync::mpsc::channel(10_000); + + // Install our tracing subscriber with the TUI layer BEFORE cli.run(). + // When reth's internal init_tracing calls try_init(), it will find a + // subscriber already installed and silently skip its own setup. + let tui_layer = tui::TuiTracingLayer::new(log_tx); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()); + + tracing_subscriber::registry() + .with(filter) + .with(tui_layer) + .init(); + + let chain_id = chain_id_from_genesis(); + let rpc_url = format!("http://{}:{}", dev_args.host, dev_args.port); + let block_time = dev_args.block_time; + let accounts = derive_keys(dev_args.accounts); + let contracts = deploy_contracts_list(&deploy_cfg); + + let app = tui::App::new(chain_id, rpc_url, block_time, accounts, contracts, log_rx); + + let (genesis_file, datadir) = prepare_genesis(&deploy_cfg); + let genesis_path = genesis_file + .path() + .to_str() + .expect("valid path") + .to_string(); + let datadir_path = datadir.path().to_str().expect("valid path").to_string(); + let args = build_reth_args(&dev_args, genesis_path, datadir_path); + + let cli = match Cli::::try_parse_from(args) { + Ok(cli) => cli, + Err(err) => { + eprintln!("{err}"); + std::process::exit(2); + } + }; + + if let Err(err) = cli.run(|builder, _evolve_args| async move { + info!("=== EV-DEV: Starting local development chain (TUI) ==="); + let _handle = builder + .node(EvolveNode::new()) + .extend_rpc_modules(move |ctx| { + let evolve_cfg = EvolveConfig::default(); + let evolve_txpool = + EvolveTxpoolApiImpl::new(ctx.pool().clone(), evolve_cfg.max_txpool_bytes); + ctx.modules.merge_configured(evolve_txpool.into_rpc())?; + Ok(()) + }) + .launch_with_debug_capabilities() + .await?; + + info!("=== EV-DEV: Local chain running - RPC ready ==="); + + tui::run(app).await?; + + Ok(()) + }) { + let _ = tui::restore_terminal(); + eprintln!("Error: {err:?}"); + std::process::exit(1); + } +} diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs new file mode 100644 index 0000000..d0a5b23 --- /dev/null +++ b/bin/ev-dev/src/tui/app.rs @@ -0,0 +1,156 @@ +use std::{collections::VecDeque, time::Instant}; + +use tokio::sync::mpsc; + +const MAX_LOGS: usize = 1000; +const MAX_BLOCKS: usize = 200; + +#[derive(Debug, Clone)] +pub(crate) struct BlockInfo { + pub(crate) number: u64, + pub(crate) hash: String, + pub(crate) tx_count: u64, + pub(crate) gas_used: u64, +} + +#[derive(Debug, Clone)] +pub(crate) struct LogEntry { + pub(crate) level: tracing::Level, + pub(crate) target: String, + pub(crate) message: String, + pub(crate) fields: Vec<(String, String)>, + pub(crate) timestamp: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Panel { + Blocks, + Logs, + Accounts, +} + +pub(crate) struct App { + // Static + pub(crate) chain_id: u64, + pub(crate) rpc_url: String, + pub(crate) block_time: u64, + pub(crate) accounts: Vec<(String, String)>, + pub(crate) deploy_contracts: Option>, + + // Dynamic + pub(crate) blocks: VecDeque, + pub(crate) logs: VecDeque, + pub(crate) current_block: u64, + pub(crate) start_time: Instant, + + // UI state + pub(crate) active_panel: Panel, + pub(crate) log_scroll: usize, + pub(crate) block_scroll: usize, + pub(crate) should_quit: bool, + + // Channel + pub(crate) log_rx: mpsc::Receiver, +} + +impl App { + pub(crate) fn new( + chain_id: u64, + rpc_url: String, + block_time: u64, + accounts: Vec<(String, String)>, + deploy_contracts: Option>, + log_rx: mpsc::Receiver, + ) -> Self { + Self { + chain_id, + rpc_url, + block_time, + accounts, + deploy_contracts, + blocks: VecDeque::new(), + logs: VecDeque::new(), + current_block: 0, + start_time: Instant::now(), + active_panel: Panel::Logs, + log_scroll: 0, + block_scroll: 0, + should_quit: false, + log_rx, + } + } + + pub(crate) fn drain_logs(&mut self) { + while let Ok(entry) = self.log_rx.try_recv() { + if entry.message == "built block" { + if let Some(block) = self.parse_block_from_fields(&entry.fields) { + self.current_block = block.number; + self.blocks.push_front(block); + if self.blocks.len() > MAX_BLOCKS { + self.blocks.pop_back(); + } + } + } + + self.logs.push_back(entry); + if self.logs.len() > MAX_LOGS { + self.logs.pop_front(); + } + } + } + + fn parse_block_from_fields(&self, fields: &[(String, String)]) -> Option { + let mut number = None; + let mut hash = String::new(); + let mut tx_count = 0; + let mut gas_used = 0; + + for (k, v) in fields { + match k.as_str() { + "block_number" => number = v.parse().ok(), + "block_hash" => { + let h = v.trim_matches('"'); + hash = if h.len() > 10 { + format!("{}..{}", &h[..6], &h[h.len() - 4..]) + } else { + h.to_string() + }; + } + "tx_count" => tx_count = v.parse().unwrap_or(0), + "gas_used" => gas_used = v.parse().unwrap_or(0), + _ => {} + } + } + + number.map(|n| BlockInfo { + number: n, + hash, + tx_count, + gas_used, + }) + } + + pub(crate) fn next_panel(&mut self) { + self.active_panel = match self.active_panel { + Panel::Blocks => Panel::Logs, + Panel::Logs => Panel::Accounts, + Panel::Accounts => Panel::Blocks, + }; + } + + pub(crate) fn scroll_up(&mut self) { + match self.active_panel { + Panel::Logs => self.log_scroll = self.log_scroll.saturating_add(1), + Panel::Blocks => self.block_scroll = self.block_scroll.saturating_add(1), + Panel::Accounts => {} + } + } + + pub(crate) fn scroll_down(&mut self) { + match self.active_panel { + Panel::Logs => self.log_scroll = self.log_scroll.saturating_sub(1), + Panel::Blocks => self.block_scroll = self.block_scroll.saturating_sub(1), + Panel::Accounts => {} + } + } +} diff --git a/bin/ev-dev/src/tui/events.rs b/bin/ev-dev/src/tui/events.rs new file mode 100644 index 0000000..5606ac8 --- /dev/null +++ b/bin/ev-dev/src/tui/events.rs @@ -0,0 +1,16 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::app::App; + +pub(crate) fn handle_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + KeyCode::Tab => app.next_panel(), + KeyCode::Up => app.scroll_up(), + KeyCode::Down => app.scroll_down(), + _ => {} + } +} diff --git a/bin/ev-dev/src/tui/mod.rs b/bin/ev-dev/src/tui/mod.rs new file mode 100644 index 0000000..b611a3a --- /dev/null +++ b/bin/ev-dev/src/tui/mod.rs @@ -0,0 +1,72 @@ +pub(crate) mod app; +mod events; +mod tracing_layer; +mod ui; + +pub(crate) use app::App; +pub(crate) use tracing_layer::TuiTracingLayer; + +use std::io::{self, stdout}; + +use crossterm::{ + event::{Event, EventStream}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use futures::StreamExt; +use ratatui::prelude::CrosstermBackend; + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + } +} + +pub(crate) async fn run(mut app: App) -> eyre::Result<()> { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = stdout().execute(LeaveAlternateScreen); + original_hook(info); + })); + + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + let _guard = TerminalGuard; + + let backend = CrosstermBackend::new(stdout()); + let mut terminal = ratatui::Terminal::new(backend)?; + + let mut event_stream = EventStream::new(); + let mut tick = tokio::time::interval(std::time::Duration::from_millis(100)); + + loop { + if app.should_quit { + break; + } + + tokio::select! { + _ = tick.tick() => { + app.drain_logs(); + terminal.draw(|frame| ui::draw(frame, &app))?; + } + maybe_event = event_stream.next() => { + if let Some(Ok(Event::Key(key))) = maybe_event { + events::handle_key(&mut app, key); + } + } + } + } + + // Terminal restored by TerminalGuard drop + Ok(()) +} + +pub(crate) fn restore_terminal() -> io::Result<()> { + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) +} diff --git a/bin/ev-dev/src/tui/tracing_layer.rs b/bin/ev-dev/src/tui/tracing_layer.rs new file mode 100644 index 0000000..7ea78d5 --- /dev/null +++ b/bin/ev-dev/src/tui/tracing_layer.rs @@ -0,0 +1,83 @@ +use std::time::Instant; + +use tokio::sync::mpsc; +use tracing::{ + field::{Field, Visit}, + Subscriber, +}; +use tracing_subscriber::{layer::Context, registry::LookupSpan, Layer}; + +use super::app::LogEntry; + +struct FieldCollector { + fields: Vec<(String, String)>, +} + +impl FieldCollector { + const fn new() -> Self { + Self { fields: Vec::new() } + } + + fn take_message(&mut self) -> String { + if let Some(pos) = self.fields.iter().position(|(k, _)| k == "message") { + self.fields.remove(pos).1 + } else { + String::new() + } + } +} + +impl Visit for FieldCollector { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.fields + .push((field.name().to_string(), format!("{:?}", value))); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.fields + .push((field.name().to_string(), value.to_string())); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.fields + .push((field.name().to_string(), value.to_string())); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.fields + .push((field.name().to_string(), value.to_string())); + } +} + +pub(crate) struct TuiTracingLayer { + tx: mpsc::Sender, +} + +impl TuiTracingLayer { + pub(crate) const fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } +} + +impl Layer for TuiTracingLayer +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, +{ + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + let mut collector = FieldCollector::new(); + event.record(&mut collector); + + let message = collector.take_message(); + let metadata = event.metadata(); + + let entry = LogEntry { + level: *metadata.level(), + target: metadata.target().to_string(), + message, + fields: collector.fields, + timestamp: Instant::now(), + }; + + let _ = self.tx.try_send(entry); + } +} diff --git a/bin/ev-dev/src/tui/ui.rs b/bin/ev-dev/src/tui/ui.rs new file mode 100644 index 0000000..fe3d783 --- /dev/null +++ b/bin/ev-dev/src/tui/ui.rs @@ -0,0 +1,288 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, + Frame, +}; + +use super::app::{App, Panel}; + +fn border_style(app: &App, panel: Panel) -> Style { + if app.active_panel == panel { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + } +} + +const fn level_color(level: &tracing::Level) -> Color { + match *level { + tracing::Level::ERROR => Color::Red, + tracing::Level::WARN => Color::Yellow, + tracing::Level::INFO => Color::Green, + tracing::Level::DEBUG | tracing::Level::TRACE => Color::DarkGray, + } +} + +fn format_uptime(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { + format!("{h}h{m:02}m{s:02}s") + } else if m > 0 { + format!("{m}m{s:02}s") + } else { + format!("{s}s") + } +} + +fn format_gas(gas: u64) -> String { + if gas >= 1_000_000 { + format!("{:.1}M", gas as f64 / 1_000_000.0) + } else if gas >= 1_000 { + format!("{:.1}k", gas as f64 / 1_000.0) + } else { + gas.to_string() + } +} + +pub(crate) fn draw(frame: &mut Frame<'_>, app: &App) { + let area = frame.area(); + + let outer = Layout::vertical([ + Constraint::Length(3), // header + Constraint::Min(6), // main content + Constraint::Length(3), // footer + ]) + .split(area); + + draw_header(frame, app, outer[0]); + draw_main(frame, app, outer[1]); + draw_footer(frame, app, outer[2]); +} + +fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { + let block_time_str = if app.block_time == 0 { + "auto".to_string() + } else { + format!("{}s", app.block_time) + }; + + let text = Line::from(vec![ + Span::styled(" Chain: ", Style::default().fg(Color::DarkGray)), + Span::styled(app.chain_id.to_string(), Style::default().fg(Color::White)), + Span::styled(" RPC: ", Style::default().fg(Color::DarkGray)), + Span::styled(&app.rpc_url, Style::default().fg(Color::Cyan)), + Span::styled(" Block: ", Style::default().fg(Color::DarkGray)), + Span::styled(block_time_str, Style::default().fg(Color::White)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .title(" ev-dev ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .border_style(Style::default().fg(Color::Cyan)); + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); +} + +fn draw_main(frame: &mut Frame<'_>, app: &App, area: Rect) { + let main_split = Layout::vertical([ + Constraint::Percentage(45), // top row (blocks + accounts) + Constraint::Percentage(55), // logs + ]) + .split(area); + + let top_split = Layout::horizontal([ + Constraint::Percentage(55), // blocks + Constraint::Percentage(45), // accounts + ]) + .split(main_split[0]); + + draw_blocks(frame, app, top_split[0]); + draw_accounts(frame, app, top_split[1]); + draw_logs(frame, app, main_split[1]); +} + +fn draw_blocks(frame: &mut Frame<'_>, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Blocks ") + .border_style(border_style(app, Panel::Blocks)); + + let header = Row::new(vec![ + Cell::from("Block").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Hash").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Txs").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Gas").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .style(Style::default().fg(Color::DarkGray)); + + let rows: Vec> = app + .blocks + .iter() + .skip(app.block_scroll) + .map(|b| { + Row::new(vec![ + Cell::from(format!("#{}", b.number)), + Cell::from(b.hash.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(format!("{}", b.tx_count)), + Cell::from(format_gas(b.gas_used)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(5), + Constraint::Min(6), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(block) + .row_highlight_style(Style::default().fg(Color::Cyan)); + + frame.render_widget(table, area); +} + +fn draw_accounts(frame: &mut Frame<'_>, app: &App, area: Rect) { + let mut items: Vec> = app + .accounts + .iter() + .enumerate() + .map(|(i, (addr, _key))| { + let truncated = if addr.len() > 10 { + format!("{}..{}", &addr[..6], &addr[addr.len() - 4..]) + } else { + addr.clone() + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("({i}) "), Style::default().fg(Color::DarkGray)), + Span::styled(truncated, Style::default().fg(Color::White)), + Span::styled(" 1000000 ETH", Style::default().fg(Color::Green)), + ])) + }) + .collect(); + + if let Some(ref contracts) = app.deploy_contracts { + items.push(ListItem::new(Line::from(""))); + items.push(ListItem::new(Line::from(Span::styled( + "Genesis Contracts", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )))); + for (name, addr) in contracts { + let truncated = if addr.len() > 10 { + format!("{}..{}", &addr[..6], &addr[addr.len() - 4..]) + } else { + addr.clone() + }; + items.push(ListItem::new(Line::from(vec![ + Span::styled(format!("{name:18} "), Style::default().fg(Color::DarkGray)), + Span::styled(truncated, Style::default().fg(Color::White)), + ]))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .title(" Accounts ") + .border_style(border_style(app, Panel::Accounts)); + + let list = List::new(items).block(block); + frame.render_widget(list, area); +} + +fn draw_logs(frame: &mut Frame<'_>, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Logs ") + .border_style(border_style(app, Panel::Logs)); + + let inner_height = area.height.saturating_sub(2) as usize; + let total = app.logs.len(); + + let end = total.saturating_sub(app.log_scroll); + let start = end.saturating_sub(inner_height); + + let items: Vec> = app + .logs + .iter() + .skip(start) + .take(end.saturating_sub(start)) + .map(|entry| { + let color = level_color(&entry.level); + let level_str = format!("{:5}", entry.level); + let elapsed = entry.timestamp.elapsed().as_secs(); + let ts = format!("{elapsed:>4}s"); + + let target_short = entry.target.rsplit("::").next().unwrap_or(&entry.target); + + let mut spans = vec![ + Span::styled(ts, Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(level_str, Style::default().fg(color)), + Span::raw(" "), + Span::styled( + format!("{target_short:>16} "), + Style::default().fg(Color::DarkGray), + ), + Span::styled(entry.message.clone(), Style::default().fg(Color::White)), + ]; + + for (k, v) in &entry.fields { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{k}="), + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled(v.clone(), Style::default().fg(Color::Gray))); + } + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(items).block(block); + frame.render_widget(list, area); +} + +fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { + let uptime = app.start_time.elapsed().as_secs(); + + let text = Line::from(vec![ + Span::styled(" Up: ", Style::default().fg(Color::DarkGray)), + Span::styled(format_uptime(uptime), Style::default().fg(Color::White)), + Span::styled(" Block: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("#{}", app.current_block), + Style::default().fg(Color::Cyan), + ), + Span::styled(" | ", Style::default().fg(Color::DarkGray)), + Span::styled("[q]", Style::default().fg(Color::Yellow)), + Span::styled("uit ", Style::default().fg(Color::DarkGray)), + Span::styled("[Tab]", Style::default().fg(Color::Yellow)), + Span::styled("focus ", Style::default().fg(Color::DarkGray)), + Span::styled("[", Style::default().fg(Color::Yellow)), + Span::styled("Up/Down", Style::default().fg(Color::Yellow)), + Span::styled("]", Style::default().fg(Color::Yellow)), + Span::styled("scroll", Style::default().fg(Color::DarkGray)), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); +} From e0c333054bb94afadb3e0178499ab20d906a8a26 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 20 Mar 2026 15:41:09 +0100 Subject: [PATCH 2/6] feat(ev-dev): add real-time balance polling to TUI dashboard --- Cargo.lock | 1 + bin/ev-dev/Cargo.toml | 1 + bin/ev-dev/src/main.rs | 12 ++++++- bin/ev-dev/src/tui/app.rs | 71 ++++++++++++++++++++++++++++++++++++++- bin/ev-dev/src/tui/mod.rs | 3 +- bin/ev-dev/src/tui/ui.rs | 7 +++- 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 608726b..587e1d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2985,6 +2985,7 @@ name = "ev-dev" version = "0.1.0" dependencies = [ "alloy-primitives", + "alloy-provider", "alloy-signer-local", "clap", "crossterm", diff --git a/bin/ev-dev/Cargo.toml b/bin/ev-dev/Cargo.toml index deed6ff..57115c7 100644 --- a/bin/ev-dev/Cargo.toml +++ b/bin/ev-dev/Cargo.toml @@ -25,6 +25,7 @@ reth-ethereum-cli.workspace = true # Alloy dependencies alloy-signer-local.workspace = true alloy-primitives.workspace = true +alloy-provider.workspace = true # Reth tracing (for Layers type) reth-tracing.workspace = true diff --git a/bin/ev-dev/src/main.rs b/bin/ev-dev/src/main.rs index 187f3a4..4c8b660 100644 --- a/bin/ev-dev/src/main.rs +++ b/bin/ev-dev/src/main.rs @@ -333,7 +333,16 @@ fn run_with_tui(dev_args: EvDevArgs, deploy_cfg: Option) { let accounts = derive_keys(dev_args.accounts); let contracts = deploy_contracts_list(&deploy_cfg); - let app = tui::App::new(chain_id, rpc_url, block_time, accounts, contracts, log_rx); + let (balance_tx, balance_rx) = tokio::sync::mpsc::channel(16); + let app = tui::App::new( + chain_id, + rpc_url.clone(), + block_time, + accounts.clone(), + contracts, + log_rx, + balance_rx, + ); let (genesis_file, datadir) = prepare_genesis(&deploy_cfg); let genesis_path = genesis_file @@ -368,6 +377,7 @@ fn run_with_tui(dev_args: EvDevArgs, deploy_cfg: Option) { info!("=== EV-DEV: Local chain running - RPC ready ==="); + tui::spawn_balance_poller(rpc_url, accounts, balance_tx); tui::run(app).await?; Ok(()) diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs index d0a5b23..a0ac2a5 100644 --- a/bin/ev-dev/src/tui/app.rs +++ b/bin/ev-dev/src/tui/app.rs @@ -1,5 +1,6 @@ use std::{collections::VecDeque, time::Instant}; +use alloy_primitives::{Address, U256}; use tokio::sync::mpsc; const MAX_LOGS: usize = 1000; @@ -42,6 +43,7 @@ pub(crate) struct App { pub(crate) logs: VecDeque, pub(crate) current_block: u64, pub(crate) start_time: Instant, + pub(crate) balances: Vec, // UI state pub(crate) active_panel: Panel, @@ -49,8 +51,9 @@ pub(crate) struct App { pub(crate) block_scroll: usize, pub(crate) should_quit: bool, - // Channel + // Channels pub(crate) log_rx: mpsc::Receiver, + pub(crate) balance_rx: mpsc::Receiver>, } impl App { @@ -61,7 +64,10 @@ impl App { accounts: Vec<(String, String)>, deploy_contracts: Option>, log_rx: mpsc::Receiver, + balance_rx: mpsc::Receiver>, ) -> Self { + let initial_balance = "1000000 ETH".to_string(); + let balances = vec![initial_balance; accounts.len()]; Self { chain_id, rpc_url, @@ -72,11 +78,19 @@ impl App { logs: VecDeque::new(), current_block: 0, start_time: Instant::now(), + balances, active_panel: Panel::Logs, log_scroll: 0, block_scroll: 0, should_quit: false, log_rx, + balance_rx, + } + } + + pub(crate) fn drain_balances(&mut self) { + while let Ok(new_balances) = self.balance_rx.try_recv() { + self.balances = new_balances; } } @@ -154,3 +168,58 @@ impl App { } } } + +fn format_ether(wei: U256) -> String { + let ether_unit = U256::from(10u64).pow(U256::from(18)); + let whole = wei / ether_unit; + let remainder = wei % ether_unit; + + let frac_digits = 4; + let frac_unit = U256::from(10u64).pow(U256::from(18 - frac_digits)); + let frac = remainder / frac_unit; + + let frac_val: u64 = frac.try_into().unwrap_or(0); + let formatted = format!("{whole}.{frac_val:0>4}"); + // Trim trailing zeros but keep at least one decimal + let trimmed = formatted.trim_end_matches('0'); + let trimmed = trimmed.trim_end_matches('.'); + format!("{trimmed} ETH") +} + +pub(crate) fn spawn_balance_poller( + rpc_url: String, + accounts: Vec<(String, String)>, + tx: mpsc::Sender>, +) { + let addresses: Vec
= accounts + .iter() + .filter_map(|(addr, _)| addr.parse().ok()) + .collect(); + + tokio::spawn(async move { + use alloy_provider::{Provider, ProviderBuilder}; + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + loop { + interval.tick().await; + + let provider = match ProviderBuilder::new() + .connect_http(rpc_url.parse().expect("valid RPC URL")) + { + provider => provider, + }; + + let mut balances = Vec::with_capacity(addresses.len()); + for addr in &addresses { + match provider.get_balance(*addr).await { + Ok(bal) => balances.push(format_ether(bal)), + Err(_) => balances.push("? ETH".to_string()), + } + } + + if tx.send(balances).await.is_err() { + break; + } + } + }); +} diff --git a/bin/ev-dev/src/tui/mod.rs b/bin/ev-dev/src/tui/mod.rs index b611a3a..083eff3 100644 --- a/bin/ev-dev/src/tui/mod.rs +++ b/bin/ev-dev/src/tui/mod.rs @@ -3,7 +3,7 @@ mod events; mod tracing_layer; mod ui; -pub(crate) use app::App; +pub(crate) use app::{spawn_balance_poller, App}; pub(crate) use tracing_layer::TuiTracingLayer; use std::io::{self, stdout}; @@ -51,6 +51,7 @@ pub(crate) async fn run(mut app: App) -> eyre::Result<()> { tokio::select! { _ = tick.tick() => { app.drain_logs(); + app.drain_balances(); terminal.draw(|frame| ui::draw(frame, &app))?; } maybe_event = event_stream.next() => { diff --git a/bin/ev-dev/src/tui/ui.rs b/bin/ev-dev/src/tui/ui.rs index fe3d783..3006f70 100644 --- a/bin/ev-dev/src/tui/ui.rs +++ b/bin/ev-dev/src/tui/ui.rs @@ -165,10 +165,15 @@ fn draw_accounts(frame: &mut Frame<'_>, app: &App, area: Rect) { } else { addr.clone() }; + let balance = app + .balances + .get(i) + .cloned() + .unwrap_or_else(|| "? ETH".to_string()); ListItem::new(Line::from(vec![ Span::styled(format!("({i}) "), Style::default().fg(Color::DarkGray)), Span::styled(truncated, Style::default().fg(Color::White)), - Span::styled(" 1000000 ETH", Style::default().fg(Color::Green)), + Span::styled(format!(" {balance}"), Style::default().fg(Color::Green)), ])) }) .collect(); From 8a3a4f362c303e14c39d37077e7dbe10891ed077 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Mon, 23 Mar 2026 14:21:12 +0100 Subject: [PATCH 3/6] fix(ev-dev): replace redundant match with direct let binding --- bin/ev-dev/src/tui/app.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs index a0ac2a5..29490e9 100644 --- a/bin/ev-dev/src/tui/app.rs +++ b/bin/ev-dev/src/tui/app.rs @@ -203,11 +203,8 @@ pub(crate) fn spawn_balance_poller( loop { interval.tick().await; - let provider = match ProviderBuilder::new() - .connect_http(rpc_url.parse().expect("valid RPC URL")) - { - provider => provider, - }; + let provider = + ProviderBuilder::new().connect_http(rpc_url.parse().expect("valid RPC URL")); let mut balances = Vec::with_capacity(addresses.len()); for addr in &addresses { From fa99c4f54a70b8dea2dbb9918cf295cef7f55e4e Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 24 Mar 2026 10:45:11 +0100 Subject: [PATCH 4/6] feat(ev-dev): add block selection and transaction detail overlay to TUI Add interactive block selection with arrow keys and Enter to fetch/display block transactions via RPC in a popup overlay. --- Cargo.lock | 271 ++++++++++++++++++++++++++++++++++- bin/ev-dev/Cargo.toml | 3 + bin/ev-dev/README.md | 40 +++++- bin/ev-dev/src/tui/app.rs | 141 +++++++++++++++++- bin/ev-dev/src/tui/events.rs | 24 +++- bin/ev-dev/src/tui/mod.rs | 1 + bin/ev-dev/src/tui/ui.rs | 195 ++++++++++++++++++++++--- 7 files changed, 647 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 587e1d9..cd194bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,6 +1039,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "ark-bls12-381" version = "0.5.0" @@ -1730,6 +1750,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -1922,6 +1948,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "coins-bip32" version = "0.12.0" @@ -2685,6 +2720,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2872,6 +2917,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "ethereum_hashing" version = "0.7.0" @@ -2984,9 +3035,12 @@ dependencies = [ name = "ev-dev" version = "0.1.0" dependencies = [ + "alloy-network", "alloy-primitives", "alloy-provider", + "alloy-rpc-types", "alloy-signer-local", + "arboard", "clap", "crossterm", "ev-deployer", @@ -3315,6 +3369,35 @@ dependencies = [ "bytes", ] +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fdlimit" version = "0.3.0" @@ -3607,6 +3690,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3753,6 +3846,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash-db" version = "0.15.2" @@ -4215,6 +4319,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -5180,6 +5298,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multiaddr" version = "0.18.2" @@ -5436,6 +5564,27 @@ dependencies = [ "smallvec", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -5443,6 +5592,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -5455,6 +5636,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6012,6 +6204,19 @@ dependencies = [ "crunchy", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polyval" version = "0.6.2" @@ -6221,6 +6426,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quanta" version = "0.12.6" @@ -6242,6 +6453,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-protobuf" version = "0.8.1" @@ -9839,7 +10056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -10624,6 +10841,20 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "tikv-jemalloc-ctl" version = "0.6.1" @@ -11727,6 +11958,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -12393,6 +12630,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -12559,3 +12813,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +dependencies = [ + "zune-core", +] diff --git a/bin/ev-dev/Cargo.toml b/bin/ev-dev/Cargo.toml index 57115c7..db27463 100644 --- a/bin/ev-dev/Cargo.toml +++ b/bin/ev-dev/Cargo.toml @@ -26,6 +26,8 @@ reth-ethereum-cli.workspace = true alloy-signer-local.workspace = true alloy-primitives.workspace = true alloy-provider.workspace = true +alloy-rpc-types.workspace = true +alloy-network.workspace = true # Reth tracing (for Layers type) reth-tracing.workspace = true @@ -43,6 +45,7 @@ futures.workspace = true ratatui = "0.30" crossterm = { version = "0.29", features = ["event-stream"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } +arboard = "3" [lints] workspace = true diff --git a/bin/ev-dev/README.md b/bin/ev-dev/README.md index 39a615f..2338766 100644 --- a/bin/ev-dev/README.md +++ b/bin/ev-dev/README.md @@ -2,15 +2,24 @@ One-command local development chain for Evolve. Think of it as the Evolve equivalent of [Hardhat Node](https://hardhat.org/hardhat-network/docs/overview) or [Anvil](https://book.getfoundry.sh/reference/anvil/). +## Installation + +```bash +# Install to ~/.cargo/bin +just install-ev-dev + +# Or build without installing +just build-ev-dev +``` + ## Quick Start ```bash # Build and run just dev-chain -# Or build separately -just build-ev-dev -./target/release/ev-dev +# Or run directly after installing +ev-dev ``` The chain starts immediately with 10 pre-funded accounts, each holding 1,000,000 ETH. @@ -28,6 +37,31 @@ ev-dev [OPTIONS] | `--block-time` | `1` | Block time in seconds (`0` = mine on transaction) | | `--silent` | `false` | Suppress the startup banner | | `--accounts` | `10` | Number of accounts to display (1-20) | +| `--deploy-config` | — | Path to an ev-deployer TOML config to deploy contracts at genesis | +| `--tui` | `false` | Launch with an interactive terminal UI instead of plain log output | + +### TUI Mode + +Pass `--tui` to launch an interactive terminal dashboard: + +```bash +ev-dev --tui +``` + +The TUI shows: + +- **Chain info** — chain ID, RPC URL, block time +- **Accounts** — addresses, private keys, and real-time balances (polled every 2s) +- **Deployed contracts** — when using `--deploy-config` +- **Logs** — live node logs with scrollback + +Keyboard shortcuts: + +| Key | Action | +|-----|--------| +| `Tab` | Cycle between panels | +| `↑` / `↓` | Scroll within the active panel | +| `q` / `Esc` / `Ctrl+C` | Quit | ### Examples diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs index 29490e9..b9c7954 100644 --- a/bin/ev-dev/src/tui/app.rs +++ b/bin/ev-dev/src/tui/app.rs @@ -23,6 +23,20 @@ pub(crate) struct LogEntry { pub(crate) timestamp: Instant, } +#[derive(Debug, Clone)] +pub(crate) struct TxInfo { + pub(crate) hash: String, + pub(crate) from: String, + pub(crate) to: String, + pub(crate) value: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct BlockDetail { + pub(crate) number: u64, + pub(crate) txs: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Panel { Blocks, @@ -48,12 +62,17 @@ pub(crate) struct App { // UI state pub(crate) active_panel: Panel, pub(crate) log_scroll: usize, - pub(crate) block_scroll: usize, + pub(crate) block_selected: usize, + pub(crate) account_selected: usize, + pub(crate) clipboard_msg: Option<(String, Instant)>, + pub(crate) block_detail: Option, pub(crate) should_quit: bool, // Channels pub(crate) log_rx: mpsc::Receiver, pub(crate) balance_rx: mpsc::Receiver>, + pub(crate) detail_tx: mpsc::Sender, + pub(crate) detail_rx: mpsc::Receiver, } impl App { @@ -68,6 +87,7 @@ impl App { ) -> Self { let initial_balance = "1000000 ETH".to_string(); let balances = vec![initial_balance; accounts.len()]; + let (detail_tx, detail_rx) = mpsc::channel(4); Self { chain_id, rpc_url, @@ -81,10 +101,15 @@ impl App { balances, active_panel: Panel::Logs, log_scroll: 0, - block_scroll: 0, + block_selected: 0, + account_selected: 0, + clipboard_msg: None, + block_detail: None, should_quit: false, log_rx, balance_rx, + detail_tx, + detail_rx, } } @@ -155,18 +180,122 @@ impl App { pub(crate) fn scroll_up(&mut self) { match self.active_panel { Panel::Logs => self.log_scroll = self.log_scroll.saturating_add(1), - Panel::Blocks => self.block_scroll = self.block_scroll.saturating_add(1), - Panel::Accounts => {} + Panel::Blocks => { + self.block_selected = self.block_selected.saturating_sub(1); + } + Panel::Accounts => { + self.account_selected = self.account_selected.saturating_sub(1); + } } } pub(crate) fn scroll_down(&mut self) { match self.active_panel { Panel::Logs => self.log_scroll = self.log_scroll.saturating_sub(1), - Panel::Blocks => self.block_scroll = self.block_scroll.saturating_sub(1), - Panel::Accounts => {} + Panel::Blocks => { + if !self.blocks.is_empty() { + self.block_selected = + (self.block_selected + 1).min(self.blocks.len() - 1); + } + } + Panel::Accounts => { + if !self.accounts.is_empty() { + self.account_selected = + (self.account_selected + 1).min(self.accounts.len() - 1); + } + } + } + } + + pub(crate) fn copy_account_address(&mut self) { + if let Some((addr, _)) = self.accounts.get(self.account_selected) { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(addr.clone()); + let truncated = if addr.len() > 10 { + format!("{}..{}", &addr[..6], &addr[addr.len() - 4..]) + } else { + addr.clone() + }; + self.clipboard_msg = + Some((format!("Copied address {truncated}"), Instant::now())); + } + } + } + + pub(crate) fn copy_account_key(&mut self) { + if let Some((_, key)) = self.accounts.get(self.account_selected) { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(key.clone()); + self.clipboard_msg = + Some(("Copied private key".to_string(), Instant::now())); + } + } + } + + pub(crate) fn fetch_block_detail(&self) { + let Some(block_info) = self.blocks.get(self.block_selected) else { + return; + }; + let tx = self.detail_tx.clone(); + let rpc_url = self.rpc_url.clone(); + let block_num = block_info.number; + + tokio::spawn(async move { + use alloy_network::TransactionResponse; + use alloy_provider::{Provider, ProviderBuilder}; + use alloy_rpc_types::{BlockNumberOrTag, TransactionTrait}; + + let provider = + ProviderBuilder::new().connect_http(rpc_url.parse().expect("valid RPC URL")); + + let result = provider + .get_block_by_number(BlockNumberOrTag::Number(block_num)) + .full() + .await; + + let txs = match result { + Ok(Some(block)) => block + .transactions + .into_transactions() + .map(|t| { + let hash = format!("{}", t.tx_hash()); + let from = format!("{}", t.from()); + let to = t + .to() + .map_or("Contract Creation".into(), |a| truncate_hex(&format!("{a}"))); + let value = format_ether(t.value()); + TxInfo { + hash: truncate_hex(&hash), + from: truncate_hex(&from), + to, + value, + } + }) + .collect(), + _ => vec![], + }; + + let _ = tx.send(BlockDetail { number: block_num, txs }).await; + }); + } + + pub(crate) fn drain_block_detail(&mut self) { + if let Ok(detail) = self.detail_rx.try_recv() { + self.block_detail = Some(detail); } } + + pub(crate) fn close_block_detail(&mut self) { + self.block_detail = None; + } +} + +fn truncate_hex(s: &str) -> String { + if s.len() > 10 { + format!("{}..{}", &s[..6], &s[s.len() - 4..]) + } else { + s.to_string() + } } fn format_ether(wei: U256) -> String { diff --git a/bin/ev-dev/src/tui/events.rs b/bin/ev-dev/src/tui/events.rs index 5606ac8..8e23ea9 100644 --- a/bin/ev-dev/src/tui/events.rs +++ b/bin/ev-dev/src/tui/events.rs @@ -1,8 +1,21 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use super::app::App; +use super::app::{App, Panel}; pub(crate) fn handle_key(app: &mut App, key: KeyEvent) { + // If block detail overlay is open, handle it separately + if app.block_detail.is_some() { + match key.code { + KeyCode::Esc | KeyCode::Enter => app.close_block_detail(), + KeyCode::Char('q') => app.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + _ => {} + } + return; + } + match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -11,6 +24,15 @@ pub(crate) fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Tab => app.next_panel(), KeyCode::Up => app.scroll_up(), KeyCode::Down => app.scroll_down(), + KeyCode::Char('a') if app.active_panel == Panel::Accounts => { + app.copy_account_address(); + } + KeyCode::Char('k') if app.active_panel == Panel::Accounts => { + app.copy_account_key(); + } + KeyCode::Enter if app.active_panel == Panel::Blocks => { + app.fetch_block_detail(); + } _ => {} } } diff --git a/bin/ev-dev/src/tui/mod.rs b/bin/ev-dev/src/tui/mod.rs index 083eff3..10f0934 100644 --- a/bin/ev-dev/src/tui/mod.rs +++ b/bin/ev-dev/src/tui/mod.rs @@ -52,6 +52,7 @@ pub(crate) async fn run(mut app: App) -> eyre::Result<()> { _ = tick.tick() => { app.drain_logs(); app.drain_balances(); + app.drain_block_detail(); terminal.draw(|frame| ui::draw(frame, &app))?; } maybe_event = event_stream.next() => { diff --git a/bin/ev-dev/src/tui/ui.rs b/bin/ev-dev/src/tui/ui.rs index 3006f70..211a0ba 100644 --- a/bin/ev-dev/src/tui/ui.rs +++ b/bin/ev-dev/src/tui/ui.rs @@ -2,11 +2,11 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, + widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table}, Frame, }; -use super::app::{App, Panel}; +use super::app::{App, BlockDetail, Panel}; fn border_style(app: &App, panel: Panel) -> Style { if app.active_panel == panel { @@ -61,6 +61,10 @@ pub(crate) fn draw(frame: &mut Frame<'_>, app: &App) { draw_header(frame, app, outer[0]); draw_main(frame, app, outer[1]); draw_footer(frame, app, outer[2]); + + if let Some(ref detail) = app.block_detail { + draw_block_detail(frame, detail, area); + } } fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { @@ -112,6 +116,8 @@ fn draw_main(frame: &mut Frame<'_>, app: &App, area: Rect) { } fn draw_blocks(frame: &mut Frame<'_>, app: &App, area: Rect) { + let is_focused = app.active_panel == Panel::Blocks; + let block = Block::default() .borders(Borders::ALL) .title(" Blocks ") @@ -125,36 +131,53 @@ fn draw_blocks(frame: &mut Frame<'_>, app: &App, area: Rect) { ]) .style(Style::default().fg(Color::DarkGray)); + // Auto-scroll to keep selected block visible + let inner_height = area.height.saturating_sub(4) as usize; // borders + header + header separator + let scroll = if inner_height > 0 && app.block_selected >= inner_height { + app.block_selected - inner_height + 1 + } else { + 0 + }; + let rows: Vec> = app .blocks .iter() - .skip(app.block_scroll) - .map(|b| { + .enumerate() + .skip(scroll) + .take(inner_height.max(1)) + .map(|(i, b)| { + let selected = is_focused && i == app.block_selected; + let marker = if selected { "▸" } else { " " }; + let style = if selected { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ - Cell::from(format!("#{}", b.number)), + Cell::from(format!("{marker}#{}", b.number)), Cell::from(b.hash.clone()).style(Style::default().fg(Color::DarkGray)), Cell::from(format!("{}", b.tx_count)), Cell::from(format_gas(b.gas_used)), ]) + .style(style) }) .collect(); let widths = [ - Constraint::Length(8), + Constraint::Length(10), Constraint::Length(12), Constraint::Length(5), Constraint::Min(6), ]; - let table = Table::new(rows, widths) - .header(header) - .block(block) - .row_highlight_style(Style::default().fg(Color::Cyan)); + let table = Table::new(rows, widths).header(header).block(block); frame.render_widget(table, area); } fn draw_accounts(frame: &mut Frame<'_>, app: &App, area: Rect) { + let is_focused = app.active_panel == Panel::Accounts; let mut items: Vec> = app .accounts .iter() @@ -170,9 +193,15 @@ fn draw_accounts(frame: &mut Frame<'_>, app: &App, area: Rect) { .get(i) .cloned() .unwrap_or_else(|| "? ETH".to_string()); + + let selected = is_focused && i == app.account_selected; + let marker = if selected { "▸ " } else { " " }; + let addr_color = if selected { Color::Cyan } else { Color::White }; + ListItem::new(Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Cyan)), Span::styled(format!("({i}) "), Style::default().fg(Color::DarkGray)), - Span::styled(truncated, Style::default().fg(Color::White)), + Span::styled(truncated, Style::default().fg(addr_color)), Span::styled(format!(" {balance}"), Style::default().fg(Color::Green)), ])) }) @@ -262,10 +291,98 @@ fn draw_logs(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(list, area); } +fn draw_block_detail(frame: &mut Frame<'_>, detail: &BlockDetail, area: Rect) { + let popup = centered_rect(80, 60, area); + frame.render_widget(Clear, popup); + + let title = format!( + " Block #{} ({} txs) ", + detail.number, + detail.txs.len() + ); + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .border_style(Style::default().fg(Color::Cyan)); + + if detail.txs.is_empty() { + let text = Paragraph::new(Line::from(vec![ + Span::styled( + " No transactions in this block", + Style::default().fg(Color::DarkGray), + ), + ])) + .block(block); + frame.render_widget(text, popup); + } else { + let header = Row::new(vec![ + Cell::from("Hash").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("From").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("To").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Value").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .style(Style::default().fg(Color::DarkGray)); + + let rows: Vec> = detail + .txs + .iter() + .map(|tx| { + Row::new(vec![ + Cell::from(tx.hash.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(tx.from.clone()), + Cell::from(tx.to.clone()), + Cell::from(tx.value.clone()).style(Style::default().fg(Color::Green)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(14), + Constraint::Length(14), + Constraint::Length(18), + Constraint::Min(10), + ]; + + let table = Table::new(rows, widths).header(header).block(block); + frame.render_widget(table, popup); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { let uptime = app.start_time.elapsed().as_secs(); - let text = Line::from(vec![ + // Check for clipboard flash message (show for 2 seconds) + let clipboard_flash = app.clipboard_msg.as_ref().and_then(|(msg, when)| { + if when.elapsed().as_secs() < 2 { + Some(msg.clone()) + } else { + None + } + }); + + let mut spans = vec![ Span::styled(" Up: ", Style::default().fg(Color::DarkGray)), Span::styled(format_uptime(uptime), Style::default().fg(Color::White)), Span::styled(" Block: ", Style::default().fg(Color::DarkGray)), @@ -278,11 +395,55 @@ fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("uit ", Style::default().fg(Color::DarkGray)), Span::styled("[Tab]", Style::default().fg(Color::Yellow)), Span::styled("focus ", Style::default().fg(Color::DarkGray)), - Span::styled("[", Style::default().fg(Color::Yellow)), - Span::styled("Up/Down", Style::default().fg(Color::Yellow)), - Span::styled("]", Style::default().fg(Color::Yellow)), - Span::styled("scroll", Style::default().fg(Color::DarkGray)), - ]); + ]; + + if app.block_detail.is_some() { + spans.extend([ + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::styled("close", Style::default().fg(Color::DarkGray)), + ]); + } else { + match app.active_panel { + Panel::Accounts => { + spans.extend([ + Span::styled("[↑↓]", Style::default().fg(Color::Yellow)), + Span::styled("select ", Style::default().fg(Color::DarkGray)), + Span::styled("[a]", Style::default().fg(Color::Yellow)), + Span::styled("ddress ", Style::default().fg(Color::DarkGray)), + Span::styled("[k]", Style::default().fg(Color::Yellow)), + Span::styled("ey", Style::default().fg(Color::DarkGray)), + ]); + } + Panel::Blocks => { + spans.extend([ + Span::styled("[↑↓]", Style::default().fg(Color::Yellow)), + Span::styled("select ", Style::default().fg(Color::DarkGray)), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::styled("txs", Style::default().fg(Color::DarkGray)), + ]); + } + Panel::Logs => { + spans.extend([ + Span::styled("[↑↓]", Style::default().fg(Color::Yellow)), + Span::styled("scroll", Style::default().fg(Color::DarkGray)), + ]); + } + } + } + + if let Some(msg) = clipboard_flash { + spans.extend([ + Span::styled(" ", Style::default()), + Span::styled( + format!("✓ {msg}"), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ]); + } + + let text = Line::from(spans); let block = Block::default() .borders(Borders::ALL) From b68f7f7537848ff79aca269cbc08577dd3fe25a0 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 24 Mar 2026 10:50:30 +0100 Subject: [PATCH 5/6] style(ev-dev): fix rustfmt formatting in TUI module --- bin/ev-dev/src/tui/app.rs | 22 ++++++++++++---------- bin/ev-dev/src/tui/ui.rs | 16 +++++----------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs index b9c7954..d55b389 100644 --- a/bin/ev-dev/src/tui/app.rs +++ b/bin/ev-dev/src/tui/app.rs @@ -194,8 +194,7 @@ impl App { Panel::Logs => self.log_scroll = self.log_scroll.saturating_sub(1), Panel::Blocks => { if !self.blocks.is_empty() { - self.block_selected = - (self.block_selected + 1).min(self.blocks.len() - 1); + self.block_selected = (self.block_selected + 1).min(self.blocks.len() - 1); } } Panel::Accounts => { @@ -216,8 +215,7 @@ impl App { } else { addr.clone() }; - self.clipboard_msg = - Some((format!("Copied address {truncated}"), Instant::now())); + self.clipboard_msg = Some((format!("Copied address {truncated}"), Instant::now())); } } } @@ -226,8 +224,7 @@ impl App { if let Some((_, key)) = self.accounts.get(self.account_selected) { if let Ok(mut clipboard) = arboard::Clipboard::new() { let _ = clipboard.set_text(key.clone()); - self.clipboard_msg = - Some(("Copied private key".to_string(), Instant::now())); + self.clipboard_msg = Some(("Copied private key".to_string(), Instant::now())); } } } @@ -260,9 +257,9 @@ impl App { .map(|t| { let hash = format!("{}", t.tx_hash()); let from = format!("{}", t.from()); - let to = t - .to() - .map_or("Contract Creation".into(), |a| truncate_hex(&format!("{a}"))); + let to = t.to().map_or("Contract Creation".into(), |a| { + truncate_hex(&format!("{a}")) + }); let value = format_ether(t.value()); TxInfo { hash: truncate_hex(&hash), @@ -275,7 +272,12 @@ impl App { _ => vec![], }; - let _ = tx.send(BlockDetail { number: block_num, txs }).await; + let _ = tx + .send(BlockDetail { + number: block_num, + txs, + }) + .await; }); } diff --git a/bin/ev-dev/src/tui/ui.rs b/bin/ev-dev/src/tui/ui.rs index 211a0ba..8bf4cff 100644 --- a/bin/ev-dev/src/tui/ui.rs +++ b/bin/ev-dev/src/tui/ui.rs @@ -295,11 +295,7 @@ fn draw_block_detail(frame: &mut Frame<'_>, detail: &BlockDetail, area: Rect) { let popup = centered_rect(80, 60, area); frame.render_widget(Clear, popup); - let title = format!( - " Block #{} ({} txs) ", - detail.number, - detail.txs.len() - ); + let title = format!(" Block #{} ({} txs) ", detail.number, detail.txs.len()); let block = Block::default() .borders(Borders::ALL) @@ -312,12 +308,10 @@ fn draw_block_detail(frame: &mut Frame<'_>, detail: &BlockDetail, area: Rect) { .border_style(Style::default().fg(Color::Cyan)); if detail.txs.is_empty() { - let text = Paragraph::new(Line::from(vec![ - Span::styled( - " No transactions in this block", - Style::default().fg(Color::DarkGray), - ), - ])) + let text = Paragraph::new(Line::from(vec![Span::styled( + " No transactions in this block", + Style::default().fg(Color::DarkGray), + )])) .block(block); frame.render_widget(text, popup); } else { From 50302597b847f9258ed0bbe509cd953c91e9f6fd Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 24 Mar 2026 10:56:06 +0100 Subject: [PATCH 6/6] fix(ev-dev): use map_or_else to satisfy clippy or_fun_call lint --- bin/ev-dev/src/tui/app.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/ev-dev/src/tui/app.rs b/bin/ev-dev/src/tui/app.rs index d55b389..b09d876 100644 --- a/bin/ev-dev/src/tui/app.rs +++ b/bin/ev-dev/src/tui/app.rs @@ -257,9 +257,10 @@ impl App { .map(|t| { let hash = format!("{}", t.tx_hash()); let from = format!("{}", t.from()); - let to = t.to().map_or("Contract Creation".into(), |a| { - truncate_hex(&format!("{a}")) - }); + let to = t.to().map_or_else( + || "Contract Creation".into(), + |a| truncate_hex(&format!("{a}")), + ); let value = format_ether(t.value()); TxInfo { hash: truncate_hex(&hash),