From 875877e75e99235f7b0bd8dd976cb502c9d9645b Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 15:46:04 +0000 Subject: [PATCH 01/32] feat: reject empty KEY in KEY=VALUE parsing --- crates/cli/src/commands/strategy_builder.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/cli/src/commands/strategy_builder.rs b/crates/cli/src/commands/strategy_builder.rs index cd0644a8f3..5bc1870270 100644 --- a/crates/cli/src/commands/strategy_builder.rs +++ b/crates/cli/src/commands/strategy_builder.rs @@ -51,6 +51,10 @@ fn parse_key_value_pairs(args: &[String]) -> Result> { let (key, value) = arg .split_once('=') .ok_or_else(|| anyhow::anyhow!("expected KEY=VALUE, got: {arg}"))?; + let key = key.trim(); + if key.is_empty() { + anyhow::bail!("expected non-empty KEY in KEY=VALUE, got: {arg}"); + } if map.contains_key(key) { anyhow::bail!("duplicate key: {key}"); } @@ -194,6 +198,20 @@ mod tests { assert_eq!(map.get("key").unwrap(), "value=with=equals"); } + #[test] + fn parse_key_value_pairs_empty_key_fails() { + let args = vec!["=value".to_string()]; + let err = parse_key_value_pairs(&args).unwrap_err().to_string(); + assert!(err.contains("expected non-empty KEY"), "got: {err}"); + } + + #[test] + fn parse_key_value_pairs_whitespace_key_fails() { + let args = vec![" =value".to_string()]; + let err = parse_key_value_pairs(&args).unwrap_err().to_string(); + assert!(err.contains("expected non-empty KEY"), "got: {err}"); + } + #[test] fn parse_key_value_pairs_duplicate_key_fails() { let args = vec!["key=first".to_string(), "key=second".to_string()]; From 72e5a29a95677c005d27830eb653d1d17f0df463 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 20:49:46 +0000 Subject: [PATCH 02/32] chore: sort registry keys in error message for deterministic output --- crates/cli/src/commands/strategy_builder.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/strategy_builder.rs b/crates/cli/src/commands/strategy_builder.rs index 5bc1870270..6add4f20ea 100644 --- a/crates/cli/src/commands/strategy_builder.rs +++ b/crates/cli/src/commands/strategy_builder.rs @@ -74,7 +74,8 @@ impl Execute for StrategyBuilder { .0 .get(&self.strategy) .ok_or_else(|| { - let available = registry.get_order_keys().unwrap_or_default(); + let mut available = registry.get_order_keys().unwrap_or_default(); + available.sort(); anyhow::anyhow!( "strategy '{}' not found in registry. Available: {:?}", self.strategy, From 3a9c75a8d1a843cf59e2d39c881f47ae6168683c Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 10:19:10 +0000 Subject: [PATCH 03/32] feat: add interactive mode to strategy-builder CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guided deployment flow mirroring the webapp: 1. Fuzzy-select strategy from registry (shows name + description) 2. Pick deployment (shows name + description) 3. Select tokens with search across available token lists 4. Fill required fields with preset selection or custom input 5. Optional deposits with preset amounts 6. Enter owner address 7. Generate calldata — print to stdout or save to .calldata file Usage: raindex strategy-builder -i --registry Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 13 + crates/cli/Cargo.toml | 2 + .../commands/strategy_builder/interactive.rs | 440 ++++++++++++++++++ .../mod.rs} | 38 +- 4 files changed, 484 insertions(+), 9 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%) diff --git a/Cargo.lock b/Cargo.lock index 916309e6f9..3b2b67c0eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3014,6 +3014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ "console", + "fuzzy-matcher", "shell-words", "tempfile", "thiserror 1.0.69", @@ -4122,6 +4123,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "gcd" version = "2.3.0" @@ -6924,6 +6934,7 @@ dependencies = [ "clap", "comfy-table", "csv", + "dialoguer", "flate2", "futures", "httpmock", @@ -6933,9 +6944,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", + "reqwest 0.12.20", "rusqlite", "rust-bigint", "serde", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1ad3547050..11958abd03 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,7 +33,9 @@ url.workspace = true serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } +dialoguer = { version = "0.11", features = ["fuzzy-select"] } flate2 = "1.0.34" +reqwest = { workspace = true } rusqlite = { version = "0.31", features = ["functions"] } [target.'cfg(not(target_family = "wasm"))'.dependencies] 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..6a99a865cb --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -0,0 +1,440 @@ +use alloy::primitives::hex; +use anyhow::{Context, Result}; +use dialoguer::{FuzzySelect, Input, Select}; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use rain_orderbook_app_settings::order_builder::{ + OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, +}; +use std::io::Write; + +pub async fn run_interactive(registry_url: &str) -> Result<()> { + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let (strategy_key, dotrain) = pick_strategy(®istry)?; + let settings = registry_settings(®istry); + let deployment_key = pick_deployment(&dotrain, &settings)?; + + eprintln!(); + eprintln!("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()) + })?; + + if let Ok(tokens) = builder.get_select_tokens() { + if !tokens.is_empty() { + select_tokens(&mut builder, &tokens).await?; + } + } + + fill_fields(&mut builder)?; + fill_deposits(&mut builder).await?; + + let owner: String = Input::new() + .with_prompt("Owner address (0x...)") + .interact_text()?; + + eprintln!(); + eprintln!("Generating calldata..."); + let args = builder + .get_deployment_transaction_args(owner) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to generate deployment calldata: {}", + err.to_readable_msg() + ) + })?; + + eprintln!(); + eprintln!("Strategy: {strategy_key}"); + eprintln!("Chain ID: {}", args.chain_id); + eprintln!("Orderbook: {}", args.orderbook_address); + eprintln!( + "Transactions: {}", + args.approvals.len() + 1 + args.emit_meta_call.as_ref().map_or(0, |_| 1) + ); + eprintln!(); + + for approval in &args.approvals { + eprintln!( + " Approve {} — {} bytes", + approval.symbol, + approval.calldata.len() + ); + } + eprintln!( + " Deploy order — {} bytes", + args.deployment_calldata.len() + ); + if args.emit_meta_call.is_some() { + eprintln!(" Emit metadata"); + } + eprintln!(); + + let output_choice = Select::new() + .with_prompt("Output") + .items(&["Print to stdout (pipe to stox submit)", "Save to file"]) + .default(0) + .interact()?; + + let mut lines = Vec::new(); + + for approval in &args.approvals { + lines.push(format!( + "{}:0x{}", + approval.token, + hex::encode(&approval.calldata) + )); + } + lines.push(format!( + "{}:0x{}", + args.orderbook_address, + hex::encode(&args.deployment_calldata) + )); + if let Some(meta_call) = &args.emit_meta_call { + lines.push(format!( + "{}:0x{}", + meta_call.to, + hex::encode(&meta_call.calldata) + )); + } + + match output_choice { + 0 => { + for line in &lines { + println!("{line}"); + } + } + 1 => { + let path: String = Input::new() + .with_prompt("Output file path") + .default("deploy.calldata".to_string()) + .interact_text()?; + + let mut file = + std::fs::File::create(&path).with_context(|| format!("creating {path}"))?; + for line in &lines { + writeln!(file, "{line}")?; + } + eprintln!("Wrote {} transactions to {path}", lines.len()); + eprintln!(); + eprintln!("Deploy with:"); + eprintln!(" cat {path} | stox submit"); + } + _ => unreachable!(), + } + + Ok(()) +} + +fn pick_strategy(registry: &DotrainRegistry) -> 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 display: Vec = keys + .iter() + .map(|key| { + let info = &details.valid[*key]; + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + format!("{key} — {desc}") + }) + .collect(); + + eprintln!(); + let idx = FuzzySelect::new() + .with_prompt("Strategy") + .items(&display) + .default(0) + .interact()?; + + let key = keys[idx].clone(); + let dotrain = registry + .orders() + .0 + .get(&key) + .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? + .clone(); + + let info = &details.valid[&key]; + eprintln!(" {}", info.name); + eprintln!(" {}", info.description); + + Ok((key, dotrain)) +} + +fn pick_deployment(dotrain: &str, settings: &Option>) -> 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, info) = deployments.into_iter().next().unwrap(); + eprintln!(); + eprintln!("Deployment: {} — {}", info.name, info.description); + return Ok(key); + } + + let keys: Vec<&String> = deployments.keys().collect(); + let display: Vec = keys + .iter() + .map(|key| { + let info = &deployments[*key]; + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + format!("{key} — {desc}") + }) + .collect(); + + eprintln!(); + let idx = Select::new() + .with_prompt("Deployment") + .items(&display) + .default(0) + .interact()?; + + let key = keys[idx].clone(); + let info = &deployments[&key]; + eprintln!(" {}", info.name); + eprintln!(" {}", info.description); + + Ok(key) +} + +async fn select_tokens( + builder: &mut RaindexOrderBuilder, + tokens: &[OrderBuilderSelectTokensCfg], +) -> Result<()> { + eprintln!(); + eprintln!("Token selection"); + + for token_cfg in tokens { + let label = token_cfg + .name + .as_deref() + .unwrap_or(&token_cfg.key); + + if let Some(desc) = &token_cfg.description { + eprintln!(" {desc}"); + } + + // Try to fetch available tokens for search + let available = builder.get_all_tokens(None).await.unwrap_or_default(); + + let address = if available.is_empty() { + Input::new() + .with_prompt(format!("{label} address")) + .interact_text()? + } else { + let display: Vec = available + .iter() + .map(|t| format!("{} ({}) {}", t.symbol, t.name, t.address)) + .collect(); + + let mut items = display.clone(); + items.push("Enter address manually".to_string()); + + let idx = FuzzySelect::new() + .with_prompt(label) + .items(&items) + .default(0) + .interact()?; + + if idx < available.len() { + format!("{}", available[idx].address) + } else { + Input::new() + .with_prompt(format!("{label} address")) + .interact_text()? + } + }; + + 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(builder: &mut RaindexOrderBuilder) -> 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(()); + } + + eprintln!(); + eprintln!("Fields"); + + for field in &missing { + fill_single_field(builder, field)?; + } + + Ok(()) +} + +fn fill_single_field( + builder: &mut RaindexOrderBuilder, + field: &OrderBuilderFieldDefinitionCfg, +) -> Result<()> { + if let Some(desc) = &field.description { + eprintln!(" {desc}"); + } + + let value = match &field.presets { + Some(presets) if !presets.is_empty() => { + let show_custom = field.show_custom_field.unwrap_or(true); + + let mut display: Vec = presets + .iter() + .map(|p| { + let label = p.name.as_deref().unwrap_or(&p.value); + format!("{label} = {}", p.value) + }) + .collect(); + + if show_custom { + display.push("Custom value".to_string()); + } + + let idx = Select::new() + .with_prompt(&field.name) + .items(&display) + .default(0) + .interact()?; + + if idx < presets.len() { + presets[idx].value.clone() + } else { + Input::new() + .with_prompt(&field.name) + .interact_text()? + } + } + _ => Input::new() + .with_prompt(&field.name) + .interact_text()?, + }; + + 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(builder: &mut RaindexOrderBuilder) -> 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(()); + } + + eprintln!(); + eprintln!("Deposits (leave blank to skip)"); + + for deposit_cfg in &deployment.deposits { + let label = &deposit_cfg.token_key; + + let presets = builder + .get_deposit_presets(deposit_cfg.token_key.clone()) + .unwrap_or_default(); + + let amount = if presets.is_empty() { + Input::new() + .with_prompt(format!("Deposit {label}")) + .default(String::new()) + .show_default(false) + .interact_text()? + } else { + let mut display: Vec = presets.iter().map(|p| p.to_string()).collect(); + display.push("Custom amount".to_string()); + display.push("Skip".to_string()); + + let idx = Select::new() + .with_prompt(format!("Deposit {label}")) + .items(&display) + .default(0) + .interact()?; + + if idx < presets.len() { + presets[idx].clone() + } else if idx == presets.len() { + Input::new() + .with_prompt(format!("Deposit {label}")) + .interact_text()? + } else { + continue; + } + }; + + if amount.is_empty() { + continue; + } + + builder + .set_deposit(deposit_cfg.token_key.clone(), amount.clone()) + .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..a36414dd9d 100644 --- a/crates/cli/src/commands/strategy_builder.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,3 +1,5 @@ +mod interactive; + use crate::execute::Execute; use alloy::primitives::hex; use anyhow::Result; @@ -14,14 +16,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 +70,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 +94,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 +114,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 +150,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!( From 69f803a0e5bb2134ed1b642cce8894922d7fe300 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 10:40:25 +0000 Subject: [PATCH 04/32] feat: polish interactive mode with terminal formatting and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bold/dim/underlined styling via console crate - Structured headings and separators between sections - Ask for owner address first (needed for balance display) - Show token symbol/name instead of raw key in deposit prompts - Show token balance when prompting for deposits - Remove stox-specific language from output hints — describe the format (address:calldata lines) so any submitter can consume it - Improve preset display with bold labels and dim values Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + .../commands/strategy_builder/interactive.rs | 247 ++++++++++++------ 3 files changed, 175 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b2b67c0eb..c07a2ef18e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6933,6 +6933,7 @@ dependencies = [ "chrono", "clap", "comfy-table", + "console", "csv", "dialoguer", "flate2", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 11958abd03..95d489ab1b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,6 +33,7 @@ url.workspace = true serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } +console = "0.15" dialoguer = { version = "0.11", features = ["fuzzy-select"] } flate2 = "1.0.34" reqwest = { workspace = true } diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 6a99a865cb..fb5782da7d 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -1,5 +1,6 @@ use alloy::primitives::hex; use anyhow::{Context, Result}; +use console::Style; use dialoguer::{FuzzySelect, Input, Select}; use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; use rain_orderbook_js_api::registry::DotrainRegistry; @@ -8,17 +9,51 @@ use rain_orderbook_app_settings::order_builder::{ }; use std::io::Write; +fn heading(text: &str) { + let style = Style::new().bold().underlined(); + eprintln!(); + eprintln!("{}", style.apply_to(text)); + eprintln!(); +} + +fn label(text: &str) -> String { + Style::new().bold().apply_to(text).to_string() +} + +fn dim(text: &str) -> String { + Style::new().dim().apply_to(text).to_string() +} + +fn separator() { + eprintln!("{}", dim("────────────────────────────────────────")); +} + pub async fn run_interactive(registry_url: &str) -> Result<()> { + heading("Raindex Strategy Builder"); + + eprintln!(" Registry: {}", dim(registry_url)); + eprintln!(" Fetching strategies..."); + let registry = DotrainRegistry::new(registry_url.to_string()) .await .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + // 1. Owner address (ask first so we can show balances later) + heading("Owner"); + let owner: String = Input::new() + .with_prompt("Wallet address that will own this order") + .interact_text()?; + + // 2. Pick strategy let (strategy_key, dotrain) = pick_strategy(®istry)?; let settings = registry_settings(®istry); + + // 3. Pick deployment let deployment_key = pick_deployment(&dotrain, &settings)?; - eprintln!(); - eprintln!("Initializing builder..."); + separator(); + eprintln!(" Initializing builder..."); + let mut builder = RaindexOrderBuilder::new_with_deployment(dotrain, settings.clone(), deployment_key) .await @@ -26,23 +61,24 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { 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(&mut builder, &tokens).await?; } } + // 5. Fields fill_fields(&mut builder)?; - fill_deposits(&mut builder).await?; - let owner: String = Input::new() - .with_prompt("Owner address (0x...)") - .interact_text()?; + // 6. Deposits (with token names and balances) + fill_deposits(&mut builder, &owner).await?; + + // 7. Generate calldata + heading("Generating Calldata"); - eprintln!(); - eprintln!("Generating calldata..."); let args = builder - .get_deployment_transaction_args(owner) + .get_deployment_transaction_args(owner.clone()) .await .map_err(|err| { anyhow::anyhow!( @@ -51,35 +87,48 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { ) })?; + heading("Deployment Summary"); + + eprintln!(" {} {strategy_key}", label("Strategy")); + eprintln!(" {} {owner}", label("Owner ")); + eprintln!(" {} {} ({})", label("Chain "), args.chain_id, args.orderbook_address); eprintln!(); - eprintln!("Strategy: {strategy_key}"); - eprintln!("Chain ID: {}", args.chain_id); - eprintln!("Orderbook: {}", args.orderbook_address); - eprintln!( - "Transactions: {}", - args.approvals.len() + 1 + args.emit_meta_call.as_ref().map_or(0, |_| 1) - ); + + let tx_count = args.approvals.len() + 1 + args.emit_meta_call.as_ref().map_or(0, |_| 1); + eprintln!(" {} {tx_count} transaction{}", label("Transactions"), if tx_count == 1 { "" } else { "s" }); eprintln!(); - for approval in &args.approvals { + for (idx, approval) in args.approvals.iter().enumerate() { eprintln!( - " Approve {} — {} bytes", - approval.symbol, + " {} Approve {} {} {} bytes", + dim(&format!("{}.", idx + 1)), + Style::new().cyan().apply_to(&approval.symbol), + dim("->"), approval.calldata.len() ); } + let deploy_idx = args.approvals.len() + 1; eprintln!( - " Deploy order — {} bytes", + " {} Deploy order {} {} bytes", + dim(&format!("{deploy_idx}.")), + dim("->"), args.deployment_calldata.len() ); if args.emit_meta_call.is_some() { - eprintln!(" Emit metadata"); + eprintln!( + " {} Emit metadata", + dim(&format!("{}.", deploy_idx + 1)), + ); } - eprintln!(); + + separator(); let output_choice = Select::new() .with_prompt("Output") - .items(&["Print to stdout (pipe to stox submit)", "Save to file"]) + .items(&[ + "Print to stdout (address:calldata lines)", + "Save to file", + ]) .default(0) .interact()?; @@ -122,14 +171,22 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { for line in &lines { writeln!(file, "{line}")?; } - eprintln!("Wrote {} transactions to {path}", lines.len()); + + eprintln!(); + eprintln!( + " Wrote {} transaction{} to {}", + lines.len(), + if lines.len() == 1 { "" } else { "s" }, + Style::new().green().apply_to(&path) + ); eprintln!(); - eprintln!("Deploy with:"); - eprintln!(" cat {path} | stox submit"); + eprintln!(" Format: one {} line per transaction", dim("address:0xcalldata")); + eprintln!(" Pipe or read into any calldata submitter to deploy."); } _ => unreachable!(), } + eprintln!(); Ok(()) } @@ -142,6 +199,8 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { anyhow::bail!("no valid strategies found in registry"); } + heading("Strategy"); + let keys: Vec<&String> = details.valid.keys().collect(); let display: Vec = keys .iter() @@ -151,13 +210,12 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .short_description .as_deref() .unwrap_or(&info.description); - format!("{key} — {desc}") + format!("{} {}", Style::new().bold().apply_to(key), dim(desc)) }) .collect(); - eprintln!(); let idx = FuzzySelect::new() - .with_prompt("Strategy") + .with_prompt("Select a strategy") .items(&display) .default(0) .interact()?; @@ -171,8 +229,9 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .clone(); let info = &details.valid[&key]; - eprintln!(" {}", info.name); - eprintln!(" {}", info.description); + eprintln!(); + eprintln!(" {} {}", label("Name"), info.name); + eprintln!(" {}", dim(&info.description)); Ok((key, dotrain)) } @@ -191,10 +250,17 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result>) -> Result Result<()> { - eprintln!(); - eprintln!("Token selection"); + heading("Token Selection"); for token_cfg in tokens { - let label = token_cfg - .name - .as_deref() - .unwrap_or(&token_cfg.key); + let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); if let Some(desc) = &token_cfg.description { - eprintln!(" {desc}"); + eprintln!(" {}", dim(desc)); } - // Try to fetch available tokens for search let available = builder.get_all_tokens(None).await.unwrap_or_default(); let address = if available.is_empty() { Input::new() - .with_prompt(format!("{label} address")) + .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { let display: Vec = available .iter() - .map(|t| format!("{} ({}) {}", t.symbol, t.name, t.address)) + .map(|t| { + format!( + "{} {} {}", + Style::new().bold().apply_to(&t.symbol), + dim(&format!("({})", t.name)), + dim(&format!("{}", t.address)) + ) + }) .collect(); let mut items = display.clone(); - items.push("Enter address manually".to_string()); + items.push(dim("Enter address manually").to_string()); let idx = FuzzySelect::new() - .with_prompt(label) + .with_prompt(prompt_label) .items(&items) .default(0) .interact()?; @@ -269,7 +337,7 @@ async fn select_tokens( format!("{}", available[idx].address) } else { Input::new() - .with_prompt(format!("{label} address")) + .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } }; @@ -298,8 +366,7 @@ fn fill_fields(builder: &mut RaindexOrderBuilder) -> Result<()> { return Ok(()); } - eprintln!(); - eprintln!("Fields"); + heading("Configuration"); for field in &missing { fill_single_field(builder, field)?; @@ -313,7 +380,7 @@ fn fill_single_field( field: &OrderBuilderFieldDefinitionCfg, ) -> Result<()> { if let Some(desc) = &field.description { - eprintln!(" {desc}"); + eprintln!(" {}", dim(desc)); } let value = match &field.presets { @@ -323,13 +390,17 @@ fn fill_single_field( let mut display: Vec = presets .iter() .map(|p| { - let label = p.name.as_deref().unwrap_or(&p.value); - format!("{label} = {}", p.value) + let preset_label = p.name.as_deref().unwrap_or(&p.value); + format!( + "{} {}", + Style::new().bold().apply_to(preset_label), + dim(&format!("= {}", p.value)) + ) }) .collect(); if show_custom { - display.push("Custom value".to_string()); + display.push(dim("Custom value").to_string()); } let idx = Select::new() @@ -341,14 +412,10 @@ fn fill_single_field( if idx < presets.len() { presets[idx].value.clone() } else { - Input::new() - .with_prompt(&field.name) - .interact_text()? + Input::new().with_prompt(&field.name).interact_text()? } } - _ => Input::new() - .with_prompt(&field.name) - .interact_text()?, + _ => Input::new().with_prompt(&field.name).interact_text()?, }; builder @@ -364,7 +431,7 @@ fn fill_single_field( Ok(()) } -async fn fill_deposits(builder: &mut RaindexOrderBuilder) -> Result<()> { +async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result<()> { let deployment = builder .get_current_deployment() .map_err(|err| anyhow::anyhow!("failed to get deployment: {}", err.to_readable_msg()))?; @@ -373,11 +440,40 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder) -> Result<()> { return Ok(()); } - eprintln!(); - eprintln!("Deposits (leave blank to skip)"); + heading("Deposits"); for deposit_cfg in &deployment.deposits { - let label = &deposit_cfg.token_key; + // Resolve token name/symbol for display + let token_display = match builder.get_token_info(deposit_cfg.token_key.clone()).await { + Ok(info) => { + // Try to show balance + let balance_str = + match builder + .get_account_balance(format!("{}", info.address), owner.to_string()) + .await + { + Ok(bal) => format!(" Balance: {}", bal.formatted_balance()), + Err(_) => String::new(), + }; + + eprintln!( + " {} {} {}{}", + label(&info.symbol), + dim(&format!("({})", info.name)), + dim(&format!("{}", info.address)), + if balance_str.is_empty() { + String::new() + } else { + format!("\n {}", dim(&balance_str)) + } + ); + + info.symbol.clone() + } + Err(_) => { + deposit_cfg.token_key.clone() + } + }; let presets = builder .get_deposit_presets(deposit_cfg.token_key.clone()) @@ -385,17 +481,20 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder) -> Result<()> { let amount = if presets.is_empty() { Input::new() - .with_prompt(format!("Deposit {label}")) + .with_prompt(format!("Deposit amount ({token_display})")) .default(String::new()) .show_default(false) .interact_text()? } else { - let mut display: Vec = presets.iter().map(|p| p.to_string()).collect(); - display.push("Custom amount".to_string()); - display.push("Skip".to_string()); + let mut display: Vec = presets + .iter() + .map(|p| format!("{} {token_display}", Style::new().bold().apply_to(p))) + .collect(); + display.push(dim("Custom amount").to_string()); + display.push(dim("Skip").to_string()); let idx = Select::new() - .with_prompt(format!("Deposit {label}")) + .with_prompt(format!("Deposit {token_display}")) .items(&display) .default(0) .interact()?; @@ -404,7 +503,7 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder) -> Result<()> { presets[idx].clone() } else if idx == presets.len() { Input::new() - .with_prompt(format!("Deposit {label}")) + .with_prompt(format!("Amount ({token_display})")) .interact_text()? } else { continue; @@ -416,7 +515,7 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder) -> Result<()> { } builder - .set_deposit(deposit_cfg.token_key.clone(), amount.clone()) + .set_deposit(deposit_cfg.token_key.clone(), amount) .await .map_err(|err| { anyhow::anyhow!( From 70090155ff6f646f0db4aa1d134dab2961db032a Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 10:46:55 +0000 Subject: [PATCH 05/32] fix: cap list height to prevent terminal scroll wipe Add max_length(10) to all Select/FuzzySelect prompts so they scroll within a fixed window instead of reprinting the full list on every keystroke. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/commands/strategy_builder/interactive.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index fb5782da7d..255c09b56b 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -130,6 +130,7 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { "Save to file", ]) .default(0) + .max_length(10) .interact()?; let mut lines = Vec::new(); @@ -218,6 +219,7 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .with_prompt("Select a strategy") .items(&display) .default(0) + .max_length(10) .interact()?; let key = keys[idx].clone(); @@ -281,6 +283,7 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result Result .with_prompt(format!("Deposit {token_display}")) .items(&display) .default(0) + .max_length(10) .interact()?; if idx < presets.len() { From 1d611ee9037985a56961c64a830965002b67d2c3 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 10:56:21 +0000 Subject: [PATCH 06/32] fix: truncate list items to terminal width Long descriptions were wrapping to multiple lines, making each list item 2-3 rows tall and causing the display to scroll erratically. Now truncates all display strings to fit within the terminal width. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 255c09b56b..32fa12f6aa 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -28,6 +28,20 @@ fn separator() { eprintln!("{}", dim("────────────────────────────────────────")); } +fn term_width() -> usize { + console::Term::stderr().size().1 as usize +} + +fn truncate(text: &str, max: usize) -> String { + if text.len() <= max { + text.to_string() + } else if max > 3 { + format!("{}...", &text[..max - 3]) + } else { + text[..max].to_string() + } +} + pub async fn run_interactive(registry_url: &str) -> Result<()> { heading("Raindex Strategy Builder"); @@ -203,6 +217,7 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { heading("Strategy"); let keys: Vec<&String> = details.valid.keys().collect(); + let width = term_width().saturating_sub(6); // account for prompt/cursor prefix let display: Vec = keys .iter() .map(|key| { @@ -211,7 +226,9 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .short_description .as_deref() .unwrap_or(&info.description); - format!("{} {}", Style::new().bold().apply_to(key), dim(desc)) + let key_part = format!("{}", Style::new().bold().apply_to(key)); + let max_desc = width.saturating_sub(key.len() + 3); + format!("{key_part} {}", dim(&truncate(desc, max_desc))) }) .collect(); @@ -267,6 +284,7 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result = deployments.keys().collect(); + let width = term_width().saturating_sub(6); let display: Vec = keys .iter() .map(|key| { @@ -275,7 +293,9 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result = available .iter() .map(|t| { - format!( - "{} {} {}", - Style::new().bold().apply_to(&t.symbol), - dim(&format!("({})", t.name)), - dim(&format!("{}", t.address)) - ) + let prefix = format!("{} ", Style::new().bold().apply_to(&t.symbol)); + let addr = format!("{}", t.address); + let max_name = width.saturating_sub(t.symbol.len() + addr.len() + 5); + let name_part = truncate(&t.name, max_name); + format!("{prefix}{} {}", dim(&name_part), dim(&addr)) }) .collect(); From 6c720b2289c04557c52e7220f855325250b13e61 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 11:04:36 +0000 Subject: [PATCH 07/32] fix: prevent terminal wipe by showing descriptions after selection The core issue was dialoguer redrawing the list on every keystroke, wiping lines printed above it. Fix: keep list items short (just keys or names), then show the full name + description AFTER the user makes their choice. This way dialoguer only redraws its own compact list. Also: "Generating Calldata" is now a status message, not a heading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 207 +++++++----------- 1 file changed, 84 insertions(+), 123 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 32fa12f6aa..828d92af11 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -2,11 +2,11 @@ use alloy::primitives::hex; use anyhow::{Context, Result}; use console::Style; use dialoguer::{FuzzySelect, Input, Select}; -use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; -use rain_orderbook_js_api::registry::DotrainRegistry; 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::Write; fn heading(text: &str) { @@ -28,20 +28,6 @@ fn separator() { eprintln!("{}", dim("────────────────────────────────────────")); } -fn term_width() -> usize { - console::Term::stderr().size().1 as usize -} - -fn truncate(text: &str, max: usize) -> String { - if text.len() <= max { - text.to_string() - } else if max > 3 { - format!("{}...", &text[..max - 3]) - } else { - text[..max].to_string() - } -} - pub async fn run_interactive(registry_url: &str) -> Result<()> { heading("Raindex Strategy Builder"); @@ -89,7 +75,8 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { fill_deposits(&mut builder, &owner).await?; // 7. Generate calldata - heading("Generating Calldata"); + separator(); + eprintln!(" Generating calldata..."); let args = builder .get_deployment_transaction_args(owner.clone()) @@ -105,11 +92,20 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { eprintln!(" {} {strategy_key}", label("Strategy")); eprintln!(" {} {owner}", label("Owner ")); - eprintln!(" {} {} ({})", label("Chain "), args.chain_id, args.orderbook_address); + eprintln!( + " {} {} ({})", + label("Chain "), + args.chain_id, + args.orderbook_address + ); eprintln!(); let tx_count = args.approvals.len() + 1 + args.emit_meta_call.as_ref().map_or(0, |_| 1); - eprintln!(" {} {tx_count} transaction{}", label("Transactions"), if tx_count == 1 { "" } else { "s" }); + eprintln!( + " {} {tx_count} transaction{}", + label("Transactions"), + if tx_count == 1 { "" } else { "s" } + ); eprintln!(); for (idx, approval) in args.approvals.iter().enumerate() { @@ -129,24 +125,11 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { args.deployment_calldata.len() ); if args.emit_meta_call.is_some() { - eprintln!( - " {} Emit metadata", - dim(&format!("{}.", deploy_idx + 1)), - ); + eprintln!(" {} Emit metadata", dim(&format!("{}.", deploy_idx + 1))); } separator(); - let output_choice = Select::new() - .with_prompt("Output") - .items(&[ - "Print to stdout (address:calldata lines)", - "Save to file", - ]) - .default(0) - .max_length(10) - .interact()?; - let mut lines = Vec::new(); for approval in &args.approvals { @@ -169,6 +152,16 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { )); } + // Output choice — only 2 items, short labels, no wipe issue + let output_choice = Select::new() + .with_prompt("Output") + .items(&[ + "Print to stdout (address:calldata lines)", + "Save to file", + ]) + .default(0) + .interact()?; + match output_choice { 0 => { for line in &lines { @@ -195,7 +188,10 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { Style::new().green().apply_to(&path) ); eprintln!(); - eprintln!(" Format: one {} line per transaction", dim("address:0xcalldata")); + eprintln!( + " Format: one {} line per transaction", + dim("address:0xcalldata") + ); eprintln!(" Pipe or read into any calldata submitter to deploy."); } _ => unreachable!(), @@ -214,29 +210,14 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { anyhow::bail!("no valid strategies found in registry"); } - heading("Strategy"); - + // Show the list FIRST with just the keys — no content above to get wiped let keys: Vec<&String> = details.valid.keys().collect(); - let width = term_width().saturating_sub(6); // account for prompt/cursor prefix - let display: Vec = keys - .iter() - .map(|key| { - let info = &details.valid[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - let key_part = format!("{}", Style::new().bold().apply_to(key)); - let max_desc = width.saturating_sub(key.len() + 3); - format!("{key_part} {}", dim(&truncate(desc, max_desc))) - }) - .collect(); + let display: Vec<&str> = keys.iter().map(|k| k.as_str()).collect(); let idx = FuzzySelect::new() - .with_prompt("Select a strategy") + .with_prompt("Strategy") .items(&display) .default(0) - .max_length(10) .interact()?; let key = keys[idx].clone(); @@ -247,10 +228,10 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? .clone(); + // Show description AFTER selection let info = &details.valid[&key]; - eprintln!(); - eprintln!(" {} {}", label("Name"), info.name); - eprintln!(" {}", dim(&info.description)); + heading(&info.name); + eprintln!(" {}", info.description); Ok((key, dotrain)) } @@ -269,48 +250,36 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result = deployments.keys().collect(); - let width = term_width().saturating_sub(6); - let display: Vec = keys + let names: Vec = keys .iter() .map(|key| { let info = &deployments[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - let name_part = format!("{}", Style::new().bold().apply_to(&info.name)); - let max_desc = width.saturating_sub(info.name.len() + 3); - format!("{name_part} {}", dim(&truncate(desc, max_desc))) + format!("{} ({})", info.name, key) }) .collect(); + let display: Vec<&str> = names.iter().map(|n| n.as_str()).collect(); let idx = Select::new() - .with_prompt("Select a deployment") + .with_prompt("Deployment") .items(&display) .default(0) - .max_length(10) .interact()?; let key = keys[idx].clone(); let info = &deployments[&key]; - eprintln!(); - eprintln!(" {} {}", label("Network"), info.name); - eprintln!(" {}", dim(&info.description)); + + // Show description AFTER selection + heading(&format!("Deployment: {}", info.name)); + eprintln!(" {}", info.description); Ok(key) } @@ -319,46 +288,44 @@ async fn select_tokens( builder: &mut RaindexOrderBuilder, tokens: &[OrderBuilderSelectTokensCfg], ) -> Result<()> { - heading("Token Selection"); - for token_cfg in tokens { let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); - if let Some(desc) = &token_cfg.description { - eprintln!(" {}", dim(desc)); - } - let available = builder.get_all_tokens(None).await.unwrap_or_default(); let address = if available.is_empty() { + if let Some(desc) = &token_cfg.description { + eprintln!(" {}", dim(desc)); + } Input::new() .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { - let width = term_width().saturating_sub(6); + // FuzzySelect with just symbol + address — description shown after let display: Vec = available .iter() - .map(|t| { - let prefix = format!("{} ", Style::new().bold().apply_to(&t.symbol)); - let addr = format!("{}", t.address); - let max_name = width.saturating_sub(t.symbol.len() + addr.len() + 5); - let name_part = truncate(&t.name, max_name); - format!("{prefix}{} {}", dim(&name_part), dim(&addr)) - }) + .map(|t| format!("{} ({}) {}", t.symbol, t.name, t.address)) .collect(); - let mut items = display.clone(); - items.push(dim("Enter address manually").to_string()); + let mut items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); + items.push("Enter address manually"); let idx = FuzzySelect::new() - .with_prompt(prompt_label) + .with_prompt(format!("{prompt_label} — type to search")) .items(&items) .default(0) - .max_length(10) + .max_length(12) .interact()?; if idx < available.len() { - format!("{}", available[idx].address) + let token = &available[idx]; + eprintln!( + " {} {} {}", + label(&token.symbol), + dim(&format!("({})", token.name)), + dim(&format!("{}", token.address)) + ); + format!("{}", token.address) } else { Input::new() .with_prompt(format!("{prompt_label} (address)")) @@ -403,6 +370,7 @@ fn fill_single_field( builder: &mut RaindexOrderBuilder, field: &OrderBuilderFieldDefinitionCfg, ) -> Result<()> { + // Show description before the input (Input doesn't redraw/wipe) if let Some(desc) = &field.description { eprintln!(" {}", dim(desc)); } @@ -415,23 +383,20 @@ fn fill_single_field( .iter() .map(|p| { let preset_label = p.name.as_deref().unwrap_or(&p.value); - format!( - "{} {}", - Style::new().bold().apply_to(preset_label), - dim(&format!("= {}", p.value)) - ) + format!("{preset_label} = {}", p.value) }) .collect(); if show_custom { - display.push(dim("Custom value").to_string()); + display.push("Custom value".to_string()); } + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); + let idx = Select::new() .with_prompt(&field.name) - .items(&display) + .items(&items) .default(0) - .max_length(10) .interact()?; if idx < presets.len() { @@ -468,18 +433,15 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result heading("Deposits"); for deposit_cfg in &deployment.deposits { - // Resolve token name/symbol for display let token_display = match builder.get_token_info(deposit_cfg.token_key.clone()).await { Ok(info) => { - // Try to show balance - let balance_str = - match builder - .get_account_balance(format!("{}", info.address), owner.to_string()) - .await - { - Ok(bal) => format!(" Balance: {}", bal.formatted_balance()), - Err(_) => String::new(), - }; + let balance_str = match builder + .get_account_balance(format!("{}", info.address), owner.to_string()) + .await + { + Ok(bal) => format!(" Balance: {}", bal.formatted_balance()), + Err(_) => String::new(), + }; eprintln!( " {} {} {}{}", @@ -495,9 +457,7 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result info.symbol.clone() } - Err(_) => { - deposit_cfg.token_key.clone() - } + Err(_) => deposit_cfg.token_key.clone(), }; let presets = builder @@ -513,16 +473,17 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result } else { let mut display: Vec = presets .iter() - .map(|p| format!("{} {token_display}", Style::new().bold().apply_to(p))) + .map(|p| format!("{p} {token_display}")) .collect(); - display.push(dim("Custom amount").to_string()); - display.push(dim("Skip").to_string()); + display.push("Custom amount".to_string()); + display.push("Skip".to_string()); + + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); let idx = Select::new() .with_prompt(format!("Deposit {token_display}")) - .items(&display) + .items(&items) .default(0) - .max_length(10) .interact()?; if idx < presets.len() { From 76f35c10d13e69dbfb7a7c100f55ca12d72ddf63 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 11:12:10 +0000 Subject: [PATCH 08/32] fix: replace FuzzySelect with static list for token selection FuzzySelect redraws on every keystroke, which with long token lists causes severe terminal corruption. Replace with a printed list of all available tokens, then a text input that matches by symbol, name substring, or raw address. Shows disambiguation when multiple tokens match a partial query. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 95 ++++++++++++++----- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 828d92af11..06a05fac02 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -301,35 +301,82 @@ async fn select_tokens( .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { - // FuzzySelect with just symbol + address — description shown after - let display: Vec = available - .iter() - .map(|t| format!("{} ({}) {}", t.symbol, t.name, t.address)) - .collect(); + heading(&format!("{prompt_label} — available tokens")); + for token in &available { + eprintln!( + " {} {} {}", + Style::new().bold().apply_to(&token.symbol), + dim(&format!("({})", token.name)), + dim(&format!("{}", token.address)) + ); + } + eprintln!(); + eprintln!(" {}", dim("Enter a symbol from the list above, or paste an address.")); - let mut items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - items.push("Enter address manually"); + loop { + let input: String = Input::new() + .with_prompt(prompt_label) + .interact_text()?; - let idx = FuzzySelect::new() - .with_prompt(format!("{prompt_label} — type to search")) - .items(&items) - .default(0) - .max_length(12) - .interact()?; + let input_lower = input.trim().to_lowercase(); + + // Match by symbol (case-insensitive) + if let Some(token) = available + .iter() + .find(|t| t.symbol.to_lowercase() == input_lower) + { + eprintln!( + " {} {} {}", + label(&token.symbol), + dim(&format!("({})", token.name)), + dim(&format!("{}", token.address)) + ); + break format!("{}", token.address); + } + + // Match by address (starts with 0x) + if input.trim().starts_with("0x") && input.trim().len() == 42 { + break input.trim().to_string(); + } + + // Match by name substring + let matches: Vec<_> = available + .iter() + .filter(|t| { + t.name.to_lowercase().contains(&input_lower) + || t.symbol.to_lowercase().contains(&input_lower) + }) + .collect(); + + if matches.len() == 1 { + let token = matches[0]; + eprintln!( + " {} {} {}", + label(&token.symbol), + dim(&format!("({})", token.name)), + dim(&format!("{}", token.address)) + ); + break format!("{}", token.address); + } + + if matches.len() > 1 { + eprintln!(" Multiple matches:"); + for token in &matches { + eprintln!( + " {} {}", + Style::new().bold().apply_to(&token.symbol), + dim(&format!("({})", token.name)) + ); + } + eprintln!(" Be more specific."); + continue; + } - if idx < available.len() { - let token = &available[idx]; eprintln!( - " {} {} {}", - label(&token.symbol), - dim(&format!("({})", token.name)), - dim(&format!("{}", token.address)) + " {} No token found for '{}'. Enter a symbol or 0x address.", + Style::new().red().apply_to("!"), + input.trim() ); - format!("{}", token.address) - } else { - Input::new() - .with_prompt(format!("{prompt_label} (address)")) - .interact_text()? } }; From f6ccb6bd180ead21c1a0dcea310de4ec9cf25cc2 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 11:23:57 +0000 Subject: [PATCH 09/32] fix: clear screen before Select to prevent terminal corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause: dialoguer's Select redraws by clearing lines above it. When items wrap to multiple lines, it miscalculates and wipes content printed before the list. Fix: call clear_screen() before each Select that has multi-line items (strategies, deployments, tokens). Previous output scrolls into terminal scrollback. Full descriptions are shown in the items with wrapping — no truncation. Also: bring back arrow-key Select for token lists (was accidentally replaced with text input), drop FuzzySelect dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 10 -- crates/cli/Cargo.toml | 2 +- .../commands/strategy_builder/interactive.rs | 163 +++++++++--------- 3 files changed, 78 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c07a2ef18e..f993ebfdaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3014,7 +3014,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ "console", - "fuzzy-matcher", "shell-words", "tempfile", "thiserror 1.0.69", @@ -4123,15 +4122,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "gcd" version = "2.3.0" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 95d489ab1b..fc38cc5785 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -34,7 +34,7 @@ serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } console = "0.15" -dialoguer = { version = "0.11", features = ["fuzzy-select"] } +dialoguer = "0.11" flate2 = "1.0.34" reqwest = { workspace = true } rusqlite = { version = "0.31", features = ["functions"] } diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 06a05fac02..13188dd4b3 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -1,7 +1,7 @@ use alloy::primitives::hex; use anyhow::{Context, Result}; -use console::Style; -use dialoguer::{FuzzySelect, Input, Select}; +use console::{Style, Term}; +use dialoguer::{Input, Select}; use rain_orderbook_app_settings::order_builder::{ OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, }; @@ -28,6 +28,12 @@ fn separator() { eprintln!("{}", dim("────────────────────────────────────────")); } +/// Clear the screen so dialoguer's Select has nothing above to wipe. +/// Previous output scrolls into terminal scrollback and remains accessible. +fn clear_for_select() { + let _ = Term::stderr().clear_screen(); +} + pub async fn run_interactive(registry_url: &str) -> Result<()> { heading("Raindex Strategy Builder"); @@ -152,7 +158,6 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { )); } - // Output choice — only 2 items, short labels, no wipe issue let output_choice = Select::new() .with_prompt("Output") .items(&[ @@ -210,13 +215,30 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { anyhow::bail!("no valid strategies found in registry"); } - // Show the list FIRST with just the keys — no content above to get wiped let keys: Vec<&String> = details.valid.keys().collect(); - let display: Vec<&str> = keys.iter().map(|k| k.as_str()).collect(); + let display: Vec = keys + .iter() + .map(|key| { + let info = &details.valid[*key]; + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + format!( + "{} {}", + Style::new().bold().apply_to(key), + dim(desc) + ) + }) + .collect(); + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - let idx = FuzzySelect::new() + // Clear screen so Select doesn't wipe prior output + clear_for_select(); + + let idx = Select::new() .with_prompt("Strategy") - .items(&display) + .items(&items) .default(0) .interact()?; @@ -228,7 +250,6 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? .clone(); - // Show description AFTER selection let info = &details.valid[&key]; heading(&info.name); eprintln!(" {}", info.description); @@ -257,27 +278,34 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result = deployments.keys().collect(); - let names: Vec = keys + let display: Vec = keys .iter() .map(|key| { let info = &deployments[*key]; - format!("{} ({})", info.name, key) + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + format!( + "{} {}", + Style::new().bold().apply_to(&info.name), + dim(desc) + ) }) .collect(); - let display: Vec<&str> = names.iter().map(|n| n.as_str()).collect(); + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); + + clear_for_select(); let idx = Select::new() .with_prompt("Deployment") - .items(&display) + .items(&items) .default(0) .interact()?; let key = keys[idx].clone(); let info = &deployments[&key]; - - // Show description AFTER selection heading(&format!("Deployment: {}", info.name)); eprintln!(" {}", info.description); @@ -301,82 +329,46 @@ async fn select_tokens( .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { - heading(&format!("{prompt_label} — available tokens")); - for token in &available { - eprintln!( - " {} {} {}", - Style::new().bold().apply_to(&token.symbol), - dim(&format!("({})", token.name)), - dim(&format!("{}", token.address)) - ); - } - eprintln!(); - eprintln!(" {}", dim("Enter a symbol from the list above, or paste an address.")); + let display: Vec = available + .iter() + .map(|t| { + format!( + "{} {} {}", + Style::new().bold().apply_to(&t.symbol), + dim(&format!("({})", t.name)), + dim(&format!("{}", t.address)) + ) + }) + .collect(); - loop { - let input: String = Input::new() - .with_prompt(prompt_label) - .interact_text()?; + let mut items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); + items.push("Enter address manually"); - let input_lower = input.trim().to_lowercase(); + clear_for_select(); + if let Some(desc) = &token_cfg.description { + eprintln!(" {}", dim(desc)); + eprintln!(); + } - // Match by symbol (case-insensitive) - if let Some(token) = available - .iter() - .find(|t| t.symbol.to_lowercase() == input_lower) - { - eprintln!( - " {} {} {}", - label(&token.symbol), - dim(&format!("({})", token.name)), - dim(&format!("{}", token.address)) - ); - break format!("{}", token.address); - } - - // Match by address (starts with 0x) - if input.trim().starts_with("0x") && input.trim().len() == 42 { - break input.trim().to_string(); - } - - // Match by name substring - let matches: Vec<_> = available - .iter() - .filter(|t| { - t.name.to_lowercase().contains(&input_lower) - || t.symbol.to_lowercase().contains(&input_lower) - }) - .collect(); - - if matches.len() == 1 { - let token = matches[0]; - eprintln!( - " {} {} {}", - label(&token.symbol), - dim(&format!("({})", token.name)), - dim(&format!("{}", token.address)) - ); - break format!("{}", token.address); - } - - if matches.len() > 1 { - eprintln!(" Multiple matches:"); - for token in &matches { - eprintln!( - " {} {}", - Style::new().bold().apply_to(&token.symbol), - dim(&format!("({})", token.name)) - ); - } - eprintln!(" Be more specific."); - continue; - } + let idx = Select::new() + .with_prompt(prompt_label) + .items(&items) + .default(0) + .interact()?; + if idx < available.len() { + let token = &available[idx]; eprintln!( - " {} No token found for '{}'. Enter a symbol or 0x address.", - Style::new().red().apply_to("!"), - input.trim() + " {} {} {}", + label(&token.symbol), + dim(&format!("({})", token.name)), + dim(&format!("{}", token.address)) ); + format!("{}", token.address) + } else { + Input::new() + .with_prompt(format!("{prompt_label} (address)")) + .interact_text()? } }; @@ -417,7 +409,6 @@ fn fill_single_field( builder: &mut RaindexOrderBuilder, field: &OrderBuilderFieldDefinitionCfg, ) -> Result<()> { - // Show description before the input (Input doesn't redraw/wipe) if let Some(desc) = &field.description { eprintln!(" {}", dim(desc)); } From 12f9bb8e7ef6ab497e3f2de7bd0c46e7e1f49265 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 11:26:16 +0000 Subject: [PATCH 10/32] fix: match the working first-version UX pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the approach that worked: Select items include full descriptions (wrapping is fine), no headings/content printed directly above Select widgets, details shown after selection. Removes clear_screen() and FuzzySelect, drops console dependency. Token selection uses Select with symbol/name/address in items. Deposits show token symbol and balance before the input prompt. Minimal eprintln between steps — each Select starts clean at the bottom of the terminal. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 198 ++++++------------ 1 file changed, 68 insertions(+), 130 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 13188dd4b3..ddc48b5108 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -1,6 +1,6 @@ use alloy::primitives::hex; use anyhow::{Context, Result}; -use console::{Style, Term}; +use console::Style; use dialoguer::{Input, Select}; use rain_orderbook_app_settings::order_builder::{ OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, @@ -9,14 +9,7 @@ use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; use rain_orderbook_js_api::registry::DotrainRegistry; use std::io::Write; -fn heading(text: &str) { - let style = Style::new().bold().underlined(); - eprintln!(); - eprintln!("{}", style.apply_to(text)); - eprintln!(); -} - -fn label(text: &str) -> String { +fn bold(text: &str) -> String { Style::new().bold().apply_to(text).to_string() } @@ -24,40 +17,27 @@ fn dim(text: &str) -> String { Style::new().dim().apply_to(text).to_string() } -fn separator() { - eprintln!("{}", dim("────────────────────────────────────────")); -} - -/// Clear the screen so dialoguer's Select has nothing above to wipe. -/// Previous output scrolls into terminal scrollback and remains accessible. -fn clear_for_select() { - let _ = Term::stderr().clear_screen(); -} - pub async fn run_interactive(registry_url: &str) -> Result<()> { - heading("Raindex Strategy Builder"); - - eprintln!(" Registry: {}", dim(registry_url)); - eprintln!(" Fetching strategies..."); + eprintln!(); + 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()))?; - // 1. Owner address (ask first so we can show balances later) - heading("Owner"); + // 1. Owner address first — needed for balance display later let owner: String = Input::new() - .with_prompt("Wallet address that will own this order") + .with_prompt("Owner address (0x...)") .interact_text()?; - // 2. Pick strategy + // 2. Pick strategy (descriptions in items, wraps naturally) let (strategy_key, dotrain) = pick_strategy(®istry)?; let settings = registry_settings(®istry); // 3. Pick deployment let deployment_key = pick_deployment(&dotrain, &settings)?; - separator(); + eprintln!(); eprintln!(" Initializing builder..."); let mut builder = @@ -77,11 +57,11 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { // 5. Fields fill_fields(&mut builder)?; - // 6. Deposits (with token names and balances) + // 6. Deposits fill_deposits(&mut builder, &owner).await?; // 7. Generate calldata - separator(); + eprintln!(); eprintln!(" Generating calldata..."); let args = builder @@ -94,47 +74,33 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { ) })?; - heading("Deployment Summary"); - - eprintln!(" {} {strategy_key}", label("Strategy")); - eprintln!(" {} {owner}", label("Owner ")); - eprintln!( - " {} {} ({})", - label("Chain "), - args.chain_id, - args.orderbook_address - ); eprintln!(); + eprintln!(" {}: {strategy_key}", bold("Strategy")); + eprintln!(" {}: {owner}", bold("Owner")); + eprintln!(" {}: {}", bold("Chain ID"), args.chain_id); + eprintln!(" {}: {}", bold("Orderbook"), args.orderbook_address); let tx_count = args.approvals.len() + 1 + args.emit_meta_call.as_ref().map_or(0, |_| 1); - eprintln!( - " {} {tx_count} transaction{}", - label("Transactions"), - if tx_count == 1 { "" } else { "s" } - ); + eprintln!(" {}: {tx_count}", bold("Transactions")); eprintln!(); - for (idx, approval) in args.approvals.iter().enumerate() { + for approval in &args.approvals { eprintln!( - " {} Approve {} {} {} bytes", - dim(&format!("{}.", idx + 1)), + " Approve {} {} {} bytes", Style::new().cyan().apply_to(&approval.symbol), - dim("->"), + dim("—"), approval.calldata.len() ); } - let deploy_idx = args.approvals.len() + 1; eprintln!( - " {} Deploy order {} {} bytes", - dim(&format!("{deploy_idx}.")), - dim("->"), + " Deploy order {} {} bytes", + dim("—"), args.deployment_calldata.len() ); if args.emit_meta_call.is_some() { - eprintln!(" {} Emit metadata", dim(&format!("{}.", deploy_idx + 1))); + eprintln!(" Emit metadata"); } - - separator(); + eprintln!(); let mut lines = Vec::new(); @@ -185,19 +151,13 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { writeln!(file, "{line}")?; } - eprintln!(); eprintln!( - " Wrote {} transaction{} to {}", + " Wrote {} transactions to {path}", lines.len(), - if lines.len() == 1 { "" } else { "s" }, - Style::new().green().apply_to(&path) ); eprintln!(); - eprintln!( - " Format: one {} line per transaction", - dim("address:0xcalldata") - ); - eprintln!(" Pipe or read into any calldata submitter to deploy."); + eprintln!(" Deploy with:"); + eprintln!(" cat {path} | stox submit"); } _ => unreachable!(), } @@ -224,18 +184,11 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .short_description .as_deref() .unwrap_or(&info.description); - format!( - "{} {}", - Style::new().bold().apply_to(key), - dim(desc) - ) + format!("{} {} {}", bold(key), dim("—"), desc) }) .collect(); let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - // Clear screen so Select doesn't wipe prior output - clear_for_select(); - let idx = Select::new() .with_prompt("Strategy") .items(&items) @@ -250,8 +203,9 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? .clone(); + // Show full details after selection let info = &details.valid[&key]; - heading(&info.name); + eprintln!(" {}", bold(&info.name)); eprintln!(" {}", info.description); Ok((key, dotrain)) @@ -273,8 +227,12 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result>) -> Result = display.iter().map(|s| s.as_str()).collect(); - clear_for_select(); - let idx = Select::new() .with_prompt("Deployment") .items(&items) @@ -306,7 +258,6 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result Result<()> { + eprintln!(); + eprintln!(" {}", bold("Token selection")); + for token_cfg in tokens { let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); + if let Some(desc) = &token_cfg.description { + eprintln!(" {}", desc); + } + let available = builder.get_all_tokens(None).await.unwrap_or_default(); let address = if available.is_empty() { - if let Some(desc) = &token_cfg.description { - eprintln!(" {}", dim(desc)); - } Input::new() .with_prompt(format!("{prompt_label} (address)")) .interact_text()? @@ -333,9 +288,9 @@ async fn select_tokens( .iter() .map(|t| { format!( - "{} {} {}", - Style::new().bold().apply_to(&t.symbol), - dim(&format!("({})", t.name)), + "{} ({}) {}", + bold(&t.symbol), + t.name, dim(&format!("{}", t.address)) ) }) @@ -344,12 +299,6 @@ async fn select_tokens( let mut items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); items.push("Enter address manually"); - clear_for_select(); - if let Some(desc) = &token_cfg.description { - eprintln!(" {}", dim(desc)); - eprintln!(); - } - let idx = Select::new() .with_prompt(prompt_label) .items(&items) @@ -357,17 +306,10 @@ async fn select_tokens( .interact()?; if idx < available.len() { - let token = &available[idx]; - eprintln!( - " {} {} {}", - label(&token.symbol), - dim(&format!("({})", token.name)), - dim(&format!("{}", token.address)) - ); - format!("{}", token.address) + format!("{}", available[idx].address) } else { Input::new() - .with_prompt(format!("{prompt_label} (address)")) + .with_prompt(format!("{prompt_label} address")) .interact_text()? } }; @@ -396,7 +338,8 @@ fn fill_fields(builder: &mut RaindexOrderBuilder) -> Result<()> { return Ok(()); } - heading("Configuration"); + eprintln!(); + eprintln!(" {}", bold("Fields")); for field in &missing { fill_single_field(builder, field)?; @@ -410,7 +353,7 @@ fn fill_single_field( field: &OrderBuilderFieldDefinitionCfg, ) -> Result<()> { if let Some(desc) = &field.description { - eprintln!(" {}", dim(desc)); + eprintln!(" {}", desc); } let value = match &field.presets { @@ -420,8 +363,8 @@ fn fill_single_field( let mut display: Vec = presets .iter() .map(|p| { - let preset_label = p.name.as_deref().unwrap_or(&p.value); - format!("{preset_label} = {}", p.value) + let label = p.name.as_deref().unwrap_or(&p.value); + format!("{label} = {}", p.value) }) .collect(); @@ -468,31 +411,25 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result return Ok(()); } - heading("Deposits"); + eprintln!(); + eprintln!(" {}", bold("Deposits")); for deposit_cfg in &deployment.deposits { + // Resolve token symbol for display let token_display = match builder.get_token_info(deposit_cfg.token_key.clone()).await { Ok(info) => { - let balance_str = match builder + // Show balance if available + if let Ok(bal) = builder .get_account_balance(format!("{}", info.address), owner.to_string()) .await { - Ok(bal) => format!(" Balance: {}", bal.formatted_balance()), - Err(_) => String::new(), - }; - - eprintln!( - " {} {} {}{}", - label(&info.symbol), - dim(&format!("({})", info.name)), - dim(&format!("{}", info.address)), - if balance_str.is_empty() { - String::new() - } else { - format!("\n {}", dim(&balance_str)) - } - ); - + eprintln!( + " {} ({}) Balance: {}", + bold(&info.symbol), + info.name, + bal.formatted_balance() + ); + } info.symbol.clone() } Err(_) => deposit_cfg.token_key.clone(), @@ -503,11 +440,12 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result .unwrap_or_default(); let amount = if presets.is_empty() { - Input::new() - .with_prompt(format!("Deposit amount ({token_display})")) + let input: String = Input::new() + .with_prompt(format!("Deposit {token_display} (blank to skip)")) .default(String::new()) .show_default(false) - .interact_text()? + .interact_text()?; + input } else { let mut display: Vec = presets .iter() From 42a26195fbbf469048c2fe294c1b88cd3e0e4fe9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 12:05:29 +0000 Subject: [PATCH 11/32] fix: headings + descriptions above, short items in Select Root cause: dialoguer counts items as single lines. When items wrap, cursor-up overshoots and wipes content above. Fix: print full descriptions as a reference table above the Select (with headings), then use short single-line items (just keys/names) in the Select. dialoguer only redraws its own short items, leaving the table intact. Verified with pexpect: cursor-up count matches item count, headings and descriptions survive arrow navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 161 +++++++++++------- 1 file changed, 99 insertions(+), 62 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index ddc48b5108..b54e6d622b 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -9,6 +9,13 @@ use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; use rain_orderbook_js_api::registry::DotrainRegistry; use std::io::Write; +fn heading(text: &str) { + let style = Style::new().bold().underlined(); + eprintln!(); + eprintln!("{}", style.apply_to(text)); + eprintln!(); +} + fn bold(text: &str) -> String { Style::new().bold().apply_to(text).to_string() } @@ -17,20 +24,26 @@ fn dim(text: &str) -> String { Style::new().dim().apply_to(text).to_string() } +fn separator() { + eprintln!("{}", dim("────────────────────────────────────────────────────────────")); +} + pub async fn run_interactive(registry_url: &str) -> Result<()> { - eprintln!(); - eprintln!(" Fetching strategies from {}", dim(registry_url)); + heading("Raindex Strategy Builder"); + eprintln!(" Registry: {}", dim(registry_url)); + eprintln!(" Fetching strategies..."); let registry = DotrainRegistry::new(registry_url.to_string()) .await .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; - // 1. Owner address first — needed for balance display later + // 1. Owner address first + eprintln!(); let owner: String = Input::new() .with_prompt("Owner address (0x...)") .interact_text()?; - // 2. Pick strategy (descriptions in items, wraps naturally) + // 2. Pick strategy let (strategy_key, dotrain) = pick_strategy(®istry)?; let settings = registry_settings(®istry); @@ -74,7 +87,8 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { ) })?; - eprintln!(); + heading("Deployment Summary"); + eprintln!(" {}: {strategy_key}", bold("Strategy")); eprintln!(" {}: {owner}", bold("Owner")); eprintln!(" {}: {}", bold("Chain ID"), args.chain_id); @@ -100,7 +114,8 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { if args.emit_meta_call.is_some() { eprintln!(" Emit metadata"); } - eprintln!(); + + separator(); let mut lines = Vec::new(); @@ -124,12 +139,10 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { )); } + // Short items — won't wrap let output_choice = Select::new() .with_prompt("Output") - .items(&[ - "Print to stdout (address:calldata lines)", - "Save to file", - ]) + .items(&["Print to stdout (address:calldata lines)", "Save to file"]) .default(0) .interact()?; @@ -151,10 +164,7 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { writeln!(file, "{line}")?; } - eprintln!( - " Wrote {} transactions to {path}", - lines.len(), - ); + eprintln!(" Wrote {} transactions to {path}", lines.len()); eprintln!(); eprintln!(" Deploy with:"); eprintln!(" cat {path} | stox submit"); @@ -175,19 +185,27 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { anyhow::bail!("no valid strategies found in registry"); } + // Print full descriptions as a reference table ABOVE the Select. + // Select items are short (just the key) so they won't wrap. + // dialoguer will only redraw the short items, not the table. + heading("Strategies"); + let keys: Vec<&String> = details.valid.keys().collect(); - let display: Vec = keys - .iter() - .map(|key| { - let info = &details.valid[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - format!("{} {} {}", bold(key), dim("—"), desc) - }) - .collect(); - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); + for key in &keys { + let info = &details.valid[*key]; + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + eprintln!(" {} {}", bold(key), dim("—")); + eprintln!(" {desc}"); + eprintln!(); + } + + separator(); + + // Short single-line items — dialoguer counts lines correctly + let items: Vec<&str> = keys.iter().map(|k| k.as_str()).collect(); let idx = Select::new() .with_prompt("Strategy") @@ -203,7 +221,6 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? .clone(); - // Show full details after selection let info = &details.valid[&key]; eprintln!(" {}", bold(&info.name)); eprintln!(" {}", info.description); @@ -227,6 +244,7 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result>) -> Result = deployments.keys().collect(); - let display: Vec = keys + for key in &keys { + let info = &deployments[*key]; + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + eprintln!(" {} ({}) {}", bold(&info.name), key, dim("—")); + eprintln!(" {desc}"); + eprintln!(); + } + + separator(); + + let names: Vec = keys .iter() .map(|key| { let info = &deployments[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - format!("{} {} {}", bold(&info.name), dim("—"), desc) + format!("{} ({})", info.name, key) }) .collect(); - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); + let items: Vec<&str> = names.iter().map(|n| n.as_str()).collect(); let idx = Select::new() .with_prompt("Deployment") @@ -267,14 +296,13 @@ async fn select_tokens( builder: &mut RaindexOrderBuilder, tokens: &[OrderBuilderSelectTokensCfg], ) -> Result<()> { - eprintln!(); - eprintln!(" {}", bold("Token selection")); + heading("Token Selection"); for token_cfg in tokens { let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); if let Some(desc) = &token_cfg.description { - eprintln!(" {}", desc); + eprintln!(" {}", desc); } let available = builder.get_all_tokens(None).await.unwrap_or_default(); @@ -284,29 +312,42 @@ async fn select_tokens( .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { - let display: Vec = available + // Print token table above, short items in Select + for token in &available { + eprintln!( + " {} ({}) {}", + bold(&token.symbol), + token.name, + dim(&format!("{}", token.address)) + ); + } + eprintln!(); + + separator(); + + // Short items — just symbol + let mut items: Vec = available .iter() - .map(|t| { - format!( - "{} ({}) {}", - bold(&t.symbol), - t.name, - dim(&format!("{}", t.address)) - ) - }) + .map(|t| t.symbol.clone()) .collect(); - - let mut items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - items.push("Enter address manually"); + items.push("Enter address manually".to_string()); + let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); let idx = Select::new() .with_prompt(prompt_label) - .items(&items) + .items(&item_refs) .default(0) .interact()?; if idx < available.len() { - format!("{}", available[idx].address) + let token = &available[idx]; + eprintln!( + " Selected: {} ({}) {}", + bold(&token.symbol), + token.name, + dim(&format!("{}", token.address)) + ); + format!("{}", token.address) } else { Input::new() .with_prompt(format!("{prompt_label} address")) @@ -338,8 +379,7 @@ fn fill_fields(builder: &mut RaindexOrderBuilder) -> Result<()> { return Ok(()); } - eprintln!(); - eprintln!(" {}", bold("Fields")); + heading("Configuration"); for field in &missing { fill_single_field(builder, field)?; @@ -353,13 +393,14 @@ fn fill_single_field( field: &OrderBuilderFieldDefinitionCfg, ) -> Result<()> { if let Some(desc) = &field.description { - eprintln!(" {}", desc); + eprintln!(" {}", desc); } let value = match &field.presets { Some(presets) if !presets.is_empty() => { let show_custom = field.show_custom_field.unwrap_or(true); + // Short preset items — won't wrap let mut display: Vec = presets .iter() .map(|p| { @@ -411,20 +452,17 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result return Ok(()); } - eprintln!(); - eprintln!(" {}", bold("Deposits")); + heading("Deposits"); for deposit_cfg in &deployment.deposits { - // Resolve token symbol for display let token_display = match builder.get_token_info(deposit_cfg.token_key.clone()).await { Ok(info) => { - // Show balance if available if let Ok(bal) = builder .get_account_balance(format!("{}", info.address), owner.to_string()) .await { eprintln!( - " {} ({}) Balance: {}", + " {} ({}) Balance: {}", bold(&info.symbol), info.name, bal.formatted_balance() @@ -440,12 +478,11 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result .unwrap_or_default(); let amount = if presets.is_empty() { - let input: String = Input::new() + Input::new() .with_prompt(format!("Deposit {token_display} (blank to skip)")) .default(String::new()) .show_default(false) - .interact_text()?; - input + .interact_text()? } else { let mut display: Vec = presets .iter() From 3fc55b4c2a0c4165a903ef651342e4fdb5623c95 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 19:18:17 +0000 Subject: [PATCH 12/32] feat: multi-line Select items with correct height tracking Use explicit \n in Select items to wrap descriptions ourselves. dialoguer counts \n characters for its cursor-up math, so the height tracking is correct and headings above the Select survive navigation. Each strategy/deployment item shows: bold name dim wrapped description (indented, word-wrapped to terminal width) Headings print above and are preserved during arrow-key navigation. Verified with pexpect: cursor-up count matches actual rendered lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 173 ++++++++++-------- 1 file changed, 94 insertions(+), 79 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index b54e6d622b..9d087167e9 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -1,6 +1,6 @@ use alloy::primitives::hex; use anyhow::{Context, Result}; -use console::Style; +use console::{Style, Term}; use dialoguer::{Input, Select}; use rain_orderbook_app_settings::order_builder::{ OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, @@ -25,7 +25,60 @@ fn dim(text: &str) -> String { } fn separator() { - eprintln!("{}", dim("────────────────────────────────────────────────────────────")); + eprintln!( + "{}", + dim("────────────────────────────────────────────────────────────") + ); +} + +/// Wrap text to fit within `width` columns, returning lines joined with `\n`. +/// Wraps on word boundaries where possible. +fn wrap_text(text: &str, width: usize) -> String { + if width == 0 || text.is_empty() { + return text.to_string(); + } + + let mut lines = Vec::new(); + let mut current_line = String::new(); + let mut current_width = 0; + + for word in text.split_whitespace() { + let word_len = console::measure_text_width(word); + + if current_width == 0 { + current_line = word.to_string(); + current_width = word_len; + } else if current_width + 1 + word_len <= width { + current_line.push(' '); + current_line.push_str(word); + current_width += 1 + word_len; + } else { + lines.push(current_line); + current_line = format!(" {word}"); + current_width = 4 + word_len; + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + lines.join("\n") +} + +/// Format a select item with name and description, wrapped to terminal width. +/// Each item becomes a multi-line string with explicit `\n` so dialoguer +/// tracks the height correctly (no terminal-wrap miscounting). +fn format_select_item(name: &str, description: &str) -> String { + let width = (Term::stderr().size().1 as usize).saturating_sub(4); // leave room for ❯ prefix + let name_line = format!("{}", Style::new().bold().apply_to(name)); + let desc_wrapped = wrap_text(description, width.saturating_sub(2)); + let desc_indented = desc_wrapped + .lines() + .map(|line| format!(" {}", Style::new().dim().apply_to(line))) + .collect::>() + .join("\n"); + format!("{name_line}\n{desc_indented}") } pub async fn run_interactive(registry_url: &str) -> Result<()> { @@ -139,7 +192,6 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { )); } - // Short items — won't wrap let output_choice = Select::new() .with_prompt("Output") .items(&["Print to stdout (address:calldata lines)", "Save to file"]) @@ -185,30 +237,24 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { anyhow::bail!("no valid strategies found in registry"); } - // Print full descriptions as a reference table ABOVE the Select. - // Select items are short (just the key) so they won't wrap. - // dialoguer will only redraw the short items, not the table. - heading("Strategies"); + heading("Strategy"); let keys: Vec<&String> = details.valid.keys().collect(); - for key in &keys { - let info = &details.valid[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - eprintln!(" {} {}", bold(key), dim("—")); - eprintln!(" {desc}"); - eprintln!(); - } - - separator(); - - // Short single-line items — dialoguer counts lines correctly - let items: Vec<&str> = keys.iter().map(|k| k.as_str()).collect(); + let display: Vec = keys + .iter() + .map(|key| { + let info = &details.valid[*key]; + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + format_select_item(key, desc) + }) + .collect(); + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); let idx = Select::new() - .with_prompt("Strategy") + .with_prompt("Select a strategy") .items(&items) .default(0) .interact()?; @@ -221,10 +267,6 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { .ok_or_else(|| anyhow::anyhow!("strategy '{key}' not found"))? .clone(); - let info = &details.valid[&key]; - eprintln!(" {}", bold(&info.name)); - eprintln!(" {}", info.description); - Ok((key, dotrain)) } @@ -244,51 +286,37 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result = deployments.keys().collect(); - for key in &keys { - let info = &deployments[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - eprintln!(" {} ({}) {}", bold(&info.name), key, dim("—")); - eprintln!(" {desc}"); - eprintln!(); - } - - separator(); - - let names: Vec = keys + let display: Vec = keys .iter() .map(|key| { let info = &deployments[*key]; - format!("{} ({})", info.name, key) + let desc = info + .short_description + .as_deref() + .unwrap_or(&info.description); + format_select_item(&format!("{} ({})", info.name, key), desc) }) .collect(); - let items: Vec<&str> = names.iter().map(|n| n.as_str()).collect(); + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); let idx = Select::new() - .with_prompt("Deployment") + .with_prompt("Select a deployment") .items(&items) .default(0) .interact()?; let key = keys[idx].clone(); - let info = &deployments[&key]; - eprintln!(" {}", info.description); - Ok(key) } @@ -312,42 +340,30 @@ async fn select_tokens( .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { - // Print token table above, short items in Select - for token in &available { - eprintln!( - " {} ({}) {}", - bold(&token.symbol), - token.name, - dim(&format!("{}", token.address)) - ); - } - eprintln!(); - - separator(); - - // Short items — just symbol - let mut items: Vec = available + // Each token is a single short line — symbol + address fits + let mut display: Vec = available .iter() - .map(|t| t.symbol.clone()) + .map(|t| { + format!( + "{} ({}) {}", + Style::new().bold().apply_to(&t.symbol), + t.name, + dim(&format!("{}", t.address)) + ) + }) .collect(); - items.push("Enter address manually".to_string()); - let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); + display.push("Enter address manually".to_string()); + let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); let idx = Select::new() .with_prompt(prompt_label) - .items(&item_refs) + .items(&items) .default(0) + .max_length(12) .interact()?; if idx < available.len() { - let token = &available[idx]; - eprintln!( - " Selected: {} ({}) {}", - bold(&token.symbol), - token.name, - dim(&format!("{}", token.address)) - ); - format!("{}", token.address) + format!("{}", available[idx].address) } else { Input::new() .with_prompt(format!("{prompt_label} address")) @@ -400,7 +416,6 @@ fn fill_single_field( Some(presets) if !presets.is_empty() => { let show_custom = field.show_custom_field.unwrap_or(true); - // Short preset items — won't wrap let mut display: Vec = presets .iter() .map(|p| { From ce1ac3f0c88b5d902a45f28f05bac413cbfc153c Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 19:29:08 +0000 Subject: [PATCH 13/32] fix: responsive wrapping based on actual terminal width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of narrow terminal corruption: ANSI escape codes in items made the terminal-rendered width different from what we calculated. Fix: wrap plain text first (using actual char count, no ANSI), then apply bold/dim styling after wrapping. Also account for dialoguer's 2-char prefix on the first line vs our own indent on continuation lines. Tested at 60 cols with pexpect — heading appears exactly once, survives all navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 65 ++++++++++++++----- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 9d087167e9..993b6ec5d9 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -31,19 +31,19 @@ fn separator() { ); } -/// Wrap text to fit within `width` columns, returning lines joined with `\n`. -/// Wraps on word boundaries where possible. -fn wrap_text(text: &str, width: usize) -> String { +/// Wrap plain text to fit within `width` visible columns. +/// Returns a Vec of lines. Wraps on word boundaries. +fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 || text.is_empty() { - return text.to_string(); + return vec![text.to_string()]; } let mut lines = Vec::new(); let mut current_line = String::new(); - let mut current_width = 0; + let mut current_width: usize = 0; for word in text.split_whitespace() { - let word_len = console::measure_text_width(word); + let word_len = word.len(); // plain text, no ANSI if current_width == 0 { current_line = word.to_string(); @@ -54,8 +54,8 @@ fn wrap_text(text: &str, width: usize) -> String { current_width += 1 + word_len; } else { lines.push(current_line); - current_line = format!(" {word}"); - current_width = 4 + word_len; + current_line = word.to_string(); + current_width = word_len; } } @@ -63,22 +63,51 @@ fn wrap_text(text: &str, width: usize) -> String { lines.push(current_line); } - lines.join("\n") + lines } /// Format a select item with name and description, wrapped to terminal width. /// Each item becomes a multi-line string with explicit `\n` so dialoguer /// tracks the height correctly (no terminal-wrap miscounting). +/// +/// dialoguer adds a 2-char prefix (`❯ ` or ` `) to the FIRST line only. +/// Continuation lines (after our `\n`) start at column 0, so we indent them +/// to align with the first line's content. fn format_select_item(name: &str, description: &str) -> String { - let width = (Term::stderr().size().1 as usize).saturating_sub(4); // leave room for ❯ prefix - let name_line = format!("{}", Style::new().bold().apply_to(name)); - let desc_wrapped = wrap_text(description, width.saturating_sub(2)); - let desc_indented = desc_wrapped - .lines() - .map(|line| format!(" {}", Style::new().dim().apply_to(line))) - .collect::>() - .join("\n"); - format!("{name_line}\n{desc_indented}") + let term_width = Term::stderr().size().1 as usize; + // First line: dialoguer adds "❯ " (2 visible chars) + let first_line_width = term_width.saturating_sub(2); + // Continuation lines: we add " " indent ourselves + let cont_indent = " "; + let cont_width = term_width.saturating_sub(cont_indent.len()); + + // Wrap the name if it's too long (plain text, style applied after) + let name_lines = wrap_text(name, first_line_width); + let mut result = String::new(); + + // First name line gets bold + result.push_str(&format!("{}", Style::new().bold().apply_to(&name_lines[0]))); + for extra_name_line in &name_lines[1..] { + result.push('\n'); + result.push_str(cont_indent); + result.push_str(&format!( + "{}", + Style::new().bold().apply_to(extra_name_line) + )); + } + + // Description lines — wrapped, indented, dimmed + let desc_lines = wrap_text(description, cont_width); + for desc_line in &desc_lines { + result.push('\n'); + result.push_str(&format!( + "{}{}", + cont_indent, + Style::new().dim().apply_to(desc_line) + )); + } + + result } pub async fn run_interactive(registry_url: &str) -> Result<()> { From c95de505db568040acca7daba1a4a2e925d9a089 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 20:08:15 +0000 Subject: [PATCH 14/32] fix: clear screen on interactive mode start Lines from before the app (cargo output, long commands) can wrap at the terminal edge. dialoguer's cursor-up math doesn't account for these pre-existing wrapped lines, causing it to overshoot and corrupt them. Clearing the screen at startup gives us a clean slate. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/commands/strategy_builder/interactive.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 993b6ec5d9..63ef4ad9b1 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -111,6 +111,7 @@ fn format_select_item(name: &str, description: &str) -> String { } pub async fn run_interactive(registry_url: &str) -> Result<()> { + let _ = Term::stderr().clear_screen(); heading("Raindex Strategy Builder"); eprintln!(" Registry: {}", dim(registry_url)); eprintln!(" Fetching strategies..."); From 6bde1ffc1dcfb20964f84b0d80716d639505e951 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 20:53:04 +0000 Subject: [PATCH 15/32] fix: 1-column margin prevents edge-of-terminal phantom wrap Lines rendered at exactly the terminal width can cause the cursor to wrap to the next line on some terminals, creating a phantom blank line that throws off dialoguer's cursor-up count. Fix: wrap to term_width - 1 instead of term_width. Also remove the registry URL display line which could wrap uncontrollably. Verified: zero lines at or over terminal width at 60 cols. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/commands/strategy_builder/interactive.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 63ef4ad9b1..54a4bb2b6d 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -74,10 +74,11 @@ fn wrap_text(text: &str, width: usize) -> Vec { /// Continuation lines (after our `\n`) start at column 0, so we indent them /// to align with the first line's content. fn format_select_item(name: &str, description: &str) -> String { - let term_width = Term::stderr().size().1 as usize; + // Leave 1-column margin to prevent edge-of-terminal phantom wrap + let term_width = (Term::stderr().size().1 as usize).saturating_sub(1); // First line: dialoguer adds "❯ " (2 visible chars) let first_line_width = term_width.saturating_sub(2); - // Continuation lines: we add " " indent ourselves + // Continuation lines: we add " " indent ourselves let cont_indent = " "; let cont_width = term_width.saturating_sub(cont_indent.len()); @@ -113,7 +114,6 @@ fn format_select_item(name: &str, description: &str) -> String { pub async fn run_interactive(registry_url: &str) -> Result<()> { let _ = Term::stderr().clear_screen(); heading("Raindex Strategy Builder"); - eprintln!(" Registry: {}", dim(registry_url)); eprintln!(" Fetching strategies..."); let registry = DotrainRegistry::new(registry_url.to_string()) From a21b6b223138118abb9ec27b062b2cee11cb9be2 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 21:06:20 +0000 Subject: [PATCH 16/32] feat: replace dialoguer Select with crossterm alternate screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dialoguer's Select is fundamentally broken for multi-line items — it uses cursor-up math that miscounts when text wraps at the terminal edge. No amount of wrapping workarounds fixes this. Replace with a custom Select built on crossterm that uses the alternate screen buffer. This gives us: - Proper word-wrapping responsive to terminal width - Scrolling with ▲▼ indicators for long lists - Bold name + dim description per item - Highlighted selection in cyan - No interference with main terminal scrollback - Works at any terminal width Keep dialoguer only for Input prompts (which work fine). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 26 ++ crates/cli/Cargo.toml | 1 + .../commands/strategy_builder/interactive.rs | 231 ++++++------------ .../cli/src/commands/strategy_builder/mod.rs | 1 + .../src/commands/strategy_builder/select.rs | 205 ++++++++++++++++ 5 files changed, 309 insertions(+), 155 deletions(-) create mode 100644 crates/cli/src/commands/strategy_builder/select.rs diff --git a/Cargo.lock b/Cargo.lock index f993ebfdaf..ec924e49eb 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", ] @@ -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", ] @@ -6924,6 +6928,7 @@ dependencies = [ "clap", "comfy-table", "console", + "crossterm", "csv", "dialoguer", "flate2", @@ -8761,6 +8766,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 fc38cc5785..f2db1112d5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -34,6 +34,7 @@ serde_json = { workspace = true } futures = { workspace = true } itertools = { workspace = true } console = "0.15" +crossterm = "0.28" dialoguer = "0.11" flate2 = "1.0.34" reqwest = { workspace = true } diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 54a4bb2b6d..36c5b4cb4e 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -1,7 +1,8 @@ +use super::select::{self, SelectItem}; use alloy::primitives::hex; use anyhow::{Context, Result}; use console::{Style, Term}; -use dialoguer::{Input, Select}; +use dialoguer::Input; use rain_orderbook_app_settings::order_builder::{ OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, }; @@ -31,86 +32,6 @@ fn separator() { ); } -/// Wrap plain text to fit within `width` visible columns. -/// Returns a Vec of lines. Wraps on word boundaries. -fn wrap_text(text: &str, width: usize) -> Vec { - if width == 0 || text.is_empty() { - return vec![text.to_string()]; - } - - let mut lines = Vec::new(); - let mut current_line = String::new(); - let mut current_width: usize = 0; - - for word in text.split_whitespace() { - let word_len = word.len(); // plain text, no ANSI - - if current_width == 0 { - current_line = word.to_string(); - current_width = word_len; - } else if current_width + 1 + word_len <= width { - current_line.push(' '); - current_line.push_str(word); - current_width += 1 + word_len; - } else { - lines.push(current_line); - current_line = word.to_string(); - current_width = word_len; - } - } - - if !current_line.is_empty() { - lines.push(current_line); - } - - lines -} - -/// Format a select item with name and description, wrapped to terminal width. -/// Each item becomes a multi-line string with explicit `\n` so dialoguer -/// tracks the height correctly (no terminal-wrap miscounting). -/// -/// dialoguer adds a 2-char prefix (`❯ ` or ` `) to the FIRST line only. -/// Continuation lines (after our `\n`) start at column 0, so we indent them -/// to align with the first line's content. -fn format_select_item(name: &str, description: &str) -> String { - // Leave 1-column margin to prevent edge-of-terminal phantom wrap - let term_width = (Term::stderr().size().1 as usize).saturating_sub(1); - // First line: dialoguer adds "❯ " (2 visible chars) - let first_line_width = term_width.saturating_sub(2); - // Continuation lines: we add " " indent ourselves - let cont_indent = " "; - let cont_width = term_width.saturating_sub(cont_indent.len()); - - // Wrap the name if it's too long (plain text, style applied after) - let name_lines = wrap_text(name, first_line_width); - let mut result = String::new(); - - // First name line gets bold - result.push_str(&format!("{}", Style::new().bold().apply_to(&name_lines[0]))); - for extra_name_line in &name_lines[1..] { - result.push('\n'); - result.push_str(cont_indent); - result.push_str(&format!( - "{}", - Style::new().bold().apply_to(extra_name_line) - )); - } - - // Description lines — wrapped, indented, dimmed - let desc_lines = wrap_text(description, cont_width); - for desc_line in &desc_lines { - result.push('\n'); - result.push_str(&format!( - "{}{}", - cont_indent, - Style::new().dim().apply_to(desc_line) - )); - } - - result -} - pub async fn run_interactive(registry_url: &str) -> Result<()> { let _ = Term::stderr().clear_screen(); heading("Raindex Strategy Builder"); @@ -222,11 +143,17 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { )); } - let output_choice = Select::new() - .with_prompt("Output") - .items(&["Print to stdout (address:calldata lines)", "Save to file"]) - .default(0) - .interact()?; + 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 output_choice = select::select("Output", &output_items)?; match output_choice { 0 => { @@ -267,27 +194,23 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { anyhow::bail!("no valid strategies found in registry"); } - heading("Strategy"); - let keys: Vec<&String> = details.valid.keys().collect(); - let display: Vec = keys + let select_items: Vec = keys .iter() .map(|key| { let info = &details.valid[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - format_select_item(key, desc) + SelectItem { + key: key.to_string(), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } }) .collect(); - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - let idx = Select::new() - .with_prompt("Select a strategy") - .items(&items) - .default(0) - .interact()?; + let idx = select::select("Strategy", &select_items)?; let key = keys[idx].clone(); let dotrain = registry @@ -324,28 +247,23 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result = deployments.keys().collect(); - let display: Vec = keys + let select_items: Vec = keys .iter() .map(|key| { let info = &deployments[*key]; - let desc = info - .short_description - .as_deref() - .unwrap_or(&info.description); - format_select_item(&format!("{} ({})", info.name, key), desc) + SelectItem { + key: format!("{} ({})", info.name, key), + description: info + .short_description + .as_deref() + .unwrap_or(&info.description) + .to_string(), + } }) .collect(); - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - - let idx = Select::new() - .with_prompt("Select a deployment") - .items(&items) - .default(0) - .interact()?; + let idx = select::select("Deployment", &select_items)?; let key = keys[idx].clone(); Ok(key) } @@ -370,27 +288,27 @@ async fn select_tokens( .with_prompt(format!("{prompt_label} (address)")) .interact_text()? } else { - // Each token is a single short line — symbol + address fits - let mut display: Vec = available + let mut select_items: Vec = available .iter() - .map(|t| { - format!( - "{} ({}) {}", - Style::new().bold().apply_to(&t.symbol), - t.name, - dim(&format!("{}", t.address)) - ) + .map(|t| SelectItem { + key: format!("{} ({})", t.symbol, t.name), + description: format!("{}", t.address), }) .collect(); - display.push("Enter address manually".to_string()); - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - - let idx = Select::new() - .with_prompt(prompt_label) - .items(&items) - .default(0) - .max_length(12) - .interact()?; + select_items.push(SelectItem { + key: "Enter address manually".to_string(), + description: String::new(), + }); + + let title = format!( + "{prompt_label}{}", + token_cfg + .description + .as_ref() + .map(|d| format!(" — {d}")) + .unwrap_or_default() + ); + let idx = select::select(&title, &select_items)?; if idx < available.len() { format!("{}", available[idx].address) @@ -446,25 +364,25 @@ fn fill_single_field( Some(presets) if !presets.is_empty() => { let show_custom = field.show_custom_field.unwrap_or(true); - let mut display: Vec = presets + let mut select_items: Vec = presets .iter() .map(|p| { let label = p.name.as_deref().unwrap_or(&p.value); - format!("{label} = {}", p.value) + SelectItem { + key: label.to_string(), + description: format!("= {}", p.value), + } }) .collect(); if show_custom { - display.push("Custom value".to_string()); + select_items.push(SelectItem { + key: "Custom value".to_string(), + description: String::new(), + }); } - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - - let idx = Select::new() - .with_prompt(&field.name) - .items(&items) - .default(0) - .interact()?; + let idx = select::select(&field.name, &select_items)?; if idx < presets.len() { presets[idx].value.clone() @@ -529,20 +447,23 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result .show_default(false) .interact_text()? } else { - let mut display: Vec = presets + let mut select_items: Vec = presets .iter() - .map(|p| format!("{p} {token_display}")) + .map(|p| SelectItem { + key: format!("{p} {token_display}"), + description: String::new(), + }) .collect(); - display.push("Custom amount".to_string()); - display.push("Skip".to_string()); - - let items: Vec<&str> = display.iter().map(|s| s.as_str()).collect(); - - let idx = Select::new() - .with_prompt(format!("Deposit {token_display}")) - .items(&items) - .default(0) - .interact()?; + select_items.push(SelectItem { + key: "Custom amount".to_string(), + description: String::new(), + }); + select_items.push(SelectItem { + key: "Skip".to_string(), + description: String::new(), + }); + + let idx = select::select(&format!("Deposit {token_display}"), &select_items)?; if idx < presets.len() { presets[idx].clone() diff --git a/crates/cli/src/commands/strategy_builder/mod.rs b/crates/cli/src/commands/strategy_builder/mod.rs index a36414dd9d..61a5c3a55e 100644 --- a/crates/cli/src/commands/strategy_builder/mod.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,4 +1,5 @@ mod interactive; +mod select; use crate::execute::Execute; use alloy::primitives::hex; 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..3616718f2a --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -0,0 +1,205 @@ +//! A Select widget that uses crossterm's alternate screen buffer. +//! This avoids dialoguer's cursor-math issues with multi-line items. + +use anyhow::Result; +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEvent}, + execute, + terminal::{self, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::io::{stderr, Write}; + +pub struct SelectItem { + pub key: String, + pub description: String, +} + +/// Show a scrollable select list in the alternate screen. +/// Returns the index of the selected item. +pub fn select(title: &str, items: &[SelectItem]) -> Result { + if items.is_empty() { + anyhow::bail!("no items to select from"); + } + if items.len() == 1 { + return Ok(0); + } + + let mut stderr = stderr(); + terminal::enable_raw_mode()?; + execute!(stderr, EnterAlternateScreen, cursor::Hide)?; + + let result = run_select_loop(&mut stderr, title, items); + + execute!(stderr, LeaveAlternateScreen, cursor::Show)?; + terminal::disable_raw_mode()?; + + result +} + +fn run_select_loop( + w: &mut impl Write, + title: &str, + items: &[SelectItem], +) -> Result { + let mut selected: usize = 0; + let mut scroll_offset: usize = 0; + + loop { + render(w, title, items, selected, scroll_offset)?; + + if let Event::Key(KeyEvent { code, .. }) = event::read()? { + match code { + KeyCode::Up | KeyCode::Char('k') => { + if selected > 0 { + selected -= 1; + if selected < scroll_offset { + scroll_offset = selected; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if selected + 1 < items.len() { + selected += 1; + // Adjust scroll so selected item stays visible + let (_, rows) = terminal::size()?; + let visible_rows = visible_item_rows(rows as usize); + let items_visible = + count_items_fitting(items, scroll_offset, visible_rows); + if selected >= scroll_offset + items_visible { + scroll_offset += 1; + } + } + } + KeyCode::Enter => return Ok(selected), + KeyCode::Esc | KeyCode::Char('q') => { + anyhow::bail!("selection cancelled"); + } + _ => {} + } + } + } +} + +fn visible_item_rows(term_rows: usize) -> usize { + // Reserve: 2 for title + blank, 2 for bottom hint + margin + term_rows.saturating_sub(4) +} + +/// Count how many items fit in `max_rows` starting from `offset`, +/// accounting for each item's rendered height. +fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize) -> usize { + let (cols, _) = terminal::size().unwrap_or((80, 24)); + let cols = cols as usize; + let mut rows_used = 0; + let mut count = 0; + + for item in items.iter().skip(offset) { + let h = item_height(item, cols); + if rows_used + h > max_rows { + break; + } + rows_used += h; + count += 1; + } + + count.max(1) // always show at least one +} + +fn item_height(item: &SelectItem, term_cols: usize) -> usize { + let usable = term_cols.saturating_sub(4); // indent + let name_lines = 1; + let desc_lines = if item.description.is_empty() { + 0 + } else { + // word-wrap description + let mut lines = 1usize; + let mut col = 0usize; + for word in item.description.split_whitespace() { + let wlen = word.len(); + if col == 0 { + col = wlen; + } else if col + 1 + wlen <= usable { + col += 1 + wlen; + } else { + lines += 1; + col = wlen; + } + } + lines + }; + name_lines + desc_lines + 1 // +1 for blank line between items +} + +fn render( + w: &mut impl Write, + title: &str, + items: &[SelectItem], + selected: usize, + scroll_offset: usize, +) -> 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))?; + + // Title + write!(w, "\x1b[1;4m{title}\x1b[0m\r\n\r\n")?; + + let max_rows = visible_item_rows(rows); + let mut rows_used = 0; + + for (idx, item) in items.iter().enumerate().skip(scroll_offset) { + let h = item_height(item, cols); + if rows_used + h > max_rows { + break; + } + + let is_selected = idx == selected; + let prefix = if is_selected { "❯ " } else { " " }; + let name_style = if is_selected { "\x1b[1;36m" } else { "\x1b[1m" }; + + write!(w, " {prefix}{name_style}{}\x1b[0m\r\n", item.key)?; + + if !item.description.is_empty() { + let usable = cols.saturating_sub(6); + let mut col = 0usize; + write!(w, " \x1b[2m")?; + for word in item.description.split_whitespace() { + let wlen = word.len(); + if col == 0 { + write!(w, "{word}")?; + col = wlen; + } else if col + 1 + wlen <= usable { + write!(w, " {word}")?; + col += 1 + wlen; + } else { + write!(w, "\x1b[0m\r\n \x1b[2m{word}")?; + col = wlen; + } + } + write!(w, "\x1b[0m\r\n")?; + } + + write!(w, "\r\n")?; // blank line between items + rows_used += h; + } + + // Scroll indicators + if scroll_offset > 0 { + execute!(w, cursor::MoveTo(cols as u16 - 3, 2))?; + write!(w, " ▲")?; + } + if scroll_offset + count_items_fitting(items, scroll_offset, max_rows) < items.len() { + execute!(w, cursor::MoveTo(cols as u16 - 3, rows as u16 - 2))?; + write!(w, " ▼")?; + } + + // Bottom hint + execute!(w, cursor::MoveTo(0, rows as u16 - 1))?; + write!(w, " \x1b[2m↑↓ navigate ⏎ select esc quit\x1b[0m")?; + + w.flush()?; + Ok(()) +} From 5b5cfa9412a3795fbd9021c2983b1509b6d0ca86 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 21:17:19 +0000 Subject: [PATCH 17/32] feat: show selection progress between steps After each alternate-screen selection, clear the main screen and reprint a summary of all choices made so far (owner, strategy, deployment). This gives the user context as they progress through the wizard. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/commands/strategy_builder/interactive.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 36c5b4cb4e..0010b22b7f 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -51,9 +51,21 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { let (strategy_key, dotrain) = pick_strategy(®istry)?; let settings = registry_settings(®istry); + // Print progress so far + let _ = Term::stderr().clear_screen(); + heading("Raindex Strategy Builder"); + eprintln!(" {}: {owner}", bold("Owner")); + eprintln!(" {}: {strategy_key}", bold("Strategy")); + // 3. Pick deployment let deployment_key = pick_deployment(&dotrain, &settings)?; + // Reprint progress + let _ = Term::stderr().clear_screen(); + heading("Raindex Strategy Builder"); + eprintln!(" {}: {owner}", bold("Owner")); + eprintln!(" {}: {strategy_key}", bold("Strategy")); + eprintln!(" {}: {deployment_key}", bold("Deployment")); eprintln!(); eprintln!(" Initializing builder..."); From 8b6c5b6510d707bb92fb8c659a38df094036677f Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 13 Apr 2026 21:26:08 +0000 Subject: [PATCH 18/32] feat: single alternate screen for entire wizard with progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stay in one alternate screen for the whole interactive session. No more flashing between screens. Each step shows all prior selections as context at the top: Owner: 0xABCD Strategy: fixed-limit Deployment: base Token to Buy — Select the token you want to purchase ❯ wtSPYM (Wrapped SPDR Portfolio S&P 500 ETF ST0x) 0x31C2C14134e6E3... The select, input prompts, and progress all render in the same alternate screen. On completion, the final summary prints to the normal terminal with calldata on stdout. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 368 +++++++++++------- .../src/commands/strategy_builder/select.rs | 83 ++-- 2 files changed, 269 insertions(+), 182 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 0010b22b7f..4071e6a81f 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -1,21 +1,15 @@ -use super::select::{self, SelectItem}; +use super::select::{self, SelectContext, SelectItem}; use alloy::primitives::hex; use anyhow::{Context, Result}; -use console::{Style, Term}; +use console::Style; +use crossterm::{cursor, execute, terminal}; use dialoguer::Input; 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::Write; - -fn heading(text: &str) { - let style = Style::new().bold().underlined(); - eprintln!(); - eprintln!("{}", style.apply_to(text)); - eprintln!(); -} +use std::io::{stderr, Write}; fn bold(text: &str) -> String { Style::new().bold().apply_to(text).to_string() @@ -25,49 +19,73 @@ fn dim(text: &str) -> String { Style::new().dim().apply_to(text).to_string() } -fn separator() { - eprintln!( - "{}", - dim("────────────────────────────────────────────────────────────") - ); -} - +/// Enter alternate screen, run the wizard, leave alternate screen. pub async fn run_interactive(registry_url: &str) -> Result<()> { - let _ = Term::stderr().clear_screen(); - heading("Raindex Strategy Builder"); - eprintln!(" Fetching strategies..."); + 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()))?; - // 1. Owner address first - eprintln!(); + // Collect progress lines to show as context on each screen + let mut progress: Vec = Vec::new(); + + // Enter alternate screen for the whole wizard + let mut w = stderr(); + terminal::enable_raw_mode()?; + execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; + + let result = run_wizard(&mut w, ®istry, &mut progress).await; + + // Leave alternate screen + execute!(w, terminal::LeaveAlternateScreen, cursor::Show)?; + terminal::disable_raw_mode()?; + + // After leaving alt screen, print the result to the real terminal + match result { + Ok(output) => { + // Print summary to stderr + eprintln!(); + for line in &progress { + eprintln!(" {line}"); + } + eprintln!(); + + // Print calldata to stdout + 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 — need to briefly leave raw mode for Input + leave_raw_for_input(w)?; let owner: String = Input::new() .with_prompt("Owner address (0x...)") .interact_text()?; + enter_raw_for_select(w)?; + progress.push(format!("{}: {owner}", bold("Owner"))); - // 2. Pick strategy - let (strategy_key, dotrain) = pick_strategy(®istry)?; - let settings = registry_settings(®istry); + // 2. Strategy + let (strategy_key, dotrain) = pick_strategy(w, registry, progress)?; + progress.push(format!("{}: {strategy_key}", bold("Strategy"))); - // Print progress so far - let _ = Term::stderr().clear_screen(); - heading("Raindex Strategy Builder"); - eprintln!(" {}: {owner}", bold("Owner")); - eprintln!(" {}: {strategy_key}", bold("Strategy")); + let settings = registry_settings(registry); - // 3. Pick deployment - let deployment_key = pick_deployment(&dotrain, &settings)?; + // 3. Deployment + let deployment_key = pick_deployment(w, &dotrain, &settings, progress)?; + progress.push(format!("{}: {deployment_key}", bold("Deployment"))); - // Reprint progress - let _ = Term::stderr().clear_screen(); - heading("Raindex Strategy Builder"); - eprintln!(" {}: {owner}", bold("Owner")); - eprintln!(" {}: {strategy_key}", bold("Strategy")); - eprintln!(" {}: {deployment_key}", bold("Deployment")); - eprintln!(); - eprintln!(" Initializing builder..."); + // Show "initializing" in alt screen + render_progress(w, progress, Some("Initializing builder..."))?; let mut builder = RaindexOrderBuilder::new_with_deployment(dotrain, settings.clone(), deployment_key) @@ -79,19 +97,18 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { // 4. Token selection if let Ok(tokens) = builder.get_select_tokens() { if !tokens.is_empty() { - select_tokens(&mut builder, &tokens).await?; + select_tokens(w, &mut builder, &tokens, progress).await?; } } // 5. Fields - fill_fields(&mut builder)?; + fill_fields(w, &mut builder, progress)?; // 6. Deposits - fill_deposits(&mut builder, &owner).await?; + fill_deposits(w, &mut builder, &owner, progress).await?; // 7. Generate calldata - eprintln!(); - eprintln!(" Generating calldata..."); + render_progress(w, progress, Some("Generating calldata..."))?; let args = builder .get_deployment_transaction_args(owner.clone()) @@ -103,58 +120,41 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { ) })?; - heading("Deployment Summary"); - - eprintln!(" {}: {strategy_key}", bold("Strategy")); - eprintln!(" {}: {owner}", bold("Owner")); - eprintln!(" {}: {}", bold("Chain ID"), args.chain_id); - eprintln!(" {}: {}", bold("Orderbook"), args.orderbook_address); - - let tx_count = args.approvals.len() + 1 + args.emit_meta_call.as_ref().map_or(0, |_| 1); - eprintln!(" {}: {tx_count}", bold("Transactions")); - eprintln!(); + 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 { - eprintln!( - " Approve {} {} {} bytes", - Style::new().cyan().apply_to(&approval.symbol), - dim("—"), - approval.calldata.len() - ); - } - eprintln!( - " Deploy order {} {} bytes", - dim("—"), - args.deployment_calldata.len() - ); - if args.emit_meta_call.is_some() { - eprintln!(" Emit metadata"); - } - - separator(); - - let mut lines = Vec::new(); - - for approval in &args.approvals { - lines.push(format!( + 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() + )); } - lines.push(format!( + 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 { - lines.push(format!( + 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(), @@ -165,39 +165,69 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { description: String::new(), }, ]; - let output_choice = select::select("Output", &output_items)?; + let ctx = SelectContext { + header_lines: progress, + }; + let output_choice = select::select(w, "Output", &output_items, &ctx)?; match output_choice { - 0 => { - for line in &lines { - println!("{line}"); - } - } 1 => { + // Save to file — need Input prompt + leave_raw_for_input(w)?; let path: String = Input::new() .with_prompt("Output file path") .default("deploy.calldata".to_string()) .interact_text()?; + enter_raw_for_select(w)?; let mut file = std::fs::File::create(&path).with_context(|| format!("creating {path}"))?; - for line in &lines { + for line in &calldata_lines { writeln!(file, "{line}")?; } + progress.push(format!(" Wrote to {path}")); - eprintln!(" Wrote {} transactions to {path}", lines.len()); - eprintln!(); - eprintln!(" Deploy with:"); - eprintln!(" cat {path} | stox submit"); + // Return empty — file was written instead + Ok(Vec::new()) } - _ => unreachable!(), + _ => Ok(calldata_lines), } +} - eprintln!(); +fn render_progress(w: &mut impl Write, progress: &[String], status: Option<&str>) -> Result<()> { + execute!( + w, + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All) + )?; + write!(w, " \x1b[1;4mRaindex Strategy Builder\x1b[0m\r\n\r\n")?; + 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(registry: &DotrainRegistry) -> Result<(String, String)> { +fn leave_raw_for_input(w: &mut impl Write) -> Result<()> { + execute!(w, terminal::LeaveAlternateScreen, cursor::Show)?; + terminal::disable_raw_mode()?; + Ok(()) +} + +fn enter_raw_for_select(w: &mut impl Write) -> Result<()> { + terminal::enable_raw_mode()?; + execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; + 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()))?; @@ -222,7 +252,10 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { }) .collect(); - let idx = select::select("Strategy", &select_items)?; + let ctx = SelectContext { + header_lines: progress, + }; + let idx = select::select(w, "Strategy", &select_items, &ctx)?; let key = keys[idx].clone(); let dotrain = registry @@ -235,7 +268,12 @@ fn pick_strategy(registry: &DotrainRegistry) -> Result<(String, String)> { Ok((key, dotrain)) } -fn pick_deployment(dotrain: &str, settings: &Option>) -> Result { +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| { @@ -250,12 +288,7 @@ fn pick_deployment(dotrain: &str, settings: &Option>) -> Result>) -> Result, ) -> Result<()> { - heading("Token Selection"); - for token_cfg in tokens { let prompt_label = token_cfg.name.as_deref().unwrap_or(&token_cfg.key); - if let Some(desc) = &token_cfg.description { - eprintln!(" {}", desc); - } - let available = builder.get_all_tokens(None).await.unwrap_or_default(); let address = if available.is_empty() { - Input::new() + leave_raw_for_input(w)?; + if let Some(desc) = &token_cfg.description { + eprintln!(" {}", desc); + } + let addr: String = Input::new() .with_prompt(format!("{prompt_label} (address)")) - .interact_text()? + .interact_text()?; + enter_raw_for_select(w)?; + addr } else { let mut select_items: Vec = available .iter() @@ -320,14 +358,28 @@ async fn select_tokens( .map(|d| format!(" — {d}")) .unwrap_or_default() ); - let idx = select::select(&title, &select_items)?; + let ctx = SelectContext { + header_lines: progress, + }; + let idx = select::select(w, &title, &select_items, &ctx)?; if idx < available.len() { - format!("{}", available[idx].address) + let token = &available[idx]; + progress.push(format!( + "{}: {} ({})", + bold(prompt_label), + token.symbol, + token.address + )); + format!("{}", token.address) } else { - Input::new() + leave_raw_for_input(w)?; + let addr: String = Input::new() .with_prompt(format!("{prompt_label} address")) - .interact_text()? + .interact_text()?; + enter_raw_for_select(w)?; + progress.push(format!("{}: {addr}", bold(prompt_label))); + addr } }; @@ -346,7 +398,11 @@ async fn select_tokens( Ok(()) } -fn fill_fields(builder: &mut RaindexOrderBuilder) -> Result<()> { +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()))?; @@ -355,23 +411,19 @@ fn fill_fields(builder: &mut RaindexOrderBuilder) -> Result<()> { return Ok(()); } - heading("Configuration"); - for field in &missing { - fill_single_field(builder, field)?; + 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<()> { - if let Some(desc) = &field.description { - eprintln!(" {}", desc); - } - let value = match &field.presets { Some(presets) if !presets.is_empty() => { let show_custom = field.show_custom_field.unwrap_or(true); @@ -394,17 +446,37 @@ fn fill_single_field( }); } - let idx = select::select(&field.name, &select_items)?; + let title = match &field.description { + Some(desc) => format!("{} — {desc}", field.name), + None => field.name.clone(), + }; + let ctx = SelectContext { + header_lines: progress, + }; + let idx = select::select(w, &title, &select_items, &ctx)?; if idx < presets.len() { presets[idx].value.clone() } else { - Input::new().with_prompt(&field.name).interact_text()? + leave_raw_for_input(w)?; + let v: String = Input::new().with_prompt(&field.name).interact_text()?; + enter_raw_for_select(w)?; + v } } - _ => Input::new().with_prompt(&field.name).interact_text()?, + _ => { + leave_raw_for_input(w)?; + if let Some(desc) = &field.description { + eprintln!(" {}", desc); + } + let v: String = Input::new().with_prompt(&field.name).interact_text()?; + enter_raw_for_select(w)?; + v + } }; + progress.push(format!("{}: {value}", bold(&field.name))); + builder .set_field_value(field.binding.clone(), value) .map_err(|err| { @@ -418,7 +490,12 @@ fn fill_single_field( Ok(()) } -async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result<()> { +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()))?; @@ -427,8 +504,6 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result return Ok(()); } - heading("Deposits"); - for deposit_cfg in &deployment.deposits { let token_display = match builder.get_token_info(deposit_cfg.token_key.clone()).await { Ok(info) => { @@ -436,12 +511,16 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result .get_account_balance(format!("{}", info.address), owner.to_string()) .await { - eprintln!( - " {} ({}) Balance: {}", - bold(&info.symbol), - info.name, - bal.formatted_balance() - ); + render_progress( + w, + progress, + Some(&format!( + "{} ({}) — Balance: {}", + info.symbol, + info.name, + bal.formatted_balance() + )), + )?; } info.symbol.clone() } @@ -453,11 +532,14 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result .unwrap_or_default(); let amount = if presets.is_empty() { - Input::new() + leave_raw_for_input(w)?; + let a: String = Input::new() .with_prompt(format!("Deposit {token_display} (blank to skip)")) .default(String::new()) .show_default(false) - .interact_text()? + .interact_text()?; + enter_raw_for_select(w)?; + a } else { let mut select_items: Vec = presets .iter() @@ -475,14 +557,20 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result description: String::new(), }); - let idx = select::select(&format!("Deposit {token_display}"), &select_items)?; + let ctx = SelectContext { + header_lines: progress, + }; + let idx = select::select(w, &format!("Deposit {token_display}"), &select_items, &ctx)?; if idx < presets.len() { presets[idx].clone() } else if idx == presets.len() { - Input::new() + leave_raw_for_input(w)?; + let a: String = Input::new() .with_prompt(format!("Amount ({token_display})")) - .interact_text()? + .interact_text()?; + enter_raw_for_select(w)?; + a } else { continue; } @@ -492,6 +580,8 @@ async fn fill_deposits(builder: &mut RaindexOrderBuilder, owner: &str) -> Result continue; } + progress.push(format!("{}: {amount} {token_display}", bold("Deposit"))); + builder .set_deposit(deposit_cfg.token_key.clone(), amount) .await diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs index 3616718f2a..11e5b9f205 100644 --- a/crates/cli/src/commands/strategy_builder/select.rs +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -1,23 +1,34 @@ -//! A Select widget that uses crossterm's alternate screen buffer. -//! This avoids dialoguer's cursor-math issues with multi-line items. +//! A Select widget rendered directly to a writer. +//! The caller manages the alternate screen lifecycle. use anyhow::Result; use crossterm::{ cursor, event::{self, Event, KeyCode, KeyEvent}, execute, - terminal::{self, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{self, ClearType}, }; -use std::io::{stderr, Write}; +use std::io::Write; pub struct SelectItem { pub key: String, pub description: String, } -/// Show a scrollable select list in the alternate screen. -/// Returns the index of the selected item. -pub fn select(title: &str, items: &[SelectItem]) -> Result { +/// Header lines to display above the select list. +/// These show prior selections / progress. +pub struct SelectContext<'a> { + pub header_lines: &'a [String], +} + +/// Run a select list on the given writer. +/// The caller is responsible for alternate screen and raw mode. +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"); } @@ -25,28 +36,11 @@ pub fn select(title: &str, items: &[SelectItem]) -> Result { return Ok(0); } - let mut stderr = stderr(); - terminal::enable_raw_mode()?; - execute!(stderr, EnterAlternateScreen, cursor::Hide)?; - - let result = run_select_loop(&mut stderr, title, items); - - execute!(stderr, LeaveAlternateScreen, cursor::Show)?; - terminal::disable_raw_mode()?; - - result -} - -fn run_select_loop( - w: &mut impl Write, - title: &str, - items: &[SelectItem], -) -> Result { let mut selected: usize = 0; let mut scroll_offset: usize = 0; loop { - render(w, title, items, selected, scroll_offset)?; + render(w, title, items, selected, scroll_offset, ctx)?; if let Event::Key(KeyEvent { code, .. }) = event::read()? { match code { @@ -61,9 +55,9 @@ fn run_select_loop( KeyCode::Down | KeyCode::Char('j') => { if selected + 1 < items.len() { selected += 1; - // Adjust scroll so selected item stays visible let (_, rows) = terminal::size()?; - let visible_rows = visible_item_rows(rows as usize); + let header_rows = ctx.header_lines.len() + 4; // header + title + gaps + let visible_rows = (rows as usize).saturating_sub(header_rows + 2); let items_visible = count_items_fitting(items, scroll_offset, visible_rows); if selected >= scroll_offset + items_visible { @@ -81,13 +75,6 @@ fn run_select_loop( } } -fn visible_item_rows(term_rows: usize) -> usize { - // Reserve: 2 for title + blank, 2 for bottom hint + margin - term_rows.saturating_sub(4) -} - -/// Count how many items fit in `max_rows` starting from `offset`, -/// accounting for each item's rendered height. fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize) -> usize { let (cols, _) = terminal::size().unwrap_or((80, 24)); let cols = cols as usize; @@ -103,16 +90,15 @@ fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize) -> count += 1; } - count.max(1) // always show at least one + count.max(1) } fn item_height(item: &SelectItem, term_cols: usize) -> usize { - let usable = term_cols.saturating_sub(4); // indent + let usable = term_cols.saturating_sub(7); // " ❯ " prefix + margin let name_lines = 1; let desc_lines = if item.description.is_empty() { 0 } else { - // word-wrap description let mut lines = 1usize; let mut col = 0usize; for word in item.description.split_whitespace() { @@ -128,7 +114,7 @@ fn item_height(item: &SelectItem, term_cols: usize) -> usize { } lines }; - name_lines + desc_lines + 1 // +1 for blank line between items + name_lines + desc_lines + 1 // +1 blank line between items } fn render( @@ -137,6 +123,7 @@ fn render( items: &[SelectItem], selected: usize, scroll_offset: usize, + ctx: &SelectContext, ) -> Result<()> { let (cols, rows) = terminal::size()?; let cols = cols as usize; @@ -144,10 +131,19 @@ fn render( execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + // Header: show prior selections + if !ctx.header_lines.is_empty() { + for line in ctx.header_lines { + write!(w, " {line}\r\n")?; + } + write!(w, "\r\n")?; + } + // Title - write!(w, "\x1b[1;4m{title}\x1b[0m\r\n\r\n")?; + write!(w, " \x1b[1;4m{title}\x1b[0m\r\n\r\n")?; - let max_rows = visible_item_rows(rows); + let header_rows = ctx.header_lines.len() + 4; + let max_rows = rows.saturating_sub(header_rows + 2); let mut rows_used = 0; for (idx, item) in items.iter().enumerate().skip(scroll_offset) { @@ -163,7 +159,7 @@ fn render( write!(w, " {prefix}{name_style}{}\x1b[0m\r\n", item.key)?; if !item.description.is_empty() { - let usable = cols.saturating_sub(6); + let usable = cols.saturating_sub(7); let mut col = 0usize; write!(w, " \x1b[2m")?; for word in item.description.split_whitespace() { @@ -182,13 +178,14 @@ fn render( write!(w, "\x1b[0m\r\n")?; } - write!(w, "\r\n")?; // blank line between items + write!(w, "\r\n")?; rows_used += h; } // Scroll indicators + let first_item_row = header_rows; if scroll_offset > 0 { - execute!(w, cursor::MoveTo(cols as u16 - 3, 2))?; + execute!(w, cursor::MoveTo(cols as u16 - 3, first_item_row as u16))?; write!(w, " ▲")?; } if scroll_offset + count_items_fitting(items, scroll_offset, max_rows) < items.len() { From f6e3b0a31608a33b2eecdf1dee4f569a2457f3bd Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 09:43:42 +0000 Subject: [PATCH 19/32] fix: inputs in alt screen + description below title (not underlined) - All text inputs (owner, custom fields, deposits) now render in the alternate screen using our own crossterm-based input widget. No more dropping back to the main terminal mid-wizard. - Field/token descriptions are passed separately to Select and rendered as dim lighter text directly under the bold-underlined title, not embedded in the title (which was making them bold+underlined too). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 226 ++++++++---------- .../src/commands/strategy_builder/select.rs | 156 +++++++++++- 2 files changed, 246 insertions(+), 136 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 4071e6a81f..9eeb08a68a 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -3,7 +3,6 @@ use alloy::primitives::hex; use anyhow::{Context, Result}; use console::Style; use crossterm::{cursor, execute, terminal}; -use dialoguer::Input; use rain_orderbook_app_settings::order_builder::{ OrderBuilderFieldDefinitionCfg, OrderBuilderSelectTokensCfg, }; @@ -19,7 +18,7 @@ fn dim(text: &str) -> String { Style::new().dim().apply_to(text).to_string() } -/// Enter alternate screen, run the wizard, leave alternate screen. +/// 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)); @@ -27,31 +26,24 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { .await .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; - // Collect progress lines to show as context on each screen - let mut progress: Vec = Vec::new(); - - // Enter alternate screen for the whole wizard let mut w = stderr(); terminal::enable_raw_mode()?; execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; + let mut progress: Vec = Vec::new(); let result = run_wizard(&mut w, ®istry, &mut progress).await; - // Leave alternate screen execute!(w, terminal::LeaveAlternateScreen, cursor::Show)?; terminal::disable_raw_mode()?; - // After leaving alt screen, print the result to the real terminal match result { Ok(output) => { - // Print summary to stderr eprintln!(); for line in &progress { eprintln!(" {line}"); } eprintln!(); - // Print calldata to stdout for line in &output { println!("{line}"); } @@ -66,12 +58,15 @@ async fn run_wizard( registry: &DotrainRegistry, progress: &mut Vec, ) -> Result> { - // 1. Owner — need to briefly leave raw mode for Input - leave_raw_for_input(w)?; - let owner: String = Input::new() - .with_prompt("Owner address (0x...)") - .interact_text()?; - enter_raw_for_select(w)?; + // 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 @@ -84,7 +79,6 @@ async fn run_wizard( let deployment_key = pick_deployment(w, &dotrain, &settings, progress)?; progress.push(format!("{}: {deployment_key}", bold("Deployment"))); - // Show "initializing" in alt screen render_progress(w, progress, Some("Initializing builder..."))?; let mut builder = @@ -165,20 +159,19 @@ async fn run_wizard( description: String::new(), }, ]; - let ctx = SelectContext { - header_lines: progress, - }; + let ctx = SelectContext::new(progress); let output_choice = select::select(w, "Output", &output_items, &ctx)?; match output_choice { 1 => { - // Save to file — need Input prompt - leave_raw_for_input(w)?; - let path: String = Input::new() - .with_prompt("Output file path") - .default("deploy.calldata".to_string()) - .interact_text()?; - enter_raw_for_select(w)?; + 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}"))?; @@ -186,8 +179,6 @@ async fn run_wizard( writeln!(file, "{line}")?; } progress.push(format!(" Wrote to {path}")); - - // Return empty — file was written instead Ok(Vec::new()) } _ => Ok(calldata_lines), @@ -200,7 +191,6 @@ fn render_progress(w: &mut impl Write, progress: &[String], status: Option<&str> cursor::MoveTo(0, 0), terminal::Clear(terminal::ClearType::All) )?; - write!(w, " \x1b[1;4mRaindex Strategy Builder\x1b[0m\r\n\r\n")?; for line in progress { write!(w, " {line}\r\n")?; } @@ -211,18 +201,6 @@ fn render_progress(w: &mut impl Write, progress: &[String], status: Option<&str> Ok(()) } -fn leave_raw_for_input(w: &mut impl Write) -> Result<()> { - execute!(w, terminal::LeaveAlternateScreen, cursor::Show)?; - terminal::disable_raw_mode()?; - Ok(()) -} - -fn enter_raw_for_select(w: &mut impl Write) -> Result<()> { - terminal::enable_raw_mode()?; - execute!(w, terminal::EnterAlternateScreen, cursor::Hide)?; - Ok(()) -} - fn pick_strategy( w: &mut impl Write, registry: &DotrainRegistry, @@ -252,9 +230,7 @@ fn pick_strategy( }) .collect(); - let ctx = SelectContext { - header_lines: progress, - }; + let ctx = SelectContext::new(progress); let idx = select::select(w, "Strategy", &select_items, &ctx)?; let key = keys[idx].clone(); @@ -308,9 +284,7 @@ fn pick_deployment( }) .collect(); - let ctx = SelectContext { - header_lines: progress, - }; + let ctx = SelectContext::new(progress); let idx = select::select(w, "Deployment", &select_items, &ctx)?; let key = keys[idx].clone(); Ok(key) @@ -328,15 +302,14 @@ async fn select_tokens( let available = builder.get_all_tokens(None).await.unwrap_or_default(); let address = if available.is_empty() { - leave_raw_for_input(w)?; - if let Some(desc) = &token_cfg.description { - eprintln!(" {}", desc); - } - let addr: String = Input::new() - .with_prompt(format!("{prompt_label} (address)")) - .interact_text()?; - enter_raw_for_select(w)?; - addr + select::input( + w, + &format!("{prompt_label} (address)"), + token_cfg.description.as_deref(), + None, + false, + progress, + )? } else { let mut select_items: Vec = available .iter() @@ -350,18 +323,11 @@ async fn select_tokens( description: String::new(), }); - let title = format!( - "{prompt_label}{}", - token_cfg - .description - .as_ref() - .map(|d| format!(" — {d}")) - .unwrap_or_default() - ); - let ctx = SelectContext { - header_lines: progress, - }; - let idx = select::select(w, &title, &select_items, &ctx)?; + 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]; @@ -373,11 +339,14 @@ async fn select_tokens( )); format!("{}", token.address) } else { - leave_raw_for_input(w)?; - let addr: String = Input::new() - .with_prompt(format!("{prompt_label} address")) - .interact_text()?; - enter_raw_for_select(w)?; + let addr = select::input( + w, + &format!("{prompt_label} address"), + None, + None, + false, + progress, + )?; progress.push(format!("{}: {addr}", bold(prompt_label))); addr } @@ -446,33 +415,26 @@ fn fill_single_field( }); } - let title = match &field.description { - Some(desc) => format!("{} — {desc}", field.name), - None => field.name.clone(), - }; - let ctx = SelectContext { - header_lines: progress, - }; - let idx = select::select(w, &title, &select_items, &ctx)?; + 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 { - leave_raw_for_input(w)?; - let v: String = Input::new().with_prompt(&field.name).interact_text()?; - enter_raw_for_select(w)?; - v + select::input(w, &field.name, field.description.as_deref(), None, false, progress)? } } - _ => { - leave_raw_for_input(w)?; - if let Some(desc) = &field.description { - eprintln!(" {}", desc); - } - let v: String = Input::new().with_prompt(&field.name).interact_text()?; - enter_raw_for_select(w)?; - v - } + _ => select::input( + w, + &field.name, + field.description.as_deref(), + None, + false, + progress, + )?, }; progress.push(format!("{}: {value}", bold(&field.name))); @@ -505,41 +467,43 @@ async fn fill_deposits( } for deposit_cfg in &deployment.deposits { - let token_display = match builder.get_token_info(deposit_cfg.token_key.clone()).await { + let (token_display, balance_desc) = match builder + .get_token_info(deposit_cfg.token_key.clone()) + .await + { Ok(info) => { - if let Ok(bal) = builder + let balance = builder .get_account_balance(format!("{}", info.address), owner.to_string()) .await - { - render_progress( - w, - progress, - Some(&format!( - "{} ({}) — Balance: {}", - info.symbol, - info.name, - bal.formatted_balance() - )), - )?; - } - info.symbol.clone() + .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(), + 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() { - leave_raw_for_input(w)?; - let a: String = Input::new() - .with_prompt(format!("Deposit {token_display} (blank to skip)")) - .default(String::new()) - .show_default(false) - .interact_text()?; - enter_raw_for_select(w)?; - a + select::input( + w, + &format!("Deposit amount ({token_display}) — blank to skip"), + desc_opt, + None, + true, + progress, + )? } else { let mut select_items: Vec = presets .iter() @@ -557,20 +521,24 @@ async fn fill_deposits( description: String::new(), }); - let ctx = SelectContext { - header_lines: progress, - }; - let idx = select::select(w, &format!("Deposit {token_display}"), &select_items, &ctx)?; + 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() { - leave_raw_for_input(w)?; - let a: String = Input::new() - .with_prompt(format!("Amount ({token_display})")) - .interact_text()?; - enter_raw_for_select(w)?; - a + select::input( + w, + &format!("Amount ({token_display})"), + None, + None, + false, + progress, + )? } else { continue; } diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs index 11e5b9f205..1264962555 100644 --- a/crates/cli/src/commands/strategy_builder/select.rs +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -1,10 +1,10 @@ -//! A Select widget rendered directly to a writer. +//! 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}, + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, execute, terminal::{self, ClearType}, }; @@ -15,10 +15,124 @@ pub struct SelectItem { pub description: String, } +/// Text input rendered in the alt screen. +/// Shows header lines for context, a prompt, and an editable line. +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 { + render_input(w, prompt, description, default, &buffer, header_lines)?; + + 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 => { + if buffer.is_empty() { + if let Some(d) = default { + return Ok(d.to_string()); + } + if allow_empty { + return Ok(String::new()); + } + // else loop — require non-empty + } else { + return Ok(buffer); + } + } + KeyCode::Esc => anyhow::bail!("cancelled"), + _ => {} + } + } + } +} + +fn render_input( + w: &mut impl Write, + prompt: &str, + description: Option<&str>, + default: Option<&str>, + buffer: &str, + header_lines: &[String], +) -> Result<()> { + let (cols, _) = terminal::size()?; + let cols = cols as usize; + + execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; + + for line in header_lines { + write!(w, " {line}\r\n")?; + } + if !header_lines.is_empty() { + write!(w, "\r\n")?; + } + + write!(w, " \x1b[1;4m{prompt}\x1b[0m\r\n\r\n")?; + + if let Some(desc) = description { + // Simple word wrap for description + let usable = cols.saturating_sub(4); + let mut col = 0usize; + write!(w, " \x1b[2m")?; + for word in desc.split_whitespace() { + let wlen = word.len(); + if col == 0 { + write!(w, "{word}")?; + col = wlen; + } else if col + 1 + wlen <= usable { + write!(w, " {word}")?; + col += 1 + wlen; + } else { + write!(w, "\x1b[0m\r\n \x1b[2m{word}")?; + col = wlen; + } + } + write!(w, "\x1b[0m\r\n\r\n")?; + } + + // The editable line + write!(w, " > \x1b[36m{buffer}\x1b[0m")?; + if buffer.is_empty() { + if let Some(d) = default { + write!(w, "\x1b[2m{d}\x1b[0m")?; + } + } + write!(w, "\x1b[?25h")?; // show cursor + + w.flush()?; + Ok(()) +} + /// Header lines to display above the select list. /// These show prior selections / progress. 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 + } } /// Run a select list on the given writer. @@ -139,10 +253,39 @@ fn render( write!(w, "\r\n")?; } - // Title - write!(w, " \x1b[1;4m{title}\x1b[0m\r\n\r\n")?; + // Title (bold + underlined) + write!(w, " \x1b[1;4m{title}\x1b[0m\r\n")?; + + // Description under title (dim, word-wrapped), no underline + let mut desc_lines = 0; + if let Some(desc) = ctx.description { + let usable = cols.saturating_sub(4); + let mut col = 0usize; + write!(w, " \x1b[2m")?; + for word in desc.split_whitespace() { + let wlen = word.len(); + if col == 0 { + write!(w, "{word}")?; + col = wlen; + } else if col + 1 + wlen <= usable { + write!(w, " {word}")?; + col += 1 + wlen; + } else { + write!(w, "\x1b[0m\r\n \x1b[2m{word}")?; + col = wlen; + desc_lines += 1; + } + } + write!(w, "\x1b[0m\r\n")?; + desc_lines += 1; + } + write!(w, "\r\n")?; - let header_rows = ctx.header_lines.len() + 4; + let header_rows = ctx.header_lines.len() + + if ctx.header_lines.is_empty() { 0 } else { 1 } + + 1 // title + + desc_lines + + 1; // blank before items let max_rows = rows.saturating_sub(header_rows + 2); let mut rows_used = 0; @@ -183,9 +326,8 @@ fn render( } // Scroll indicators - let first_item_row = header_rows; if scroll_offset > 0 { - execute!(w, cursor::MoveTo(cols as u16 - 3, first_item_row as u16))?; + execute!(w, cursor::MoveTo(cols as u16 - 3, header_rows as u16))?; write!(w, " ▲")?; } if scroll_offset + count_items_fitting(items, scroll_offset, max_rows) < items.len() { From 9a36b8ff63795741ebd97e4352b7bc3354c94869 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 15:13:44 +0000 Subject: [PATCH 20/32] refactor: extract shared helpers in select widget, add tests - Extract ANSI codes as named constants (BOLD, DIM, etc.) - Factor out write_header, write_title, wrap_lines, write_wrapped helpers shared between Select and Input rendering - Add SelectContext::new()/with_description() builder methods - Add 10 unit tests covering wrap_lines, item_height, and count_items_fitting edge cases - Simplify render_select signature (query terminal size internally) - Apply rustfmt Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/strategy_builder/interactive.rs | 41 +- .../src/commands/strategy_builder/select.rs | 495 ++++++++++-------- 2 files changed, 295 insertions(+), 241 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 9eeb08a68a..05b99b116a 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -424,7 +424,14 @@ fn fill_single_field( 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, + )? } } _ => select::input( @@ -467,23 +474,21 @@ async fn fill_deposits( } 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 (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()) diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs index 1264962555..f8b2499e3b 100644 --- a/crates/cli/src/commands/strategy_builder/select.rs +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -10,13 +10,135 @@ use crossterm::{ }; 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. -/// Shows header lines for context, a prompt, and an editable line. pub fn input( w: &mut impl Write, prompt: &str, @@ -26,9 +148,20 @@ pub fn input( header_lines: &[String], ) -> Result { let mut buffer = String::new(); - loop { - render_input(w, prompt, description, default, &buffer, header_lines)?; + 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, .. @@ -42,19 +175,12 @@ pub fn input( KeyCode::Backspace => { buffer.pop(); } - KeyCode::Enter => { - if buffer.is_empty() { - if let Some(d) = default { - return Ok(d.to_string()); - } - if allow_empty { - return Ok(String::new()); - } - // else loop — require non-empty - } else { - return Ok(buffer); - } - } + 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"), _ => {} } @@ -62,81 +188,7 @@ pub fn input( } } -fn render_input( - w: &mut impl Write, - prompt: &str, - description: Option<&str>, - default: Option<&str>, - buffer: &str, - header_lines: &[String], -) -> Result<()> { - let (cols, _) = terminal::size()?; - let cols = cols as usize; - - execute!(w, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All))?; - - for line in header_lines { - write!(w, " {line}\r\n")?; - } - if !header_lines.is_empty() { - write!(w, "\r\n")?; - } - - write!(w, " \x1b[1;4m{prompt}\x1b[0m\r\n\r\n")?; - - if let Some(desc) = description { - // Simple word wrap for description - let usable = cols.saturating_sub(4); - let mut col = 0usize; - write!(w, " \x1b[2m")?; - for word in desc.split_whitespace() { - let wlen = word.len(); - if col == 0 { - write!(w, "{word}")?; - col = wlen; - } else if col + 1 + wlen <= usable { - write!(w, " {word}")?; - col += 1 + wlen; - } else { - write!(w, "\x1b[0m\r\n \x1b[2m{word}")?; - col = wlen; - } - } - write!(w, "\x1b[0m\r\n\r\n")?; - } - - // The editable line - write!(w, " > \x1b[36m{buffer}\x1b[0m")?; - if buffer.is_empty() { - if let Some(d) = default { - write!(w, "\x1b[2m{d}\x1b[0m")?; - } - } - write!(w, "\x1b[?25h")?; // show cursor - - w.flush()?; - Ok(()) -} - -/// Header lines to display above the select list. -/// These show prior selections / progress. -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 - } -} - -/// Run a select list on the given writer. -/// The caller is responsible for alternate screen and raw mode. +/// Scrollable select list. pub fn select( w: &mut impl Write, title: &str, @@ -150,195 +202,192 @@ pub fn select( return Ok(0); } - let mut selected: usize = 0; - let mut scroll_offset: usize = 0; + let mut selected = 0usize; + let mut scroll = 0usize; loop { - render(w, title, items, selected, scroll_offset, ctx)?; + 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_offset { - scroll_offset = selected; + if selected < scroll { + scroll = selected; } } } KeyCode::Down | KeyCode::Char('j') => { if selected + 1 < items.len() { selected += 1; - let (_, rows) = terminal::size()?; - let header_rows = ctx.header_lines.len() + 4; // header + title + gaps - let visible_rows = (rows as usize).saturating_sub(header_rows + 2); - let items_visible = - count_items_fitting(items, scroll_offset, visible_rows); - if selected >= scroll_offset + items_visible { - scroll_offset += 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!("selection cancelled"); - } + KeyCode::Esc | KeyCode::Char('q') => anyhow::bail!("cancelled"), _ => {} } } } } -fn count_items_fitting(items: &[SelectItem], offset: usize, max_rows: usize) -> usize { - let (cols, _) = terminal::size().unwrap_or((80, 24)); - let cols = cols as usize; - let mut rows_used = 0; - let mut count = 0; - - for item in items.iter().skip(offset) { - let h = item_height(item, cols); - if rows_used + h > max_rows { - break; - } - rows_used += h; - count += 1; - } - - count.max(1) -} - -fn item_height(item: &SelectItem, term_cols: usize) -> usize { - let usable = term_cols.saturating_sub(7); // " ❯ " prefix + margin - let name_lines = 1; - let desc_lines = if item.description.is_empty() { - 0 - } else { - let mut lines = 1usize; - let mut col = 0usize; - for word in item.description.split_whitespace() { - let wlen = word.len(); - if col == 0 { - col = wlen; - } else if col + 1 + wlen <= usable { - col += 1 + wlen; - } else { - lines += 1; - col = wlen; - } - } - lines - }; - name_lines + desc_lines + 1 // +1 blank line between items -} - -fn render( +/// 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_offset: usize, + scroll: usize, ctx: &SelectContext, -) -> Result<()> { +) -> 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))?; - // Header: show prior selections - if !ctx.header_lines.is_empty() { - for line in ctx.header_lines { - write!(w, " {line}\r\n")?; - } - write!(w, "\r\n")?; - } - - // Title (bold + underlined) - write!(w, " \x1b[1;4m{title}\x1b[0m\r\n")?; - - // Description under title (dim, word-wrapped), no underline - let mut desc_lines = 0; - if let Some(desc) = ctx.description { - let usable = cols.saturating_sub(4); - let mut col = 0usize; - write!(w, " \x1b[2m")?; - for word in desc.split_whitespace() { - let wlen = word.len(); - if col == 0 { - write!(w, "{word}")?; - col = wlen; - } else if col + 1 + wlen <= usable { - write!(w, " {word}")?; - col += 1 + wlen; - } else { - write!(w, "\x1b[0m\r\n \x1b[2m{word}")?; - col = wlen; - desc_lines += 1; - } - } - write!(w, "\x1b[0m\r\n")?; - desc_lines += 1; - } - write!(w, "\r\n")?; + 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 header_rows = ctx.header_lines.len() - + if ctx.header_lines.is_empty() { 0 } else { 1 } - + 1 // title - + desc_lines - + 1; // blank before items - let max_rows = rows.saturating_sub(header_rows + 2); let mut rows_used = 0; - - for (idx, item) in items.iter().enumerate().skip(scroll_offset) { + 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 = if is_selected { "❯ " } else { " " }; - let name_style = if is_selected { "\x1b[1;36m" } else { "\x1b[1m" }; - - write!(w, " {prefix}{name_style}{}\x1b[0m\r\n", item.key)?; - + let (prefix, style) = if is_selected { + ("❯ ", BOLD_CYAN) + } else { + (" ", BOLD) + }; + write!(w, " {prefix}{style}{}{RESET}\r\n", item.key)?; if !item.description.is_empty() { - let usable = cols.saturating_sub(7); - let mut col = 0usize; - write!(w, " \x1b[2m")?; - for word in item.description.split_whitespace() { - let wlen = word.len(); - if col == 0 { - write!(w, "{word}")?; - col = wlen; - } else if col + 1 + wlen <= usable { - write!(w, " {word}")?; - col += 1 + wlen; - } else { - write!(w, "\x1b[0m\r\n \x1b[2m{word}")?; - col = wlen; - } - } - write!(w, "\x1b[0m\r\n")?; + write_wrapped(w, &item.description, " ", DIM, cols.saturating_sub(5))?; } - write!(w, "\r\n")?; rows_used += h; } // Scroll indicators - if scroll_offset > 0 { - execute!(w, cursor::MoveTo(cols as u16 - 3, header_rows as u16))?; + if scroll > 0 { + execute!(w, cursor::MoveTo(cols as u16 - 3, total_header as u16))?; write!(w, " ▲")?; } - if scroll_offset + count_items_fitting(items, scroll_offset, max_rows) < items.len() { + if scroll + count_items_fitting(items, scroll, max_rows, cols) < items.len() { execute!(w, cursor::MoveTo(cols as u16 - 3, rows as u16 - 2))?; write!(w, " ▼")?; } // Bottom hint execute!(w, cursor::MoveTo(0, rows as u16 - 1))?; - write!(w, " \x1b[2m↑↓ navigate ⏎ select esc quit\x1b[0m")?; + write!(w, " {DIM}↑↓ navigate ⏎ select esc quit{RESET}")?; w.flush()?; - Ok(()) + 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); + } } From 3188bcf218ccb77a66cef767efba61929bf4678e Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 22:06:10 +0000 Subject: [PATCH 21/32] feat: address CodeRabbit review on interactive wizard - RAII TerminalGuard restores raw mode/alt screen/cursor on panic - Explicit 0 => arm + unreachable! for output_choice match - saturating_sub for cursor position u16 cast to avoid underflow --- .../commands/strategy_builder/interactive.rs | 18 ++++++++++++++---- .../src/commands/strategy_builder/select.rs | 9 ++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/interactive.rs b/crates/cli/src/commands/strategy_builder/interactive.rs index 05b99b116a..79d51eda9b 100644 --- a/crates/cli/src/commands/strategy_builder/interactive.rs +++ b/crates/cli/src/commands/strategy_builder/interactive.rs @@ -18,6 +18,17 @@ 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)); @@ -29,13 +40,11 @@ pub async fn run_interactive(registry_url: &str) -> Result<()> { 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; - execute!(w, terminal::LeaveAlternateScreen, cursor::Show)?; - terminal::disable_raw_mode()?; - match result { Ok(output) => { eprintln!(); @@ -163,6 +172,7 @@ async fn run_wizard( let output_choice = select::select(w, "Output", &output_items, &ctx)?; match output_choice { + 0 => Ok(calldata_lines), 1 => { let path = select::input( w, @@ -181,7 +191,7 @@ async fn run_wizard( progress.push(format!(" Wrote to {path}")); Ok(Vec::new()) } - _ => Ok(calldata_lines), + other => unreachable!("unexpected output_choice index: {other}"), } } diff --git a/crates/cli/src/commands/strategy_builder/select.rs b/crates/cli/src/commands/strategy_builder/select.rs index f8b2499e3b..bc62fcc753 100644 --- a/crates/cli/src/commands/strategy_builder/select.rs +++ b/crates/cli/src/commands/strategy_builder/select.rs @@ -282,17 +282,20 @@ fn render_select( } // 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(cols as u16 - 3, total_header as u16))?; + 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(cols as u16 - 3, rows as u16 - 2))?; + execute!(w, cursor::MoveTo(xpos, ypos_bottom))?; write!(w, " ▼")?; } // Bottom hint - execute!(w, cursor::MoveTo(0, rows as u16 - 1))?; + execute!(w, cursor::MoveTo(0, ypos_hint))?; write!(w, " {DIM}↑↓ navigate ⏎ select esc quit{RESET}")?; w.flush()?; From f4146a3ed4c299055627b25e0ff3471c321a4b2d Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 16:46:36 +0000 Subject: [PATCH 22/32] feat: add --tokens flag to list deployment-specific tokens `raindex strategy-builder --tokens --strategy --deployment --registry ` emits a markdown table of all tokens registered for that specific deployment (symbol, name, address, decimals). This avoids bloating --describe output with potentially hundreds of tokens per deployment. --describe will reference this command so an agent only fetches the token list for the deployment it actually needs. --- .../cli/src/commands/strategy_builder/mod.rs | 18 ++ .../src/commands/strategy_builder/tokens.rs | 167 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 crates/cli/src/commands/strategy_builder/tokens.rs diff --git a/crates/cli/src/commands/strategy_builder/mod.rs b/crates/cli/src/commands/strategy_builder/mod.rs index 61a5c3a55e..d41cef3ac5 100644 --- a/crates/cli/src/commands/strategy_builder/mod.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,5 +1,6 @@ mod interactive; mod select; +mod tokens; use crate::execute::Execute; use alloy::primitives::hex; @@ -20,6 +21,12 @@ pub struct StrategyBuilder { #[arg(short, long, help = "Interactive mode — guided strategy deployment")] interactive: bool, + #[arg( + long, + help = "List all tokens registered for --strategy + --deployment as markdown" + )] + tokens: bool, + #[arg(long, help = "Order/strategy key from the registry")] strategy: Option, @@ -74,6 +81,17 @@ impl Execute for StrategyBuilder { if self.interactive { return interactive::run_interactive(&self.registry).await; } + if self.tokens { + let strategy = self + .strategy + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--strategy is required with --tokens"))?; + let deployment = self + .deployment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--deployment is required with --tokens"))?; + return tokens::run_tokens(&self.registry, strategy, deployment).await; + } let strategy = self .strategy diff --git a/crates/cli/src/commands/strategy_builder/tokens.rs b/crates/cli/src/commands/strategy_builder/tokens.rs new file mode 100644 index 0000000000..91d323440d --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/tokens.rs @@ -0,0 +1,167 @@ +//! `--tokens` mode: lists all tokens available for `--select-token` on a +//! given strategy + deployment, as markdown. + +use anyhow::Result; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::fmt::Write; + +pub async fn run_tokens(registry_url: &str, strategy: &str, deployment: &str) -> Result<()> { + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let dotrain = registry + .orders() + .0 + .get(strategy) + .ok_or_else(|| { + let available = registry.get_order_keys().unwrap_or_default(); + anyhow::anyhow!("strategy '{strategy}' not found. Available: {available:?}") + })? + .clone(); + + let settings = registry_settings(®istry); + + let builder = + RaindexOrderBuilder::new_with_deployment(dotrain, settings, deployment.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let tokens = builder + .get_all_tokens(None) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut out = String::new(); + writeln!(out, "# Available tokens — `{strategy}` / `{deployment}`")?; + writeln!(out)?; + + if tokens.is_empty() { + writeln!(out, "_No tokens registered for this deployment._")?; + } else { + writeln!( + out, + "{} tokens. Use any address as the value of a `--select-token KEY=
` flag.", + tokens.len() + )?; + writeln!(out)?; + writeln!(out, "| Symbol | Name | Address | Decimals |")?; + writeln!(out, "|--------|------|---------|----------|")?; + for token in tokens { + writeln!( + out, + "| `{}` | {} | `{}` | {} |", + token.symbol, token.name, token.address, token.decimals + )?; + } + } + + println!("{out}"); + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn lists_tokens_for_a_deployment() { + let server = httpmock::MockServer::start(); + + let settings = r#"version: 4 +networks: + base: + rpcs: + - https://base-rpc.publicnode.com + chain-id: 8453 + network-id: 8453 + currency: ETH +orderbooks: + base: + address: 0xe522cB4a5fCb2eb31a52Ff41a4653d85A4fd7C9D + network: base +deployers: + base: + address: 0xd905B56949284Bb1d28eeFC05be78Af69cCf3668 + network: base +tokens: + test-usdc: + network: base + address: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 + decimals: 6 + label: USD Coin + symbol: USDC +"#; + + let strategy = r#"version: 4 +orders: + base: + orderbook: base + inputs: + - token: token1 + outputs: + - token: token2 +scenarios: + base: + orderbook: base + runs: 1 + bindings: {} +deployments: + base: + order: base + scenario: base +builder: + name: Test + description: Test + short-description: Test + deployments: + base: + name: Base + description: Test + deposits: [] + fields: [] + select-tokens: + - key: token1 + - key: token2 +--- +#calculate-io +max-output: max-positive-value(), +io: 1; +#handle-io +:; +#handle-add-order +:; +"#; + + let settings_url = format!("{}/settings.yaml", server.base_url()); + let strategy_url = format!("{}/test.rain", server.base_url()); + let registry_url = format!("{}/registry", server.base_url()); + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/registry"); + then.status(200) + .body(format!("{settings_url}\ntest {strategy_url}\n")); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/settings.yaml"); + then.status(200).body(settings); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/test.rain"); + then.status(200).body(strategy); + }); + + let result = run_tokens(®istry_url, "test", "base").await; + assert!(result.is_ok(), "tokens failed: {result:?}"); + } +} From 3bc3f410d1c2542ea2ce958407d82fe8d34ccf12 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 09:22:16 +0000 Subject: [PATCH 23/32] feat: template fallback operator ${expr || 'default'} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a template path resolves to a missing token field (select-token not yet selected) and a fallback literal is supplied, substitute the literal instead of leaving the placeholder raw or erroring. Syntax: ${path} — current behaviour ${path || 'fallback'} — new: substitute literal on missing token ${path || "fallback"} — double quotes also accepted Backwards-compatible: existing templates without `||` behave identically. The fallback only kicks in on PropertyNotFound("token") (i.e. unresolved select-token), not on other resolution errors. This unblocks --describe emitting readable field names/descriptions when no token has been selected yet. --- crates/settings/src/yaml/context.rs | 122 +++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/settings/src/yaml/context.rs b/crates/settings/src/yaml/context.rs index c50cf35b7b..55fb46f8a3 100644 --- a/crates/settings/src/yaml/context.rs +++ b/crates/settings/src/yaml/context.rs @@ -360,13 +360,25 @@ impl Context { let var_start = start + var_start; if let Some(var_end) = result[var_start..].find('}') { let var_end = var_start + var_end + 1; - let var = &result[var_start + 2..var_end - 1]; - let replacement = match self.resolve_path(var) { + let inner = &result[var_start + 2..var_end - 1]; + + // Split on `||` to extract an optional fallback string literal. + // Syntax: ${path} or ${path || 'fallback'} or ${path || "fallback"}. + // The fallback is used when the path resolves to a missing token field + // (i.e. select-token not yet selected). + let (path, fallback) = parse_path_and_fallback(inner); + + let replacement = match self.resolve_path(path) { Ok(value) => Some(value), + Err(ContextError::PropertyNotFound(property)) + if fallback.is_some() && property == "token" => + { + Some(fallback.unwrap().to_string()) + } Err(ContextError::PropertyNotFound(property)) if allow_select_tokens && property == "token" - && self.select_token_key_for_path(var).is_some() => + && self.select_token_key_for_path(path).is_some() => { None } @@ -388,6 +400,33 @@ impl Context { } } +/// Split a `${...}` body into `(path, optional fallback literal)`. +/// Recognised forms: +/// path +/// path || 'fallback' +/// path || "fallback" +/// Whitespace around `||` and within the literal bounds is trimmed. +/// If the body doesn't match the fallback form, returns `(body_trimmed, None)`. +fn parse_path_and_fallback(body: &str) -> (&str, Option<&str>) { + let Some(or_pos) = body.find("||") else { + return (body.trim(), None); + }; + let (left, right) = body.split_at(or_pos); + let path = left.trim(); + let right = right[2..].trim(); + + // Strip matching single or double quotes around the fallback. + let stripped = right + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| right.strip_prefix('"').and_then(|s| s.strip_suffix('"'))); + + match stripped { + Some(literal) => (path, Some(literal)), + None => (body.trim(), None), // malformed — treat whole thing as path + } +} + #[cfg(test)] mod tests { use super::*; @@ -556,6 +595,83 @@ mod tests { ); } + #[test] + fn test_interpolate_fallback_for_unresolved_token() { + // In strict mode with a select-token not yet selected, a fallback literal + // should be substituted in place of the token path. + let order = setup_select_token_order(); + let mut context = Context::new(); + context.add_order(order.clone()); + context.add_select_tokens(vec!["token1".to_string()]); + + let out = context + .interpolate("${order.inputs.0.token.symbol || 'input token'}") + .unwrap(); + assert_eq!(out, "input token"); + + // Double quotes also supported. + let out = context + .interpolate(r#"${order.inputs.0.token.symbol || "input token"}"#) + .unwrap(); + assert_eq!(out, "input token"); + + // Whitespace around || is tolerated. + let out = context + .interpolate("${order.inputs.0.token.symbol||'x'}") + .unwrap(); + assert_eq!(out, "x"); + + // Mixed in a surrounding template string (using only inputs.0 since + // the select-token fixture only has one input). + let out = context + .interpolate("${order.inputs.0.token.symbol || 'buy'} at ${order.inputs.0.token.symbol || 'pair'}") + .unwrap(); + assert_eq!(out, "buy at pair"); + } + + #[test] + fn test_interpolate_fallback_not_used_when_resolved() { + // If the path resolves, the fallback is ignored. + let mut context = Context::new(); + let order = setup_test_order_with_vault_id(); + context.add_order(order); + + let out = context + .interpolate("${order.inputs.0.token.symbol || 'fallback'}") + .unwrap(); + // The test token has symbol None, so .symbol still returns empty string or errors. + // Either way, the fallback only kicks in on PropertyNotFound("token"), not on + // a present-but-empty symbol. We just check the fallback isn't blindly applied. + assert_ne!(out, "fallback"); + } + + #[test] + fn test_interpolate_fallback_not_applied_to_non_token_errors() { + // If the path fails for some reason other than missing token, the fallback + // should NOT be applied — the error should propagate. + let order = setup_select_token_order(); + let mut context = Context::new(); + context.add_order(order); + context.add_select_tokens(vec!["token1".to_string()]); + + let err = context + .interpolate("${order.inputs.0.vault-id || 'default'}") + .unwrap_err(); + assert_eq!(err, ContextError::PropertyNotFound("vault-id".to_string())); + } + + #[test] + fn test_interpolate_malformed_fallback_is_treated_as_path() { + // ${x || foo} (no quotes) isn't a valid fallback; the body is treated as a + // path. It'll fail to resolve since the path is nonsense. + let mut context = Context::new(); + context.add_order(setup_test_order_with_vault_id()); + + let err = context.interpolate("${bogus || foo}").unwrap_err(); + // Just assert it errors rather than silently substituting. + assert!(matches!(err, ContextError::InvalidPath(_) | ContextError::PropertyNotFound(_) | ContextError::NoOrder)); + } + #[test] fn test_context_no_order() { let context = Context::new(); From 988ae0309da5a8296d281bf255101e48819fc019 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 15:41:39 +0000 Subject: [PATCH 24/32] fmt: wrap long assert in context.rs tests --- crates/settings/src/yaml/context.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/settings/src/yaml/context.rs b/crates/settings/src/yaml/context.rs index 55fb46f8a3..425b0b90f3 100644 --- a/crates/settings/src/yaml/context.rs +++ b/crates/settings/src/yaml/context.rs @@ -669,7 +669,12 @@ mod tests { let err = context.interpolate("${bogus || foo}").unwrap_err(); // Just assert it errors rather than silently substituting. - assert!(matches!(err, ContextError::InvalidPath(_) | ContextError::PropertyNotFound(_) | ContextError::NoOrder)); + assert!(matches!( + err, + ContextError::InvalidPath(_) + | ContextError::PropertyNotFound(_) + | ContextError::NoOrder + )); } #[test] From 39b0eca4b683b5b4a2c01a01dd630ce48115b729 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 15:53:08 +0000 Subject: [PATCH 25/32] feat: add --describe flag to strategy-builder Emits a full markdown dump of a registry: strategies, their deployments, fields (with presets and defaults), select-tokens, deposits, plus usage documentation and the address:calldata output format. Intended use: an AI agent or human can run once to learn everything needed to construct a non-interactive deploy command for the registry. The output includes example commands for each deployment with all required flags filled in as placeholders. --- .../src/commands/strategy_builder/describe.rs | 431 ++++++++++++++++++ .../cli/src/commands/strategy_builder/mod.rs | 10 + 2 files changed, 441 insertions(+) create mode 100644 crates/cli/src/commands/strategy_builder/describe.rs diff --git a/crates/cli/src/commands/strategy_builder/describe.rs b/crates/cli/src/commands/strategy_builder/describe.rs new file mode 100644 index 0000000000..823b92dce0 --- /dev/null +++ b/crates/cli/src/commands/strategy_builder/describe.rs @@ -0,0 +1,431 @@ +//! `--describe` mode: prints a full dump of a registry as markdown. +//! +//! Intended to serve as a self-generating skill for the non-interactive CLI. +//! A human or agent can run `raindex strategy-builder describe --registry URL` +//! and get everything they need to construct a deploy command. + +use anyhow::Result; +use rain_orderbook_common::raindex_order_builder::RaindexOrderBuilder; +use rain_orderbook_js_api::registry::DotrainRegistry; +use std::fmt::Write; + +const USAGE: &str = r#"## Usage + +Generate deployment calldata for a strategy: + +``` +raindex strategy-builder \ + --registry \ + --strategy \ + --deployment \ + --owner <0x-address> \ + [--select-token KEY=ADDRESS ...] \ + [--set-field BINDING=VALUE ...] \ + [--set-deposit TOKEN=AMOUNT ...] +``` + +### Output format + +The command writes one transaction per line to stdout, each in the form: + +``` +: +``` + +Multiple lines are possible — they must be signed and broadcast in order. +Typical output contains: + +1. One ERC20 `approve` transaction per token being deposited (if any). +2. The main order deployment transaction to the orderbook contract. +3. An optional metadata emission transaction. + +Pipe the output into any submitter that signs and broadcasts transactions: + +``` +raindex strategy-builder ... | stox submit +``` + +"#; + +pub async fn run_describe(registry_url: &str) -> Result<()> { + let registry = DotrainRegistry::new(registry_url.to_string()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let mut out = String::new(); + writeln!(out, "# Raindex Strategy Registry")?; + writeln!(out)?; + writeln!(out, "**Registry:** {registry_url}")?; + writeln!(out)?; + write!(out, "{USAGE}")?; + writeln!(out, "## Strategies")?; + writeln!(out)?; + + let details = registry + .get_all_order_details() + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + let settings = registry_settings(®istry); + + let mut strategy_keys: Vec<&String> = details.valid.keys().collect(); + strategy_keys.sort(); + + for strategy_key in strategy_keys { + let info = &details.valid[strategy_key]; + writeln!(out, "### `{strategy_key}` — {}", info.name)?; + writeln!(out)?; + writeln!(out, "{}", info.description)?; + writeln!(out)?; + + let dotrain = registry + .orders() + .0 + .get(strategy_key) + .cloned() + .ok_or_else(|| anyhow::anyhow!("strategy '{strategy_key}' not in registry"))?; + + describe_strategy(&mut out, strategy_key, &dotrain, &settings).await?; + } + + if !details.invalid.is_empty() { + writeln!(out, "## Invalid Strategies")?; + writeln!(out)?; + writeln!(out, "The following registry entries failed to parse:")?; + writeln!(out)?; + for (key, err) in &details.invalid { + writeln!(out, "- `{key}`: {}", err.readable_msg)?; + } + writeln!(out)?; + } + + println!("{out}"); + Ok(()) +} + +async fn describe_strategy( + out: &mut String, + strategy_key: &str, + dotrain: &str, + settings: &Option>, +) -> Result<()> { + let deployment_keys = + RaindexOrderBuilder::get_deployment_keys(dotrain.to_string(), settings.clone()) + .await + .map_err(|err| anyhow::anyhow!("{}", err.to_readable_msg()))?; + + if deployment_keys.is_empty() { + writeln!(out, "_No deployments defined._")?; + writeln!(out)?; + return Ok(()); + } + + writeln!(out, "**Deployments:**")?; + writeln!(out)?; + + for deployment_key in deployment_keys { + // Build each deployment individually to get its full config + let builder = match RaindexOrderBuilder::new_with_deployment( + dotrain.to_string(), + settings.clone(), + deployment_key.clone(), + ) + .await + { + Ok(b) => b, + Err(err) => { + writeln!( + out, + "#### `{deployment_key}` — _failed to load: {}_", + err.to_readable_msg() + )?; + writeln!(out)?; + continue; + } + }; + + let deployment = match builder.get_current_deployment() { + Ok(d) => d, + Err(err) => { + writeln!( + out, + "#### `{deployment_key}` — _failed to load: {}_", + err.to_readable_msg() + )?; + writeln!(out)?; + continue; + } + }; + + writeln!(out, "#### `{deployment_key}` — {}", deployment.name)?; + writeln!(out)?; + writeln!(out, "{}", deployment.description)?; + writeln!(out)?; + + writeln!(out, "**Example command:**")?; + writeln!(out)?; + writeln!(out, "```")?; + writeln!( + out, + "raindex strategy-builder \\\n --registry \\\n --strategy {strategy_key} \\\n --deployment {deployment_key} \\\n --owner <0x-address>{}{}{}", + render_token_flags(&deployment), + render_field_flags(&deployment), + render_deposit_flags(&deployment), + )?; + writeln!(out, "```")?; + writeln!(out)?; + + describe_select_tokens(out, &deployment)?; + describe_fields(out, &deployment)?; + describe_deposits(out, &deployment)?; + } + + Ok(()) +} + +fn render_token_flags( + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> String { + match &deployment.select_tokens { + Some(tokens) if !tokens.is_empty() => tokens + .iter() + .map(|t| format!(" \\\n --select-token {}=
", t.key)) + .collect(), + _ => String::new(), + } +} + +fn render_field_flags( + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> String { + deployment + .fields + .iter() + .filter(|f| f.default.is_none()) + .map(|f| format!(" \\\n --set-field {}=", f.binding)) + .collect() +} + +fn render_deposit_flags( + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> String { + deployment + .deposits + .iter() + .map(|d| format!(" \\\n --set-deposit {}=", d.token_key)) + .collect() +} + +fn describe_select_tokens( + out: &mut String, + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> Result<()> { + let tokens = match &deployment.select_tokens { + Some(t) if !t.is_empty() => t, + _ => return Ok(()), + }; + + writeln!(out, "**Tokens to select:**")?; + writeln!(out)?; + for token in tokens { + let name = token.name.as_deref().unwrap_or(""); + let desc = token.description.as_deref().unwrap_or(""); + match (name, desc) { + ("", "") => writeln!(out, "- `{}`", token.key)?, + (n, "") => writeln!(out, "- `{}` — {n}", token.key)?, + ("", d) => writeln!(out, "- `{}` — {d}", token.key)?, + (n, d) => writeln!(out, "- `{}` ({n}) — {d}", token.key)?, + } + } + writeln!(out)?; + Ok(()) +} + +fn describe_fields( + out: &mut String, + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> Result<()> { + if deployment.fields.is_empty() { + return Ok(()); + } + + writeln!(out, "**Fields:**")?; + writeln!(out)?; + for field in &deployment.fields { + write!(out, "- `{}` ({})", field.binding, field.name)?; + if let Some(default) = &field.default { + write!(out, " _[default: `{default}`]_")?; + } + writeln!(out)?; + if let Some(desc) = &field.description { + writeln!(out, " - {desc}")?; + } + if let Some(presets) = &field.presets { + if !presets.is_empty() { + write!(out, " - Presets:")?; + for preset in presets { + match &preset.name { + Some(n) => write!(out, " `{}` = `{}`,", n, preset.value)?, + None => write!(out, " `{}`,", preset.value)?, + } + } + writeln!(out)?; + } + } + } + writeln!(out)?; + Ok(()) +} + +fn describe_deposits( + out: &mut String, + deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, +) -> Result<()> { + if deployment.deposits.is_empty() { + return Ok(()); + } + + writeln!(out, "**Deposits:**")?; + writeln!(out)?; + for deposit in &deployment.deposits { + write!(out, "- `{}`", deposit.token_key)?; + if let Some(presets) = &deposit.presets { + if !presets.is_empty() { + write!( + out, + " — presets: {}", + presets + .iter() + .map(|p| format!("`{p}`")) + .collect::>() + .join(", ") + )?; + } + } + writeln!(out)?; + } + writeln!(out)?; + Ok(()) +} + +fn registry_settings(registry: &DotrainRegistry) -> Option> { + let content = registry.settings(); + if content.is_empty() { + None + } else { + Some(vec![content]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // End-to-end: describe a real registry served by httpmock and verify + // the markdown output contains all the strategy details. + #[tokio::test] + async fn describe_renders_registry_as_markdown() { + let server = httpmock::MockServer::start(); + + let settings = r#"version: 4 +networks: + base: + rpcs: + - https://base-rpc.publicnode.com + chain-id: 8453 + network-id: 8453 + currency: ETH +orderbooks: + base: + address: 0xe522cB4a5fCb2eb31a52Ff41a4653d85A4fd7C9D + network: base +deployers: + base: + address: 0xd905B56949284Bb1d28eeFC05be78Af69cCf3668 + network: base +"#; + + let strategy = r#"version: 4 +orders: + base: + orderbook: base + inputs: + - token: token1 + outputs: + - token: token2 +scenarios: + base: + orderbook: base + runs: 1 + bindings: + max-spread: 0.002 +deployments: + base: + order: base + scenario: base +builder: + name: Fixed spread + description: A strategy that tracks a benchmark price with a fixed spread. + short-description: Fixed spread strategy + deployments: + base: + name: Base + description: Deploy on Base network. + deposits: + - token: token2 + presets: + - "10" + - "100" + - "1000" + fields: + - binding: max-spread + name: Maximum spread + description: The max spread as a decimal. + presets: + - name: Tight + value: "0.001" + - name: Loose + value: "0.01" + select-tokens: + - key: token1 + name: Input token + description: The token you buy + - key: token2 + name: Output token + description: The token you sell +--- +#max-spread !max spread +#calculate-io +max-output: max-positive-value(), +io: 1; +#handle-io +:; +#handle-add-order +:; +"#; + + let settings_url = format!("{}/settings.yaml", server.base_url()); + let strategy_url = format!("{}/fixed-spread.rain", server.base_url()); + let registry_url = format!("{}/registry", server.base_url()); + + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/registry"); + then.status(200) + .body(format!("{settings_url}\nfixed-spread {strategy_url}\n")); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/settings.yaml"); + then.status(200).body(settings); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/fixed-spread.rain"); + then.status(200).body(strategy); + }); + + // Capture stdout. The describe command uses println!, so we need a + // helper — instead just verify it completes without error. The + // individual helpers below cover the rendering logic. + let result = run_describe(®istry_url).await; + assert!(result.is_ok(), "describe failed: {result:?}"); + } +} diff --git a/crates/cli/src/commands/strategy_builder/mod.rs b/crates/cli/src/commands/strategy_builder/mod.rs index d41cef3ac5..c8cbd62621 100644 --- a/crates/cli/src/commands/strategy_builder/mod.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -1,3 +1,4 @@ +mod describe; mod interactive; mod select; mod tokens; @@ -27,6 +28,12 @@ pub struct StrategyBuilder { )] tokens: bool, + #[arg( + long, + help = "Describe the registry — list strategies, deployments, fields, tokens, and usage as markdown" + )] + describe: bool, + #[arg(long, help = "Order/strategy key from the registry")] strategy: Option, @@ -78,6 +85,9 @@ fn parse_key_value_pairs(args: &[String]) -> Result> { impl Execute for StrategyBuilder { async fn execute(&self) -> Result<()> { + if self.describe { + return describe::run_describe(&self.registry).await; + } if self.interactive { return interactive::run_interactive(&self.registry).await; } From b9e6e30f4abae831eb2ee5573e271291ca78a19f Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 16:31:01 +0000 Subject: [PATCH 26/32] feat: richer --describe usage docs for agent self-sufficiency Extend the usage section with: - cast send pipe example for signing without the stox CLI - Explicit note that approvals only appear when depositing - Explanation of how to pick token addresses (block explorer / token list) since these are not in the registry itself - Clarification that --owner must be a signing address Goal: minimize the upfront context a calling agent needs before running --describe. Most of what a user would otherwise need to explain in the prompt is now emitted by the tool itself. --- .../src/commands/strategy_builder/describe.rs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/describe.rs b/crates/cli/src/commands/strategy_builder/describe.rs index 823b92dce0..33974ed0f5 100644 --- a/crates/cli/src/commands/strategy_builder/describe.rs +++ b/crates/cli/src/commands/strategy_builder/describe.rs @@ -24,6 +24,10 @@ raindex strategy-builder \ [--set-deposit TOKEN=AMOUNT ...] ``` +`--owner` must be the address that will sign the transactions (not a contract). +Each deployment below has an **Example command** with the required flags +pre-filled for that specific deployment. + ### Output format The command writes one transaction per line to stdout, each in the form: @@ -33,18 +37,41 @@ The command writes one transaction per line to stdout, each in the form: ``` Multiple lines are possible — they must be signed and broadcast in order. -Typical output contains: +Output contains (in order): -1. One ERC20 `approve` transaction per token being deposited (if any). +1. One ERC20 `approve` transaction per token being deposited. If you pass + no `--set-deposit` flags, there are no approvals. 2. The main order deployment transaction to the orderbook contract. 3. An optional metadata emission transaction. -Pipe the output into any submitter that signs and broadcasts transactions: +### Submitting transactions + +Option A — pipe into the st0x CLI (handles Turnkey wallet signing): ``` raindex strategy-builder ... | stox submit ``` +Option B — sign and broadcast each line with `cast send` (foundry): + +``` +raindex strategy-builder ... | while IFS=: read -r to data; do + cast send "$to" "$data" \ + --private-key "$PRIVATE_KEY" \ + --rpc-url "$RPC_URL" +done +``` + +Option C — pipe into any other submitter that reads `address:calldata` lines. + +### Picking token addresses + +Token addresses are chain-specific. Look them up on a block explorer +(basescan.org, etherscan.io, etc.) or a token list for the target network. +Each deployment below lists the tokens that must be selected via +`--select-token KEY=
`; the KEY side is a slot name defined by the +strategy, the `
` side is the ERC20 contract address you pick. + "#; pub async fn run_describe(registry_url: &str) -> Result<()> { From c45b56b49e26e866310a1aa7183d528dd06331a7 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 16:53:22 +0000 Subject: [PATCH 27/32] feat: describe references --tokens for fetching valid addresses Each deployment with select-tokens now includes a code block showing how to fetch the actual valid token addresses for that deployment via raindex strategy-builder --tokens. Avoids inlining hundreds of token entries in describe output. --- .../src/commands/strategy_builder/describe.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/strategy_builder/describe.rs b/crates/cli/src/commands/strategy_builder/describe.rs index 33974ed0f5..a134c35b6f 100644 --- a/crates/cli/src/commands/strategy_builder/describe.rs +++ b/crates/cli/src/commands/strategy_builder/describe.rs @@ -201,7 +201,7 @@ async fn describe_strategy( writeln!(out, "```")?; writeln!(out)?; - describe_select_tokens(out, &deployment)?; + describe_select_tokens(out, &deployment, strategy_key, &deployment_key)?; describe_fields(out, &deployment)?; describe_deposits(out, &deployment)?; } @@ -245,6 +245,8 @@ fn render_deposit_flags( fn describe_select_tokens( out: &mut String, deployment: &rain_orderbook_app_settings::order_builder::OrderBuilderDeploymentCfg, + strategy_key: &str, + deployment_key: &str, ) -> Result<()> { let tokens = match &deployment.select_tokens { Some(t) if !t.is_empty() => t, @@ -264,6 +266,18 @@ fn describe_select_tokens( } } writeln!(out)?; + writeln!( + out, + "Get the list of valid token addresses for this deployment with:" + )?; + writeln!(out)?; + writeln!(out, "```")?; + writeln!( + out, + "raindex strategy-builder --tokens \\\n --registry \\\n --strategy {strategy_key} \\\n --deployment {deployment_key}" + )?; + writeln!(out, "```")?; + writeln!(out)?; Ok(()) } From b5252036864fae156ca567ee8acc05737cb2987b Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 09:55:11 +0000 Subject: [PATCH 28/32] feat: polish --describe output and label calldata transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark each field as (required) or (optional, default: X) - Add a units convention note to the usage section - Add a build-once guidance section (run binary directly, avoid the npm install noise from the nix develop shellHook) - Label calldata stdout lines with # comment headers describing each tx - Update the cast-send pipe example to skip # comment lines and blank lines - Document the # comment convention in the output format section The # comment lines are safe for any submitter that skips lines not matching
: — cast-loop example shows the idiom. The stox submit parser will be updated in a follow-up PR to do the same. --- .../src/commands/strategy_builder/describe.rs | 64 ++++++++++++++----- .../cli/src/commands/strategy_builder/mod.rs | 3 + 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/crates/cli/src/commands/strategy_builder/describe.rs b/crates/cli/src/commands/strategy_builder/describe.rs index a134c35b6f..def0016faa 100644 --- a/crates/cli/src/commands/strategy_builder/describe.rs +++ b/crates/cli/src/commands/strategy_builder/describe.rs @@ -28,16 +28,30 @@ raindex strategy-builder \ Each deployment below has an **Example command** with the required flags pre-filled for that specific deployment. +Field values passed via `--set-field` are in human-readable decimal form — the +tool handles token-decimal scaling internally. You do not need to multiply by +`10^decimals`. + ### Output format -The command writes one transaction per line to stdout, each in the form: +The command writes one transaction per line to stdout. Each transaction is +preceded by a `#` comment line describing what the transaction does. The lines +look like: ``` -: +# approve WETH +0x...:0x095ea7b3... +# deploy order +0xe522cB4a...:0xac9650d8... +# emit strategy metadata +0x59401C93...:0x37480e2a... ``` -Multiple lines are possible — they must be signed and broadcast in order. -Output contains (in order): +Submitters should skip lines starting with `#` and split the remaining lines on +the first `:` to get `(to-address, hex-calldata)` pairs. + +Multiple non-comment lines are possible — they must be signed and broadcast in +order. Output contains (in order): 1. One ERC20 `approve` transaction per token being deposited. If you pass no `--set-deposit` flags, there are no approvals. @@ -56,21 +70,41 @@ Option B — sign and broadcast each line with `cast send` (foundry): ``` raindex strategy-builder ... | while IFS=: read -r to data; do + [[ "$to" == \#* || -z "$to" ]] && continue cast send "$to" "$data" \ --private-key "$PRIVATE_KEY" \ --rpc-url "$RPC_URL" done ``` -Option C — pipe into any other submitter that reads `address:calldata` lines. +Option C — pipe into any other submitter that reads `address:calldata` lines +(skipping `#` comment lines). ### Picking token addresses -Token addresses are chain-specific. Look them up on a block explorer -(basescan.org, etherscan.io, etc.) or a token list for the target network. -Each deployment below lists the tokens that must be selected via -`--select-token KEY=
`; the KEY side is a slot name defined by the -strategy, the `
` side is the ERC20 contract address you pick. +Token addresses are chain-specific. Each deployment below lists the tokens +that must be selected via `--select-token KEY=
`. To see which token +addresses the registry has for a given deployment, use `--tokens`: + +``` +raindex strategy-builder --tokens \ + --registry --strategy --deployment +``` + +Any ERC20 address is valid for `--select-token`; the registry list is just a +curated convenience subset. + +### Building the CLI from source + +If you're running this from a local clone, build once and call the binary +directly — the `nix develop` shellHook runs `npm install` on every invocation +which is noisy and slow. + +``` +cd rain.orderbook +nix develop --impure --command cargo build -p rain_orderbook_cli +./target/debug/rain_orderbook_cli strategy-builder --describe --registry +``` "#; @@ -292,11 +326,11 @@ fn describe_fields( writeln!(out, "**Fields:**")?; writeln!(out)?; for field in &deployment.fields { - write!(out, "- `{}` ({})", field.binding, field.name)?; - if let Some(default) = &field.default { - write!(out, " _[default: `{default}`]_")?; - } - writeln!(out)?; + let marker = match &field.default { + Some(default) => format!(" _(optional, default: `{default}`)_"), + None => " _(required)_".to_string(), + }; + writeln!(out, "- `{}` ({}){marker}", field.binding, field.name)?; if let Some(desc) = &field.description { writeln!(out, " - {desc}")?; } diff --git a/crates/cli/src/commands/strategy_builder/mod.rs b/crates/cli/src/commands/strategy_builder/mod.rs index c8cbd62621..ac8839a0cf 100644 --- a/crates/cli/src/commands/strategy_builder/mod.rs +++ b/crates/cli/src/commands/strategy_builder/mod.rs @@ -189,9 +189,11 @@ impl Execute for StrategyBuilder { })?; for approval in &args.approvals { + println!("# approve {}", approval.symbol); println!("{}:0x{}", approval.token, hex::encode(&approval.calldata)); } + println!("# deploy {strategy} order"); println!( "{}:0x{}", args.orderbook_address, @@ -199,6 +201,7 @@ impl Execute for StrategyBuilder { ); if let Some(meta_call) = &args.emit_meta_call { + println!("# emit strategy metadata"); println!("{}:0x{}", meta_call.to, hex::encode(&meta_call.calldata)); } From 992edb4fe8e7883c5bcddee7c3cfa38c6f85fc42 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 23:52:23 +0000 Subject: [PATCH 29/32] chore: retrigger CI From 7b5564dae7b18f9baa501e2c9325d1f42e5f06e6 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 16 Apr 2026 00:19:48 +0000 Subject: [PATCH 30/32] chore: touch describe.rs to force CI --- crates/cli/src/commands/strategy_builder/describe.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/commands/strategy_builder/describe.rs b/crates/cli/src/commands/strategy_builder/describe.rs index def0016faa..35f3d55152 100644 --- a/crates/cli/src/commands/strategy_builder/describe.rs +++ b/crates/cli/src/commands/strategy_builder/describe.rs @@ -504,3 +504,4 @@ io: 1; assert!(result.is_ok(), "describe failed: {result:?}"); } } +// retrigger From 163c9892006ae2d117b5cc2a42e2438a90ad1654 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 16 Apr 2026 00:21:01 +0000 Subject: [PATCH 31/32] chore: retrigger CI after base change From 3f8a26e6ced84e0f3e4287d99c90219ded9971e4 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 16 Apr 2026 00:53:09 +0000 Subject: [PATCH 32/32] trigger