From b71ab0d2dad9849d95eb98106edb7b67c09ed3ef Mon Sep 17 00:00:00 2001 From: hardyjosh <1190022+hardyjosh@users.noreply.github.com> Date: Mon, 11 May 2026 12:31:29 +0000 Subject: [PATCH] feat: add interactive mode to strategy-builder CLI (#2546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation Constructing a non-interactive `raindex strategy-builder` invocation requires knowing the strategy key, deployment key, which select-tokens apply, which fields the deployment asks for, and the presets for each field. That is the exact flow the webapp walks users through visually. Asking a human or agent to read the raw `.rain` YAML to assemble those flags is a poor UX. ## Solution Add `-i` / `--interactive` to `strategy-builder`. It drives a TUI wizard in the terminal's alternate-screen buffer that mirrors the webapp flow: 1. Enter owner address 2. Pick a strategy (scrollable list with name + description, arrow navigation, paging) 3. Pick a deployment 4. Select tokens for each slot (with balance lookup) 5. Fill each field (preset picker with custom-value escape hatch) 6. Optionally add deposits (with preset amounts) 7. Generate calldata, print to stdout or save to a file Built on `crossterm` rather than `dialoguer` because dialoguer's cursor-up redraw maths miscounts multi-line wrapped items and corrupts the terminal. The alt-screen avoids all of that — the wizard owns its own screen and leaves the main terminal untouched. All prior selections are shown as context at the top of every screen so the user can see how choices accumulate. ## Checks - [x] made this PR as small as possible - [x] unit-tested any new functionality - [x] linked any relevant issues or PRs - [ ] included screenshots (if this involves a front-end change) ## Summary by CodeRabbit * **New Features** * Added interactive mode to the strategy builder CLI command with a guided wizard for configuring strategies and deployments. * Users can now select from available strategies, configure deployment variants, choose token approvals, and input deposits through an interactive prompt. * Added support for exporting generated deployment calldata to stdout or a specified file. --- Cargo.lock | 168 +++-- crates/cli/Cargo.toml | 3 + .../commands/strategy_builder/interactive.rs | 590 ++++++++++++++++++ .../mod.rs} | 39 +- .../src/commands/strategy_builder/select.rs | 396 ++++++++++++ 5 files changed, 1123 insertions(+), 73 deletions(-) create mode 100644 crates/cli/src/commands/strategy_builder/interactive.rs rename crates/cli/src/commands/{strategy_builder.rs => strategy_builder/mod.rs} (86%) create mode 100644 crates/cli/src/commands/strategy_builder/select.rs diff --git a/Cargo.lock b/Cargo.lock index 0c360ac26f..b265699eb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2565,8 +2565,11 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", + "mio", "parking_lot", "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -5313,9 +5316,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "pkg-config", "vcpkg", @@ -5568,6 +5571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -6717,6 +6721,25 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "rain-interpreter-eval" +version = "0.1.0" +dependencies = [ + "alloy", + "eyre", + "foundry-evm", + "once_cell", + "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", + "rain_interpreter_bindings", + "reqwest 0.11.27", + "revm 24.0.1", + "revm 25.0.0", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen-utils 0.0.10 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "rain-math-float" version = "0.1.0" @@ -6814,6 +6837,51 @@ dependencies = [ "alloy", ] +[[package]] +name = "rain_interpreter_bindings" +version = "0.1.0" +dependencies = [ + "alloy", +] + +[[package]] +name = "rain_interpreter_dispair" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", + "rain_interpreter_bindings", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber 0.3.19", +] + +[[package]] +name = "rain_interpreter_parser" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", + "rain_interpreter_bindings", + "rain_interpreter_dispair", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rain_interpreter_test_fixtures" +version = "0.0.0" +dependencies = [ + "alloy", + "getrandom 0.2.16", + "serde_json", +] + [[package]] name = "rain_orderbook_app_settings" version = "0.0.0-alpha.0" @@ -6859,7 +6927,10 @@ dependencies = [ "chrono", "clap", "comfy-table", + "console", + "crossterm", "csv", + "dialoguer", "flate2", "futures", "httpmock", @@ -6869,10 +6940,11 @@ dependencies = [ "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", + "rain_orderbook_js_api", "rain_orderbook_quote", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", - "rainlang_bindings", + "reqwest 0.12.20", "rusqlite", "rust-bigint", "serde", @@ -6909,19 +6981,19 @@ dependencies = [ "once_cell", "proptest", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", + "rain-interpreter-eval", "rain-math-float", "rain-metaboard-subgraph", "rain-metadata 0.0.2-alpha.6", "rain-metadata-bindings", + "rain_interpreter_bindings", + "rain_interpreter_dispair", + "rain_interpreter_parser", "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_quote", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", - "rainlang-eval", - "rainlang_bindings", - "rainlang_dispair", - "rainlang_parser", "reqwest 0.12.20", "rusqlite", "serde", @@ -6931,7 +7003,6 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "strict-yaml-rust", - "tempfile", "thiserror 1.0.69", "tokio", "tower", @@ -7007,19 +7078,17 @@ dependencies = [ "anyhow", "async-trait", "clap", - "futures", "getrandom 0.2.16", "httpmock", "once_cell", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", + "rain-interpreter-eval", "rain-math-float", - "rain-metadata 0.0.2-alpha.6", "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", "rain_orderbook_subgraph_client", "rain_orderbook_test_fixtures", - "rainlang-eval", "reqwest 0.12.20", "serde", "serde_json", @@ -7082,60 +7151,10 @@ dependencies = [ "alloy", "getrandom 0.2.16", "rain-math-float", - "rainlang_test_fixtures", + "rain_interpreter_test_fixtures", "serde_json", ] -[[package]] -name = "rainlang-eval" -version = "0.1.0" -dependencies = [ - "alloy", - "eyre", - "foundry-evm", - "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", - "rainlang_bindings", - "revm 24.0.1", - "revm 25.0.0", - "serde", - "thiserror 1.0.69", - "wasm-bindgen-utils 0.0.10 (registry+https://github.com/rust-lang/crates.io-index)", -] - -[[package]] -name = "rainlang_bindings" -version = "0.1.0" -dependencies = [ - "alloy", -] - -[[package]] -name = "rainlang_dispair" -version = "0.1.0" -dependencies = [ - "alloy", -] - -[[package]] -name = "rainlang_parser" -version = "0.1.0" -dependencies = [ - "alloy", - "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", - "rainlang_bindings", - "rainlang_dispair", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "rainlang_test_fixtures" -version = "0.0.0" -dependencies = [ - "alloy", - "getrandom 0.2.16", -] - [[package]] name = "rand" version = "0.8.5" @@ -8030,9 +8049,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags 2.9.1", "fallible-iterator", @@ -8748,6 +8767,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 66a642500d..4bf8405871 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,6 +33,9 @@ url.workspace = true serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } +console = "0.15" +crossterm = "0.28" +dialoguer = "0.11" flate2 = "1.0.34" rusqlite = { version = "0.32", features = ["functions"] } diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs new file mode 100644 index 0000000000..79d51eda9b --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -0,0 +1,590 @@ +use super::select::{self, SelectContext, SelectItem}; +use alloy::primitives::hex; +use anyhow::{Context, Result}; +use console::Style; +use crossterm::{cursor, execute, terminal}; +use rain_orderbook_app_settings::order_builder::{ + OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, +}; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::io::{stderr, Write}; + +fn bold(text: &str) -> String { + Style::new().bold().apply_to(text).to_string() +} + +fn dim(text: &str) -> String { + Style::new().dim().apply_to(text).to_string() +} + +/// RAII guard that restores the terminal to a sane state (cooked mode, main +/// screen, visible cursor) on drop, even if a panic unwinds through us. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!(stderr(), terminal::LeaveAlternateScreen, cursor::Show); + let _ = terminal::disable_raw_mode(); + } +} + +/// Enter alternate screen once, run the entire wizard there, leave at the end. +pub async fn run_interactive(registry_url: &str) -> Result<()> { + eprintln!(" Fetching strategies from {}...", dim(registry_url)); + + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut w = stderr(); + terminal::enable_raw_mode()?; + execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; + let _guard = TerminalGuard; + + let mut progress: Vec = Vec::new(); + let result = run_wizard(&mut w, ®istry, &mut progress).await; + + match result { + Ok(output) => { + eprintln!(); + for line in &progress { + eprintln!(" {line}"); + } + eprintln!(); + + for line in &output { + println!("{line}"); + } + Ok(()) + } + Err(err) => Err(err), + } +} + +async fn run_wizard( + w: &mut impl Write, + registry: &DotrainRegistry, + progress: &mut Vec, +) -> Result> { + // 1. Owner + let owner = select::input( + w, + "Owner address", + Some("The wallet that will own this order and sign the deploy transactions."), + None, + false, + progress, + )?; + progress.push(format!("{}: {owner}", bold("Owner"))); + + // 2. Strategy + let (strategy_key, dotrain) = pick_strategy(w, registry, progress)?; + progress.push(format!("{}: {strategy_key}", bold("Strategy"))); + + let settings = registry_settings(registry); + + // 3. Deployment + let deployment_key = pick_deployment(w, &dotrain, &settings, progress)?; + progress.push(format!("{}: {deployment_key}", bold("Deployment"))); + + render_progress(w, progress, Some("Initializing builder..."))?; + + let mut builder = + RaindexOrderBuilder::new_with_deployment(dotrain, settings.clone(), deployment_key) + .await + .map_err(|err| { + anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) + })?; + + // 4. Token selection + if let Ok(tokens) = builder.get_select_tokens() { + if !tokens.is_empty() { + select_tokens(w, &mut builder, &tokens, progress).await?; + } + } + + // 5. Fields + fill_fields(w, &mut builder, progress)?; + + // 6. Deposits + fill_deposits(w, &mut builder, &owner, progress).await?; + + // 7. Generate calldata + render_progress(w, progress, Some("Generating calldata..."))?; + + let args = builder + .get_deployment_transaction_args(owner.clone()) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to generate deployment calldata: {}", + err.to_readable_msg() + ) + })?; + + progress.push(format!("{}: {}", bold("Chain"), args.chain_id)); + progress.push(format!("{}: {}", bold("Orderbook"), args.orderbook_address)); + + let mut calldata_lines = Vec::new(); + for approval in &args.approvals { + calldata_lines.push(format!( + "{}:0x{}", + approval.token, + hex::encode(&approval.calldata) + )); + progress.push(format!( + " Approve {} — {} bytes", + Style::new().cyan().apply_to(&approval.symbol), + approval.calldata.len() + )); + } + calldata_lines.push(format!( + "{}:0x{}", + args.orderbook_address, + hex::encode(&args.deployment_calldata) + )); + progress.push(format!( + " Deploy order — {} bytes", + args.deployment_calldata.len() + )); + if let Some(meta_call) = &args.emit_meta_call { + calldata_lines.push(format!( + "{}:0x{}", + meta_call.to, + hex::encode(&meta_call.calldata) + )); + progress.push(" Emit metadata".to_string()); + } + + // 8. Output choice + let output_items = vec![ + SelectItem { + key: "Print to stdout".to_string(), + description: "address:calldata lines".to_string(), + }, + SelectItem { + key: "Save to file".to_string(), + description: String::new(), + }, + ]; + let ctx = SelectContext::new(progress); + let output_choice = select::select(w, "Output", &output_items, &ctx)?; + + match output_choice { + 0 => Ok(calldata_lines), + 1 => { + let path = select::input( + w, + "Output file path", + None, + Some("deploy.calldata"), + false, + progress, + )?; + + let mut file = + std::fs::File::create(&path).with_context(|| format!("creating {path}"))?; + for line in &calldata_lines { + writeln!(file, "{line}")?; + } + progress.push(format!(" Wrote to {path}")); + Ok(Vec::new()) + } + other => unreachable!("unexpected output_choice index: {other}"), + } +} + +fn render_progress(w: &mut impl Write, progress: &[String], status: Option<&str>) -> Result<()> { + execute!( + w, + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All) + )?; + for line in progress { + write!(w, " {line}\r\n")?; + } + if let Some(msg) = status { + write!(w, "\r\n {msg}\r\n")?; + } + w.flush()?; + Ok(()) +} + +fn pick_strategy( + w: &mut impl Write, + registry: &DotrainRegistry, + progress: &[String], +) -> Result<(String, String)> { + let details = registry + .get_all_order_details() + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + if details.valid.is_empty() { + anyhow::bail!("no valid strategies found in registry"); + } + + let keys: Vec<&String> = details.valid.keys().collect(); + let select_items: Vec = keys + .iter() + .map(|key| { + let info = &details.valid[*key]; + SelectItem { + key: key.to_string(), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } + }) + .collect(); + + let ctx = SelectContext::new(progress); + let idx = select::select(w, "Strategy", &select_items, &ctx)?; + + let key = keys[idx].clone(); + let dotrain = registry + .orders() + .0 + .get(&key) + .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? + .clone(); + + Ok((key, dotrain)) +} + +fn pick_deployment( + w: &mut impl Write, + dotrain: &str, + settings: &Option>, + progress: &[String], +) -> Result { + let deployments = + RaindexOrderBuilder::get_deployment_details(dotrain.to_string(), settings.clone()) + .map_err(|err| { + anyhow::anyhow!( + "failed to get deployment details: {}", + err.to_readable_msg() + ) + })?; + + if deployments.is_empty() { + anyhow::bail!("no deployments found for this strategy"); + } + + if deployments.len() == 1 { + let (key, _) = deployments.into_iter().next().unwrap(); + return Ok(key); + } + + let keys: Vec<&String> = deployments.keys().collect(); + let select_items: Vec = keys + .iter() + .map(|key| { + let info = &deployments[*key]; + SelectItem { + key: format!("{} ({})", info.name, key), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } + }) + .collect(); + + let ctx = SelectContext::new(progress); + let idx = select::select(w, "Deployment", &select_items, &ctx)?; + let key = keys[idx].clone(); + Ok(key) +} + +async fn select_tokens( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + tokens: &[OrderBuilderSelectTokensCfg], + progress: &mut Vec, +) -> Result<()> { + for token_cfg in tokens { + let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); + + let available = builder.get_all_tokens(None).await.unwrap_or_default(); + + let address = if available.is_empty() { + select::input( + w, + &format!("{prompt_label} (address)"), + token_cfg.description.as_deref(), + None, + false, + progress, + )? + } else { + let mut select_items: Vec = available + .iter() + .map(|t| SelectItem { + key: format!("{} ({})", t.symbol, t.name), + description: format!("{}", t.address), + }) + .collect(); + select_items.push(SelectItem { + key: "Enter address manually".to_string(), + description: String::new(), + }); + + let mut ctx = SelectContext::new(progress); + if let Some(desc) = &token_cfg.description { + ctx = ctx.with_description(desc); + } + let idx = select::select(w, prompt_label, &select_items, &ctx)?; + + if idx < available.len() { + let token = &available[idx]; + progress.push(format!( + "{}: {} ({})", + bold(prompt_label), + token.symbol, + token.address + )); + format!("{}", token.address) + } else { + let addr = select::input( + w, + &format!("{prompt_label} address"), + None, + None, + false, + progress, + )?; + progress.push(format!("{}: {addr}", bold(prompt_label))); + addr + } + }; + + builder + .set_select_token(token_cfg.key.clone(), address) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to select token '{}': {}", + token_cfg.key, + err.to_readable_msg() + ) + })?; + } + + Ok(()) +} + +fn fill_fields( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + progress: &mut Vec, +) -> Result<()> { + let missing = builder + .get_missing_field_values() + .map_err(|err| anyhow::anyhow!("failed to get fields: {}", err.to_readable_msg()))?; + + if missing.is_empty() { + return Ok(()); + } + + for field in &missing { + fill_single_field(w, builder, field, progress)?; + } + + Ok(()) +} + +fn fill_single_field( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + field: &OrderBuilderFieldDefinitionCfg, + progress: &mut Vec, +) -> Result<()> { + let value = match &field.presets { + Some(presets) if !presets.is_empty() => { + let show_custom = field.show_custom_field.unwrap_or(true); + + let mut select_items: Vec = presets + .iter() + .map(|p| { + let label = p.name.as_deref().unwrap_or(&p.value); + SelectItem { + key: label.to_string(), + description: format!("= {}", p.value), + } + }) + .collect(); + + if show_custom { + select_items.push(SelectItem { + key: "Custom value".to_string(), + description: String::new(), + }); + } + + let mut ctx = SelectContext::new(progress); + if let Some(desc) = &field.description { + ctx = ctx.with_description(desc); + } + let idx = select::select(w, &field.name, &select_items, &ctx)?; + + if idx < presets.len() { + presets[idx].value.clone() + } else { + select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )? + } + } + _ => select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )?, + }; + + progress.push(format!("{}: {value}", bold(&field.name))); + + builder + .set_field_value(field.binding.clone(), value) + .map_err(|err| { + anyhow::anyhow!( + "failed to set field '{}': {}", + field.binding, + err.to_readable_msg() + ) + })?; + + Ok(()) +} + +async fn fill_deposits( + w: &mut impl Write, + builder: &mut RaindexOrderBuilder, + owner: &str, + progress: &mut Vec, +) -> Result<()> { + let deployment = builder + .get_current_deployment() + .map_err(|err| anyhow::anyhow!("failed to get deployment: {}", err.to_readable_msg()))?; + + if deployment.deposits.is_empty() { + return Ok(()); + } + + for deposit_cfg in &deployment.deposits { + let (token_display, balance_desc) = + match builder.get_token_info(deposit_cfg.token_key.clone()).await { + Ok(info) => { + let balance = builder + .get_account_balance(format!("{}", info.address), owner.to_string()) + .await + .ok() + .map(|b| b.formatted_balance().to_string()); + let desc = balance + .map(|b| format!("Your balance: {b} {}", info.symbol)) + .unwrap_or_default(); + (info.symbol.clone(), desc) + } + Err(_) => (deposit_cfg.token_key.clone(), String::new()), + }; + + let presets = builder + .get_deposit_presets(deposit_cfg.token_key.clone()) + .unwrap_or_default(); + + let desc_opt = if balance_desc.is_empty() { + None + } else { + Some(balance_desc.as_str()) + }; + + let amount = if presets.is_empty() { + select::input( + w, + &format!("Deposit amount ({token_display}) — blank to skip"), + desc_opt, + None, + true, + progress, + )? + } else { + let mut select_items: Vec = presets + .iter() + .map(|p| SelectItem { + key: format!("{p} {token_display}"), + description: String::new(), + }) + .collect(); + select_items.push(SelectItem { + key: "Custom amount".to_string(), + description: String::new(), + }); + select_items.push(SelectItem { + key: "Skip".to_string(), + description: String::new(), + }); + + let title = format!("Deposit {token_display}"); + let mut ctx = SelectContext::new(progress); + if !balance_desc.is_empty() { + ctx = ctx.with_description(&balance_desc); + } + let idx = select::select(w, &title, &select_items, &ctx)?; + + if idx < presets.len() { + presets[idx].clone() + } else if idx == presets.len() { + select::input( + w, + &format!("Amount ({token_display})"), + None, + None, + false, + progress, + )? + } else { + continue; + } + }; + + if amount.is_empty() { + continue; + } + + progress.push(format!("{}: {amount} {token_display}", bold("Deposit"))); + + builder + .set_deposit(deposit_cfg.token_key.clone(), amount) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to set deposit '{}': {}", + deposit_cfg.token_key, + err.to_readable_msg() + ) + })?; + } + + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} diff --git a/crates/cli/src/commands/strategy_builder.rs b/crates/cli/src/commands/strategy_builder/mod.rs similarity index 86% rename from crates/cli/src/commands/strategy_builder.rs rename to crates/cli/src/commands/strategy_builder/mod.rs index 6add4f20ea..61a5c3a55e 100644 --- a/crates/cli/src/commands/strategy_builder.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,3 +1,6 @@ +mod interactive; +mod select; + use crate::execute::Execute; use alloy::primitives::hex; use anyhow::Result; @@ -14,14 +17,17 @@ pub struct StrategyBuilder { )] registry: String, + #[arg(short, long, help = "Interactive mode — guided strategy deployment")] + interactive: bool, + #[arg(long, help = "Order/strategy key from the registry")] - strategy: String, + strategy: Option, #[arg(long, help = "Deployment key within the strategy")] - deployment: String, + deployment: Option, #[arg(long, help = "Order owner address")] - owner: String, + owner: Option, #[arg( long = "set-field", @@ -65,6 +71,23 @@ fn parse_key_value_pairs(args: &[String]) -> Result> { impl Execute for StrategyBuilder { async fn execute(&self) -> Result<()> { + if self.interactive { + return interactive::run_interactive(&self.registry).await; + } + + let strategy = self + .strategy + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--strategy is required in non-interactive mode"))?; + let deployment = self + .deployment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--deployment is required in non-interactive mode"))?; + let owner = self + .owner + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--owner is required in non-interactive mode"))?; + let registry = DotrainRegistry::new(self.registry.clone()) .await .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; @@ -72,14 +95,12 @@ impl Execute for StrategyBuilder { let dotrain = registry .orders() .0 - .get(&self.strategy) + .get(strategy) .ok_or_else(|| { let mut available = registry.get_order_keys().unwrap_or_default(); available.sort(); anyhow::anyhow!( - "strategy '{}' not found in registry. Available: {:?}", - self.strategy, - available + "strategy '{strategy}' not found in registry. Available: {available:?}", ) })? .clone(); @@ -94,7 +115,7 @@ impl Execute for StrategyBuilder { }; let mut builder = - RaindexOrderBuilder::new_with_deployment(dotrain, settings, self.deployment.clone()) + RaindexOrderBuilder::new_with_deployment(dotrain, settings, deployment.clone()) .await .map_err(|err| { anyhow::anyhow!("failed to create order builder: {}", err.to_readable_msg()) @@ -130,7 +151,7 @@ impl Execute for StrategyBuilder { } let args = builder - .get_deployment_transaction_args(self.owner.clone()) + .get_deployment_transaction_args(owner.clone()) .await .map_err(|err| { anyhow::anyhow!( diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs new file mode 100644 index 0000000000..bc62fcc753 --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -0,0 +1,396 @@ +//! Select and Input widgets rendered directly to a writer. +//! The caller manages the alternate screen lifecycle. + +use anyhow::Result; +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; +use std::io::Write; + +const BOLD_UNDERLINE: &str = "\x1b[1;4m"; +const BOLD: &str = "\x1b[1m"; +const BOLD_CYAN: &str = "\x1b[1;36m"; +const DIM: &str = "\x1b[2m"; +const CYAN: &str = "\x1b[36m"; +const RESET: &str = "\x1b[0m"; + +pub struct SelectItem { + pub key: String, + pub description: String, +} + +/// Context shown above a prompt: prior selections and optional description. +pub struct SelectContext<'a> { + pub header_lines: &'a [String], + pub description: Option<&'a str>, +} + +impl<'a> SelectContext<'a> { + pub fn new(header_lines: &'a [String]) -> Self { + Self { + header_lines, + description: None, + } + } + + pub fn with_description(mut self, desc: &'a str) -> Self { + self.description = Some(desc); + self + } +} + +/// Word-wrap `text` to fit within `width` columns. Returns the number of lines. +fn wrap_lines(text: &str, width: usize) -> Vec { + if text.is_empty() || width == 0 { + return Vec::new(); + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current = word.to_string(); + } else if current.len() + 1 + word.len() <= width { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current = word.to_string(); + } + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Write wrapped `text` with the given indent and ANSI style wrapper. +/// Returns number of lines written. +fn write_wrapped( + w: &mut impl Write, + text: &str, + indent: &str, + style: &str, + width: usize, +) -> Result { + let lines = wrap_lines(text, width); + for line in &lines { + write!(w, "{indent}{style}{line}{RESET}\r\n")?; + } + Ok(lines.len()) +} + +/// Write the header (prior selections) and return the number of rows used. +fn write_header(w: &mut impl Write, header_lines: &[String]) -> Result { + for line in header_lines { + write!(w, " {line}\r\n")?; + } + if !header_lines.is_empty() { + write!(w, "\r\n")?; + Ok(header_lines.len() + 1) + } else { + Ok(0) + } +} + +/// Write bold-underlined title + optional dim description. Returns rows used. +fn write_title( + w: &mut impl Write, + title: &str, + description: Option<&str>, + cols: usize, +) -> Result { + write!(w, " {BOLD_UNDERLINE}{title}{RESET}\r\n")?; + let mut rows = 1; + if let Some(desc) = description { + rows += write_wrapped(w, desc, " ", DIM, cols.saturating_sub(2))?; + } + write!(w, "\r\n")?; + Ok(rows + 1) +} + +/// Height (in rendered terminal rows) of a single select item. +fn item_height(item: &SelectItem, cols: usize) -> usize { + // " ❯ " prefix (4) + trailing margin (1) + let usable = cols.saturating_sub(5); + let desc_lines = if item.description.is_empty() { + 0 + } else { + wrap_lines(&item.description, usable).len() + }; + 1 + desc_lines + 1 // name + description + blank separator +} + +/// Count how many items fit starting at `offset`, given `max_rows` available. +fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize, cols: usize) -> usize { + let mut rows = 0; + let mut count = 0; + for item in items.iter().skip(offset) { + let h = item_height(item, cols); + if rows + h > max_rows { + break; + } + rows += h; + count += 1; + } + count.max(1) +} + +/// Text input rendered in the alt screen. +pub fn input( + w: &mut impl Write, + prompt: &str, + description: Option<&str>, + default: Option<&str>, + allow_empty: bool, + header_lines: &[String], +) -> Result { + let mut buffer = String::new(); + loop { + let (cols, _) = terminal::size()?; + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + write_header(w, header_lines)?; + write_title(w, prompt, description, cols as usize)?; + + write!(w, " > {CYAN}{buffer}{RESET}")?; + if buffer.is_empty() { + if let Some(d) = default { + write!(w, "{DIM}{d}{RESET}")?; + } + } + write!(w, "\x1b[?25h")?; // show cursor + w.flush()?; + + if let Event::Key(KeyEvent { + code, modifiers, .. + }) = event::read()? + { + match code { + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + anyhow::bail!("cancelled"); + } + KeyCode::Char(c) => buffer.push(c), + KeyCode::Backspace => { + buffer.pop(); + } + KeyCode::Enter => match (buffer.is_empty(), default, allow_empty) { + (true, Some(d), _) => return Ok(d.to_string()), + (true, None, true) => return Ok(String::new()), + (true, None, false) => {} // keep looping — require input + (false, _, _) => return Ok(buffer), + }, + KeyCode::Esc => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +/// Scrollable select list. +pub fn select( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + ctx: &SelectContext, +) -> Result { + if items.is_empty() { + anyhow::bail!("no items to select from"); + } + if items.len() == 1 { + return Ok(0); + } + + let mut selected = 0usize; + let mut scroll = 0usize; + + loop { + let (cols, rows) = terminal::size()?; + let cols = cols as usize; + let rows = rows as usize; + + let header_rows = render_select(w, title, items, selected, scroll, ctx)?; + let max_rows = rows.saturating_sub(header_rows + 2); + + if let Event::Key(KeyEvent { code, .. }) = event::read()? { + match code { + KeyCode::Up | KeyCode::Char('k') => { + if selected > 0 { + selected -= 1; + if selected < scroll { + scroll = selected; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if selected + 1 < items.len() { + selected += 1; + let fitting = count_items_fitting(items, scroll, max_rows, cols); + if selected >= scroll + fitting { + scroll += 1; + } + } + } + KeyCode::Enter => return Ok(selected), + KeyCode::Esc | KeyCode::Char('q') => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +/// Render the select list. Returns the number of rows used by the header +/// (everything above the items) so the caller can compute `max_rows`. +fn render_select( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + selected: usize, + scroll: usize, + ctx: &SelectContext, +) -> Result { + let (cols, rows) = terminal::size()?; + let cols = cols as usize; + let rows = rows as usize; + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + + let header_rows = write_header(w, ctx.header_lines)?; + let title_rows = write_title(w, title, ctx.description, cols)?; + let total_header = header_rows + title_rows; + let max_rows = rows.saturating_sub(total_header + 2); + + let mut rows_used = 0; + for (idx, item) in items.iter().enumerate().skip(scroll) { + let h = item_height(item, cols); + if rows_used + h > max_rows { + break; + } + + let is_selected = idx == selected; + let (prefix, style) = if is_selected { + ("❯ ", BOLD_CYAN) + } else { + (" ", BOLD) + }; + write!(w, " {prefix}{style}{}{RESET}\r\n", item.key)?; + if !item.description.is_empty() { + write_wrapped(w, &item.description, " ", DIM, cols.saturating_sub(5))?; + } + write!(w, "\r\n")?; + rows_used += h; + } + + // Scroll indicators + let xpos = cols.saturating_sub(3) as u16; + let ypos_bottom = rows.saturating_sub(2) as u16; + let ypos_hint = rows.saturating_sub(1) as u16; + if scroll > 0 { + execute!(w, cursor::MoveTo(xpos, total_header as u16))?; + write!(w, " ▲")?; + } + if scroll + count_items_fitting(items, scroll, max_rows, cols) < items.len() { + execute!(w, cursor::MoveTo(xpos, ypos_bottom))?; + write!(w, " ▼")?; + } + + // Bottom hint + execute!(w, cursor::MoveTo(0, ypos_hint))?; + write!(w, " {DIM}↑↓ navigate ⏎ select esc quit{RESET}")?; + + w.flush()?; + Ok(total_header) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_empty_text_returns_no_lines() { + assert!(wrap_lines("", 10).is_empty()); + } + + #[test] + fn wrap_short_text_returns_single_line() { + assert_eq!(wrap_lines("hello world", 20), vec!["hello world"]); + } + + #[test] + fn wrap_long_text_breaks_on_word_boundaries() { + let result = wrap_lines("one two three four five", 10); + assert_eq!(result, vec!["one two", "three four", "five"]); + } + + #[test] + fn wrap_very_long_word_goes_on_own_line() { + let result = wrap_lines("a supercalifragilistic b", 10); + assert_eq!(result, vec!["a", "supercalifragilistic", "b"]); + } + + #[test] + fn wrap_zero_width_returns_no_lines() { + assert!(wrap_lines("hello", 0).is_empty()); + } + + #[test] + fn item_height_with_empty_description() { + let item = SelectItem { + key: "key".into(), + description: String::new(), + }; + // name line + blank separator = 2 + assert_eq!(item_height(&item, 80), 2); + } + + #[test] + fn item_height_with_description_wraps() { + let item = SelectItem { + key: "key".into(), + description: "one two three four five six seven eight nine ten".into(), + }; + // usable width = 80 - 5 = 75 — fits on one line + assert_eq!(item_height(&item, 80), 3); + } + + #[test] + fn item_height_with_description_narrow_terminal() { + let item = SelectItem { + key: "key".into(), + description: "one two three four five six seven eight nine ten".into(), + }; + // usable = 20 - 5 = 15 — "one two three" (13), "four five six" (13), etc. + let h = item_height(&item, 20); + assert!(h > 3, "expected wrapped description, got height {h}"); + } + + #[test] + fn count_items_fitting_respects_max_rows() { + let items = vec![ + SelectItem { + key: "a".into(), + description: String::new(), + }, + SelectItem { + key: "b".into(), + description: String::new(), + }, + SelectItem { + key: "c".into(), + description: String::new(), + }, + ]; + // Each item = 2 rows; 5 rows max fits 2 items (4 rows), not 3 (6 rows) + assert_eq!(count_items_fitting(&items, 0, 5, 80), 2); + } + + #[test] + fn count_items_fitting_always_returns_at_least_one() { + let items = vec![SelectItem { + key: "a".into(), + description: "very long description that will wrap many times".into(), + }]; + // Even with max_rows = 1, we return 1 to avoid an empty list + assert_eq!(count_items_fitting(&items, 0, 1, 80), 1); + } +}