From 5127443bad68ddb2877956f2fe144d740cb7d1af Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 28 May 2026 18:28:39 -0400 Subject: [PATCH 1/3] feat: TUI Deposit/Withdraw form overhaul with picker-backed fields, async balances, and server preview --- Cargo.lock | 29 +- Cargo.toml | 5 +- src/tui/app.rs | 1346 +++++++++++++++++++++++++++++++++++++++++++++--- src/tui/mod.rs | 4 + src/tui/ui.rs | 543 ++++++++++++++++--- 5 files changed, 1775 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0cf1ad..d601e8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3049,7 +3049,7 @@ dependencies = [ [[package]] name = "nest-cli" -version = "0.2.2" +version = "0.2.3" dependencies = [ "alloy", "chrono", @@ -3062,11 +3062,14 @@ dependencies = [ "hex", "ratatui", "reqwest 0.13.2", + "semver 1.0.27", "serde", "serde_json", "sha2", "tar", + "throbber-widgets-tui", "tokio", + "tui-input", "wiremock", ] @@ -4786,6 +4789,15 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "throbber-widgets-tui" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e6941f74491a80911cb8821cb1f55f7e13bca867b28a2b14e5a1daaf691eb3" +dependencies = [ + "ratatui", +] + [[package]] name = "time" version = "0.3.47" @@ -5035,6 +5047,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-input" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd014a652e31cf25ea68d11b10a7b09549863449b19387505c9933f11eb05fa" +dependencies = [ + "ratatui", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -5092,9 +5115,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" diff --git a/Cargo.toml b/Cargo.toml index 4550e35..f8530ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nest-cli" -version = "0.2.2" +version = "0.2.3" edition = "2024" description = "CLI and TUI for Nest Vaults on Plume Network" license = "MIT" @@ -16,6 +16,8 @@ clap = { version = "4.5", features = ["derive", "env"] } # TUI ratatui = "0.30" crossterm = "0.29" +tui-input = "0.15" +throbber-widgets-tui = "0.11" # EVM / blockchain alloy = { version = "1.7", features = ["full", "sol-types"] } @@ -39,6 +41,7 @@ sha2 = "0.10" flate2 = "1" tar = "0.4" chrono = { version = "0.4", features = ["serde"] } +semver = "1" [dev-dependencies] wiremock = "0.6" diff --git a/src/tui/app.rs b/src/tui/app.rs index 3f17fe4..83112d2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,17 +1,24 @@ use std::time::{Duration, Instant}; use alloy::primitives::{Address, U256}; +use alloy::providers::Provider; use eyre::Result; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::widgets::TableState; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; +use crate::api::actions::EvmActionsClient; +use crate::api::actions_types::{ + InstantRedeemLiquidityRequest, MintBuildTxRequest, UserChainAssetRequest, +}; use crate::api::client::NestApiClient; -use crate::api::types::{HistoryPoint, VaultBasic, VaultDetailed, VaultType}; +use crate::api::types::{HistoryPoint, LiquidAsset, VaultBasic, VaultDetailed, VaultType}; use crate::chain::contracts::{Accountant, ERC20, NestVaultOft, RATE_DECIMALS}; use crate::chain::multicall::MulticallBuilder; use crate::chain::provider::build_provider; use crate::chain::signer::build_signer; -use crate::config::AppConfig; +use crate::config::{AppConfig, rpc_url_for_chain}; +use crate::tui::widgets::modal::{Modal, ModalAction}; // --------------------------------------------------------------------------- // Tab indices @@ -41,33 +48,220 @@ pub struct Position { // Form state for deposit / withdraw // --------------------------------------------------------------------------- +#[derive(Default, Debug, Clone)] +pub enum BalanceState { + #[default] + NotLoaded, + Loading, + Loaded { + raw: U256, + decimals: u8, + }, + Error(String), +} + +#[derive(Default, Debug, Clone)] +pub enum PreviewState { + #[default] + NotReady, + Loading, + Loaded { + share_amount: String, + share_decimals: u8, + }, + Error(String), +} + +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum DepositField { + #[default] + Vault, + Chain, + Asset, + Amount, + Submit, +} + +impl DepositField { + fn next(self) -> Self { + match self { + Self::Vault => Self::Chain, + Self::Chain => Self::Asset, + Self::Asset => Self::Amount, + Self::Amount => Self::Submit, + Self::Submit => Self::Vault, + } + } + fn prev(self) -> Self { + match self { + Self::Vault => Self::Submit, + Self::Chain => Self::Vault, + Self::Asset => Self::Chain, + Self::Amount => Self::Asset, + Self::Submit => Self::Amount, + } + } +} + +#[derive(Default)] pub struct DepositForm { - pub vault_slug: String, - pub chain: String, - pub amount: String, - pub active_field: usize, + pub vault: Option, + pub chain: Option, + pub asset: Option, + pub amount: tui_input::Input, + pub wallet_balance: BalanceState, + pub native_balance: BalanceState, + pub preview: PreviewState, + pub active_field: DepositField, pub status: String, + pub validation_err: Option, + /// When the amount last changed — used to debounce preview fetches. + pub last_amount_change: Option, + /// Set once a debounced preview has been spawned for the current amount. + pub preview_pending: bool, } -impl Default for DepositForm { - fn default() -> Self { - Self { - vault_slug: String::new(), - chain: "plume".to_string(), - amount: String::new(), - active_field: 0, - status: String::new(), +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum WithdrawField { + #[default] + Vault, + Chain, + Asset, + Mode, + Shares, + Submit, +} + +impl WithdrawField { + fn next(self) -> Self { + match self { + Self::Vault => Self::Chain, + Self::Chain => Self::Asset, + Self::Asset => Self::Mode, + Self::Mode => Self::Shares, + Self::Shares => Self::Submit, + Self::Submit => Self::Vault, + } + } + fn prev(self) -> Self { + match self { + Self::Vault => Self::Submit, + Self::Chain => Self::Vault, + Self::Asset => Self::Chain, + Self::Mode => Self::Asset, + Self::Shares => Self::Mode, + Self::Submit => Self::Shares, } } } #[derive(Default)] pub struct WithdrawForm { - pub vault_slug: String, - pub shares: String, - pub claim: bool, - pub active_field: usize, + pub vault: Option, + pub chain: Option, + pub redemption_asset: Option, + pub mode: Option, + pub shares: tui_input::Input, + /// User's vault share balance (in vault share decimals). + pub share_balance: BalanceState, + /// Instant-redeem liquidity available (in redemption asset decimals). + /// Zero ⇒ Instant mode disabled. + pub instant_liquidity: BalanceState, + /// Claimable shares (in vault share decimals). + /// Zero ⇒ Claim mode disabled. + pub claimable: BalanceState, + pub active_field: WithdrawField, pub status: String, + pub validation_err: Option, +} + +// --------------------------------------------------------------------------- +// Modal picker types (Part 2 — foundation for Parts 3 + 4) +// --------------------------------------------------------------------------- + +/// Which form a modal is feeding back into (drives the commit dispatch). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModalKind { + VaultPicker, + ChainPicker, + AssetPicker, + WithdrawMode, + /// Same as VaultPicker but restricted to vaults the user has positions in. + WithdrawVaultPicker, +} + +/// The discrete withdraw modes the user can pick from the Withdraw form. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WithdrawModeKind { + Request, + Instant, + Claim, +} + +/// Sum type of everything any of the pickers can produce. Large structs are +/// boxed so the enum stays small. +#[derive(Clone)] +pub enum ModalItem { + Vault(Box), + Chain(u64), + Asset(Box), + WithdrawMode(WithdrawModeKind), +} + +// --------------------------------------------------------------------------- +// Fetcher message channel — async tasks publish into here; the draw loop +// drains and applies them once per frame. +// --------------------------------------------------------------------------- + +pub enum FetchMsg { + DepositWalletBalance(BalanceState), + DepositNativeBalance(BalanceState), + DepositPreview(PreviewState), + WithdrawShareBalance(BalanceState), + WithdrawInstantLiquidity(BalanceState), + WithdrawClaimable(BalanceState), +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + Required, + NotNumeric, + ExceedsBalance, + Other(String), +} + +impl ValidationError { + pub fn message(&self) -> String { + match self { + Self::Required => "Required".to_string(), + Self::NotNumeric => "Must be a number".to_string(), + Self::ExceedsBalance => "Exceeds balance".to_string(), + Self::Other(s) => s.clone(), + } + } +} + +/// Parse a UI amount string and ensure it doesn't exceed `max_raw`. +pub fn validate_amount(input: &str, max_raw: U256, decimals: u8) -> Result { + if input.trim().is_empty() { + return Err(ValidationError::Required); + } + let raw = crate::util::parse_ui_amount(input, decimals).map_err(|e| { + let msg = e.to_string(); + if msg.contains("invalid") || msg.contains("amount") { + ValidationError::NotNumeric + } else { + ValidationError::Other(msg) + } + })?; + if raw > max_raw { + return Err(ValidationError::ExceedsBalance); + } + Ok(raw) } // --------------------------------------------------------------------------- @@ -110,6 +304,13 @@ pub struct App { pub is_loading: bool, pub last_refresh: Option, pub should_quit: bool, + + // Modal overlay (Parts 2-4). When `Some`, all keys route to the modal. + pub modal: Option<(ModalKind, Modal)>, + + // Async fetcher plumbing. + pub fetch_tx: UnboundedSender, + pub fetch_rx: UnboundedReceiver, } impl App { @@ -119,6 +320,8 @@ impl App { .as_deref() .and_then(|pk| build_signer(pk).ok().map(|s| format!("{:?}", s.address()))); + let (fetch_tx, fetch_rx) = unbounded_channel(); + let mut app = Self { cfg, active_tab: TAB_VAULTS, @@ -143,6 +346,9 @@ impl App { is_loading: false, last_refresh: None, should_quit: false, + modal: None, + fetch_tx, + fetch_rx, }; app.vaults_table_state.select(Some(0)); app.positions_table_state.select(Some(0)); @@ -399,6 +605,36 @@ impl App { self.is_loading = false; } + /// Drain any pending async fetch messages and apply them. + pub fn drain_fetches(&mut self) { + while let Ok(msg) = self.fetch_rx.try_recv() { + match msg { + FetchMsg::DepositWalletBalance(s) => self.deposit_form.wallet_balance = s, + FetchMsg::DepositNativeBalance(s) => self.deposit_form.native_balance = s, + FetchMsg::DepositPreview(s) => { + self.deposit_form.preview = s; + self.deposit_form.preview_pending = false; + } + FetchMsg::WithdrawShareBalance(s) => self.withdraw_form.share_balance = s, + FetchMsg::WithdrawInstantLiquidity(s) => self.withdraw_form.instant_liquidity = s, + FetchMsg::WithdrawClaimable(s) => self.withdraw_form.claimable = s, + } + } + + // Debounced preview kick-off (deposit tab). + if let Some(t) = self.deposit_form.last_amount_change + && !self.deposit_form.preview_pending + && t.elapsed() >= Duration::from_millis(300) + && self.deposit_form.vault.is_some() + && self.deposit_form.chain.is_some() + && self.deposit_form.asset.is_some() + && !self.deposit_form.amount.value().trim().is_empty() + { + self.deposit_form.last_amount_change = None; + self.spawn_deposit_preview(); + } + } + // -- Event handling ----------------------------------------------------- pub fn poll_event(&self) -> Result> { @@ -420,9 +656,28 @@ impl App { return self.handle_filter_key(key); } + // Modal overlay traps every key while open. Take ownership so we can + // freely mutate the rest of `self` (e.g. commit into the form state). + if let Some((kind, mut modal)) = self.modal.take() { + let action = modal.handle_key(key); + match action { + ModalAction::None => self.modal = Some((kind, modal)), + ModalAction::Cancelled => {} + ModalAction::Selected(item) => { + self.commit_modal_selection(kind, item).await; + } + } + return false; + } + // Global keys (always available, including inside forms). match key.code { - KeyCode::Char('q') => return true, + KeyCode::Char('q') + if !matches!(self.active_tab, TAB_DEPOSIT | TAB_WITHDRAW) + || !self.is_amount_field_active() => + { + return true; + } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return true, KeyCode::Tab => { self.active_tab = (self.active_tab + 1) % TAB_TITLES.len(); @@ -436,11 +691,17 @@ impl App { }; self.on_tab_switch().await; } - KeyCode::Char(c @ '1'..='5') => { + KeyCode::Char(c @ '1'..='5') + if !matches!(self.active_tab, TAB_DEPOSIT | TAB_WITHDRAW) + || !self.is_amount_field_active() => + { self.active_tab = (c as usize) - ('1' as usize); self.on_tab_switch().await; } - KeyCode::Char('r') => { + KeyCode::Char('r') + if !matches!(self.active_tab, TAB_DEPOSIT | TAB_WITHDRAW) + || !self.is_amount_field_active() => + { self.refresh_current_tab().await; } _ => { @@ -459,6 +720,98 @@ impl App { false } + /// Returns true when the cursor is in the deposit-amount or withdraw-shares + /// field, where character keys should go to the input widget instead of + /// being treated as global shortcuts. + fn is_amount_field_active(&self) -> bool { + match self.active_tab { + TAB_DEPOSIT => self.deposit_form.active_field == DepositField::Amount, + TAB_WITHDRAW => self.withdraw_form.active_field == WithdrawField::Shares, + _ => false, + } + } + + /// Apply a picker selection back into the right piece of state. + async fn commit_modal_selection(&mut self, kind: ModalKind, item: ModalItem) { + match (kind, item) { + (ModalKind::VaultPicker, ModalItem::Vault(v)) => { + let changed = self + .deposit_form + .vault + .as_ref() + .map(|cur| cur.basic.slug != v.basic.slug) + .unwrap_or(true); + self.deposit_form.vault = Some(*v); + if changed { + // Vault change resets downstream selections. + self.deposit_form.chain = None; + self.deposit_form.asset = None; + self.deposit_form.wallet_balance = BalanceState::NotLoaded; + self.deposit_form.native_balance = BalanceState::NotLoaded; + self.deposit_form.preview = PreviewState::NotReady; + self.deposit_form.validation_err = None; + } + } + (ModalKind::WithdrawVaultPicker, ModalItem::Vault(v)) => { + let changed = self + .withdraw_form + .vault + .as_ref() + .map(|cur| cur.basic.slug != v.basic.slug) + .unwrap_or(true); + self.withdraw_form.vault = Some(*v); + if changed { + self.withdraw_form.chain = None; + self.withdraw_form.redemption_asset = None; + self.withdraw_form.mode = None; + self.withdraw_form.share_balance = BalanceState::NotLoaded; + self.withdraw_form.instant_liquidity = BalanceState::NotLoaded; + self.withdraw_form.claimable = BalanceState::NotLoaded; + self.withdraw_form.validation_err = None; + } + } + (ModalKind::ChainPicker, ModalItem::Chain(c)) => { + match self.active_tab { + TAB_DEPOSIT => { + self.deposit_form.chain = Some(c); + // Reset asset (asset list depends on chain). + self.deposit_form.asset = None; + self.deposit_form.wallet_balance = BalanceState::NotLoaded; + self.deposit_form.native_balance = BalanceState::NotLoaded; + self.spawn_deposit_native_balance(); + } + TAB_WITHDRAW => { + self.withdraw_form.chain = Some(c); + self.withdraw_form.redemption_asset = None; + self.withdraw_form.mode = None; + self.withdraw_form.share_balance = BalanceState::NotLoaded; + self.withdraw_form.instant_liquidity = BalanceState::NotLoaded; + self.withdraw_form.claimable = BalanceState::NotLoaded; + self.spawn_withdraw_share_balance(); + } + _ => {} + } + } + (ModalKind::AssetPicker, ModalItem::Asset(a)) => match self.active_tab { + TAB_DEPOSIT => { + self.deposit_form.asset = Some(*a); + self.spawn_deposit_wallet_balance(); + self.maybe_trigger_preview_debounce(); + } + TAB_WITHDRAW => { + self.withdraw_form.redemption_asset = Some(*a); + self.spawn_withdraw_instant_liquidity(); + self.spawn_withdraw_claimable(); + } + _ => {} + }, + (ModalKind::WithdrawMode, ModalItem::WithdrawMode(m)) => { + self.withdraw_form.mode = Some(m); + } + _ => {} + } + } + async fn on_tab_switch(&mut self) { match self.active_tab { TAB_VAULTS if self.vaults.is_empty() => self.load_vaults().await, @@ -495,7 +848,9 @@ impl App { if let Some(sel) = self.vaults_table_state.selected() { let filtered = self.filtered_vaults(); if let Some(v) = filtered.get(sel) { - self.deposit_form.vault_slug = v.basic.slug.clone(); + let item = ModalItem::Vault(Box::new((*v).clone())); + self.commit_modal_selection(ModalKind::VaultPicker, item) + .await; self.active_tab = TAB_DEPOSIT; } } @@ -532,8 +887,16 @@ impl App { KeyCode::Char('w') => { if let Some(sel) = self.positions_table_state.selected() && let Some(p) = self.positions.get(sel) + && let Some(v) = self + .vaults + .iter() + .find(|vd| vd.basic.slug == p.vault_slug) + .cloned() { - self.withdraw_form.vault_slug = p.vault_slug.clone(); + self.withdraw_form.vault = Some(v); + self.withdraw_form.chain = None; + self.withdraw_form.redemption_asset = None; + self.withdraw_form.mode = None; self.active_tab = TAB_WITHDRAW; } } @@ -585,89 +948,587 @@ impl App { false } + // ===== Deposit form ===== + fn handle_deposit_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { + use tui_input::backend::crossterm::EventHandler; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { self.active_tab = TAB_VAULTS; } - KeyCode::Down => { - self.deposit_form.active_field = (self.deposit_form.active_field + 1) % 3; + (KeyCode::Down, _) => { + self.deposit_form.active_field = self.deposit_form.active_field.next(); } - KeyCode::Up => { - self.deposit_form.active_field = if self.deposit_form.active_field == 0 { - 2 - } else { - self.deposit_form.active_field - 1 - }; + (KeyCode::Up, _) => { + self.deposit_form.active_field = self.deposit_form.active_field.prev(); } - KeyCode::Backspace => { - let field = self.active_deposit_field_mut(); - field.pop(); + (KeyCode::Char('m'), m) if m.contains(KeyModifiers::CONTROL) => { + if self.deposit_form.active_field == DepositField::Amount + && let BalanceState::Loaded { raw, decimals } = + self.deposit_form.wallet_balance.clone() + { + let s = format_raw_amount(raw, decimals); + self.deposit_form.amount = tui_input::Input::new(s); + self.deposit_form.last_amount_change = Some(Instant::now()); + self.deposit_form.validation_err = None; + } } - KeyCode::Char(c) => { - let field = self.active_deposit_field_mut(); - field.push(c); + (KeyCode::Enter, _) => match self.deposit_form.active_field { + DepositField::Vault => self.open_vault_picker(false), + DepositField::Chain => self.open_deposit_chain_picker(), + DepositField::Asset => self.open_deposit_asset_picker(), + DepositField::Amount => {} // typing field, Enter is no-op + DepositField::Submit => self.submit_deposit(), + }, + _ => { + if self.deposit_form.active_field == DepositField::Amount { + let before = self.deposit_form.amount.value().to_string(); + self.deposit_form.amount.handle_event(&Event::Key(key)); + if self.deposit_form.amount.value() != before { + self.deposit_form.last_amount_change = Some(Instant::now()); + self.deposit_form.preview = PreviewState::NotReady; + self.deposit_form.validation_err = + self.validate_deposit_amount().err().map(|e| e.message()); + } + } } - KeyCode::Enter => { - self.deposit_form.status = - "Use CLI: nest deposit --vault-slug --amount --chain " - .to_string(); + } + } + + fn validate_deposit_amount(&self) -> Result { + let decimals = self + .deposit_form + .asset + .as_ref() + .map(|a| a.decimals) + .unwrap_or(18); + let max_raw = match &self.deposit_form.wallet_balance { + BalanceState::Loaded { raw, .. } => *raw, + _ => U256::MAX, // don't gate on balance if not yet loaded + }; + validate_amount(self.deposit_form.amount.value(), max_raw, decimals) + } + + fn open_vault_picker(&mut self, withdraw: bool) { + if self.vaults.is_empty() { + self.status_message = "No vaults loaded yet — wait for the Vaults tab".to_string(); + return; + } + let items: Vec = if withdraw { + let slugs: std::collections::HashSet<_> = self + .positions + .iter() + .map(|p| p.vault_slug.clone()) + .collect(); + self.vaults + .iter() + .filter(|v| slugs.contains(&v.basic.slug)) + .map(|v| ModalItem::Vault(Box::new(v.clone()))) + .collect() + } else { + self.vaults + .iter() + .map(|v| ModalItem::Vault(Box::new(v.clone()))) + .collect() + }; + if items.is_empty() { + self.status_message = "No Nest positions — deposit first".to_string(); + return; + } + let modal = Modal::new("Select vault", items, |item| match item { + ModalItem::Vault(v) => { + use ratatui::text::Span; + let badge = format!(" [{:?}] ", v.basic.vault_type).to_lowercase(); + let apy = v + .apy + .as_ref() + .and_then(|a| a.rolling_7d) + .map(|p| format!("{:.2}% 7d", p * 100.0)) + .unwrap_or_else(|| "-".to_string()); + ratatui::text::Line::from(vec![ + Span::raw(v.basic.name.clone()), + Span::raw(" "), + Span::raw(badge), + Span::raw(" "), + Span::raw(apy), + ]) } - _ => {} + _ => ratatui::text::Line::from(""), + }); + let kind = if withdraw { + ModalKind::WithdrawVaultPicker + } else { + ModalKind::VaultPicker + }; + self.modal = Some((kind, modal)); + } + + fn open_deposit_chain_picker(&mut self) { + let Some(vault) = self.deposit_form.vault.clone() else { + self.status_message = "Select a vault first".to_string(); + return; + }; + let chain_ids = chain_ids_for_vault(&vault.basic); + if chain_ids.is_empty() { + self.status_message = "Vault has no supported chains".to_string(); + return; + } + let items: Vec = chain_ids.iter().map(|&c| ModalItem::Chain(c)).collect(); + let modal = Modal::new("Select chain", items, |item| match item { + ModalItem::Chain(c) => { + let name = crate::config::chain_config(*c) + .map(|cfg| cfg.name) + .unwrap_or("?"); + ratatui::text::Line::from(format!("{name} ({c})")) + } + _ => ratatui::text::Line::from(""), + }); + self.modal = Some((ModalKind::ChainPicker, modal)); + } + + fn open_deposit_asset_picker(&mut self) { + let Some(vault) = self.deposit_form.vault.clone() else { + self.status_message = "Select a vault first".to_string(); + return; + }; + let Some(chain) = self.deposit_form.chain else { + self.status_message = "Select a chain first".to_string(); + return; + }; + let assets: Vec = vault + .basic + .liquid_assets + .iter() + .filter(|a| a.chain_id == chain) + .cloned() + .collect(); + if assets.is_empty() { + self.status_message = format!("No liquid assets for chain {chain}"); + return; } + let items: Vec = assets + .into_iter() + .map(|a| ModalItem::Asset(Box::new(a))) + .collect(); + let modal = Modal::new("Select asset", items, |item| match item { + ModalItem::Asset(a) => ratatui::text::Line::from(format!( + "{} {}", + a.symbol, + short_addr(&a.contract_address) + )), + _ => ratatui::text::Line::from(""), + }); + self.modal = Some((ModalKind::AssetPicker, modal)); } - fn active_deposit_field_mut(&mut self) -> &mut String { - match self.deposit_form.active_field { - 0 => &mut self.deposit_form.vault_slug, - 1 => &mut self.deposit_form.chain, - 2 => &mut self.deposit_form.amount, - _ => &mut self.deposit_form.vault_slug, + fn submit_deposit(&mut self) { + let Some(ref vault) = self.deposit_form.vault else { + self.deposit_form.status = "Pick a vault first".to_string(); + return; + }; + let Some(chain) = self.deposit_form.chain else { + self.deposit_form.status = "Pick a chain first".to_string(); + return; + }; + let Some(ref asset) = self.deposit_form.asset else { + self.deposit_form.status = "Pick an asset first".to_string(); + return; + }; + if let Err(e) = self.validate_deposit_amount() { + self.deposit_form.validation_err = Some(e.message()); + self.deposit_form.status = format!("Invalid amount: {}", e.message()); + return; } + let chain_name = crate::config::chain_config(chain) + .map(|c| c.name.to_lowercase()) + .unwrap_or_else(|| chain.to_string()); + self.deposit_form.status = format!( + "Run: nest deposit --vault {} --asset {} --amount {} --chain {}", + vault.basic.slug, + asset.contract_address, + self.deposit_form.amount.value(), + chain_name + ); } + // ===== Withdraw form ===== + fn handle_withdraw_key(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { + use tui_input::backend::crossterm::EventHandler; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { self.active_tab = TAB_PORTFOLIO; } - KeyCode::Down => { - self.withdraw_form.active_field = (self.withdraw_form.active_field + 1) % 3; + (KeyCode::Down, _) => { + self.withdraw_form.active_field = self.withdraw_form.active_field.next(); } - KeyCode::Up => { - self.withdraw_form.active_field = if self.withdraw_form.active_field == 0 { - 2 - } else { - self.withdraw_form.active_field - 1 - }; + (KeyCode::Up, _) => { + self.withdraw_form.active_field = self.withdraw_form.active_field.prev(); } - KeyCode::Backspace => { - let field = self.active_withdraw_field_mut(); - field.pop(); + (KeyCode::Char('m'), m) if m.contains(KeyModifiers::CONTROL) => { + if self.withdraw_form.active_field == WithdrawField::Shares { + let cap = match self.withdraw_form.mode { + Some(WithdrawModeKind::Claim) => self.withdraw_form.claimable.clone(), + _ => self.withdraw_form.share_balance.clone(), + }; + if let BalanceState::Loaded { raw, decimals } = cap { + let s = format_raw_amount(raw, decimals); + self.withdraw_form.shares = tui_input::Input::new(s); + self.withdraw_form.validation_err = None; + } + } } - KeyCode::Char('c') if self.withdraw_form.active_field == 2 => { - self.withdraw_form.claim = !self.withdraw_form.claim; + (KeyCode::Enter, _) => match self.withdraw_form.active_field { + WithdrawField::Vault => self.open_vault_picker(true), + WithdrawField::Chain => self.open_withdraw_chain_picker(), + WithdrawField::Asset => self.open_withdraw_asset_picker(), + WithdrawField::Mode => self.open_withdraw_mode_picker(), + WithdrawField::Shares => {} + WithdrawField::Submit => self.submit_withdraw(), + }, + _ => { + if self.withdraw_form.active_field == WithdrawField::Shares { + let before = self.withdraw_form.shares.value().to_string(); + self.withdraw_form.shares.handle_event(&Event::Key(key)); + if self.withdraw_form.shares.value() != before { + self.withdraw_form.validation_err = + self.validate_withdraw_shares().err().map(|e| e.message()); + } + } } - KeyCode::Char(c) => { - let field = self.active_withdraw_field_mut(); - field.push(c); + } + } + + fn validate_withdraw_shares(&self) -> Result { + let decimals = self + .withdraw_form + .vault + .as_ref() + .map(|v| v.basic.decimals) + .unwrap_or(18); + let cap = match self.withdraw_form.mode { + Some(WithdrawModeKind::Claim) => match &self.withdraw_form.claimable { + BalanceState::Loaded { raw, .. } => *raw, + _ => U256::MAX, + }, + _ => match &self.withdraw_form.share_balance { + BalanceState::Loaded { raw, .. } => *raw, + _ => U256::MAX, + }, + }; + validate_amount(self.withdraw_form.shares.value(), cap, decimals) + } + + fn open_withdraw_chain_picker(&mut self) { + let Some(vault) = self.withdraw_form.vault.clone() else { + self.status_message = "Select a vault first".to_string(); + return; + }; + let chain_ids = chain_ids_for_vault(&vault.basic); + if chain_ids.is_empty() { + self.status_message = "Vault has no supported chains".to_string(); + return; + } + let items: Vec = chain_ids.iter().map(|&c| ModalItem::Chain(c)).collect(); + let modal = Modal::new("Select chain", items, |item| match item { + ModalItem::Chain(c) => { + let name = crate::config::chain_config(*c) + .map(|cfg| cfg.name) + .unwrap_or("?"); + ratatui::text::Line::from(format!("{name} ({c})")) } - KeyCode::Enter => { - self.withdraw_form.status = - "Use CLI: nest withdraw --vault-slug --shares ".to_string(); + _ => ratatui::text::Line::from(""), + }); + self.modal = Some((ModalKind::ChainPicker, modal)); + } + + fn open_withdraw_asset_picker(&mut self) { + let Some(vault) = self.withdraw_form.vault.clone() else { + self.status_message = "Select a vault first".to_string(); + return; + }; + let Some(chain) = self.withdraw_form.chain else { + self.status_message = "Select a chain first".to_string(); + return; + }; + let assets: Vec = vault + .basic + .liquid_assets + .iter() + .filter(|a| a.chain_id == chain) + .cloned() + .collect(); + if assets.is_empty() { + self.status_message = format!("No liquid assets for chain {chain}"); + return; + } + let items: Vec = assets + .into_iter() + .map(|a| ModalItem::Asset(Box::new(a))) + .collect(); + let modal = Modal::new("Select redemption asset", items, |item| match item { + ModalItem::Asset(a) => ratatui::text::Line::from(format!( + "{} {}", + a.symbol, + short_addr(&a.contract_address) + )), + _ => ratatui::text::Line::from(""), + }); + self.modal = Some((ModalKind::AssetPicker, modal)); + } + + fn open_withdraw_mode_picker(&mut self) { + // Gates require asset to be picked. + if self.withdraw_form.redemption_asset.is_none() { + self.status_message = "Select an asset first".to_string(); + return; + } + let mut items: Vec = vec![ModalItem::WithdrawMode(WithdrawModeKind::Request)]; + let liq_nonzero = matches!( + self.withdraw_form.instant_liquidity, + BalanceState::Loaded { ref raw, .. } if !raw.is_zero() + ); + let claim_nonzero = matches!( + self.withdraw_form.claimable, + BalanceState::Loaded { ref raw, .. } if !raw.is_zero() + ); + if liq_nonzero { + items.push(ModalItem::WithdrawMode(WithdrawModeKind::Instant)); + } + if claim_nonzero { + items.push(ModalItem::WithdrawMode(WithdrawModeKind::Claim)); + } + let modal = Modal::new("Select mode", items, |item| match item { + ModalItem::WithdrawMode(m) => { + let s = match m { + WithdrawModeKind::Request => "Request (2-phase, cooldown)", + WithdrawModeKind::Instant => "Instant (solver-fulfilled)", + WithdrawModeKind::Claim => "Claim (after cooldown)", + }; + ratatui::text::Line::from(s) } - _ => {} + _ => ratatui::text::Line::from(""), + }); + self.modal = Some((ModalKind::WithdrawMode, modal)); + } + + fn submit_withdraw(&mut self) { + let Some(ref vault) = self.withdraw_form.vault else { + self.withdraw_form.status = "Pick a vault first".to_string(); + return; + }; + let Some(chain) = self.withdraw_form.chain else { + self.withdraw_form.status = "Pick a chain first".to_string(); + return; + }; + let Some(ref asset) = self.withdraw_form.redemption_asset else { + self.withdraw_form.status = "Pick a redemption asset first".to_string(); + return; + }; + let Some(mode) = self.withdraw_form.mode else { + self.withdraw_form.status = "Pick a mode first".to_string(); + return; + }; + if let Err(e) = self.validate_withdraw_shares() { + self.withdraw_form.validation_err = Some(e.message()); + self.withdraw_form.status = format!("Invalid shares: {}", e.message()); + return; } + let chain_name = crate::config::chain_config(chain) + .map(|c| c.name.to_lowercase()) + .unwrap_or_else(|| chain.to_string()); + let cmd = match mode { + WithdrawModeKind::Request => format!( + "Run: nest withdraw --vault {} --shares {} --redemption-asset {} --chain {}", + vault.basic.slug, + self.withdraw_form.shares.value(), + asset.contract_address, + chain_name, + ), + WithdrawModeKind::Instant => format!( + "Run: nest instant-redeem submit --vault {} --shares {} --redemption-asset {} --chain {}", + vault.basic.slug, + self.withdraw_form.shares.value(), + asset.contract_address, + chain_name, + ), + WithdrawModeKind::Claim => format!( + "Run: nest claim submit --vault {} --redemption-asset {} --chain {}", + vault.basic.slug, asset.contract_address, chain_name, + ), + }; + self.withdraw_form.status = cmd; } - fn active_withdraw_field_mut(&mut self) -> &mut String { - match self.withdraw_form.active_field { - 0 => &mut self.withdraw_form.vault_slug, - 1 => &mut self.withdraw_form.shares, - _ => &mut self.withdraw_form.vault_slug, // claim is toggled separately + // ===== Async fetchers ===== + + fn maybe_trigger_preview_debounce(&mut self) { + if !self.deposit_form.amount.value().trim().is_empty() { + self.deposit_form.last_amount_change = Some(Instant::now()); } } + fn wallet_address(&self) -> Option
{ + self.wallet_address.as_ref().and_then(|a| a.parse().ok()) + } + + fn spawn_deposit_wallet_balance(&mut self) { + let (Some(asset), Some(chain), Some(wallet)) = ( + self.deposit_form.asset.clone(), + self.deposit_form.chain, + self.wallet_address(), + ) else { + return; + }; + self.deposit_form.wallet_balance = BalanceState::Loading; + let tx = self.fetch_tx.clone(); + tokio::spawn(async move { + let result = + fetch_erc20_balance(chain, &asset.contract_address, wallet, asset.decimals).await; + let _ = tx.send(FetchMsg::DepositWalletBalance(result)); + }); + } + + fn spawn_deposit_native_balance(&mut self) { + let (Some(chain), Some(wallet)) = (self.deposit_form.chain, self.wallet_address()) else { + return; + }; + self.deposit_form.native_balance = BalanceState::Loading; + let tx = self.fetch_tx.clone(); + tokio::spawn(async move { + let result = fetch_native_balance(chain, wallet).await; + let _ = tx.send(FetchMsg::DepositNativeBalance(result)); + }); + } + + fn spawn_deposit_preview(&mut self) { + let (Some(vault), Some(chain), Some(asset), Some(wallet)) = ( + self.deposit_form.vault.clone(), + self.deposit_form.chain, + self.deposit_form.asset.clone(), + self.wallet_address(), + ) else { + return; + }; + let amount_str = self.deposit_form.amount.value().to_string(); + let raw = match crate::util::parse_ui_amount(&amount_str, asset.decimals) { + Ok(r) => r, + Err(e) => { + self.deposit_form.preview = PreviewState::Error(e.to_string()); + return; + } + }; + self.deposit_form.preview = PreviewState::Loading; + self.deposit_form.preview_pending = true; + let tx = self.fetch_tx.clone(); + let actions_url = self.cfg.evm_actions_api_url.clone(); + tokio::spawn(async move { + let client = EvmActionsClient::new(&actions_url); + let req = MintBuildTxRequest { + deposit_asset: asset.contract_address.clone(), + deposit_amount: raw.to_string(), + chain_id: chain, + recipient: format!("{wallet:?}"), + skip_simulation: Some(true), + }; + let state = match client.mint_build_tx(&vault.basic.slug, &req).await { + Ok(bundle) => match (bundle.share_amount, bundle.share_decimals) { + (Some(amt), Some(dec)) => PreviewState::Loaded { + share_amount: amt, + share_decimals: dec, + }, + _ => PreviewState::Error("API response missing shareAmount".to_string()), + }, + Err(e) => PreviewState::Error(e.to_string()), + }; + let _ = tx.send(FetchMsg::DepositPreview(state)); + }); + } + + fn spawn_withdraw_share_balance(&mut self) { + let (Some(vault), Some(_chain), Some(wallet)) = ( + self.withdraw_form.vault.clone(), + self.withdraw_form.chain, + self.wallet_address(), + ) else { + return; + }; + let vault_addr = vault.basic.vault_address.clone(); + let decimals = vault.basic.decimals; + let rpc = self.cfg.rpc_url.clone(); + self.withdraw_form.share_balance = BalanceState::Loading; + let tx = self.fetch_tx.clone(); + tokio::spawn(async move { + // Share balance is always on the vault's chain (Plume) for Nest vaults. + let state = fetch_erc20_balance_with_rpc(&rpc, &vault_addr, wallet, decimals).await; + let _ = tx.send(FetchMsg::WithdrawShareBalance(state)); + }); + } + + fn spawn_withdraw_instant_liquidity(&mut self) { + let (Some(vault), Some(chain), Some(asset)) = ( + self.withdraw_form.vault.clone(), + self.withdraw_form.chain, + self.withdraw_form.redemption_asset.clone(), + ) else { + return; + }; + self.withdraw_form.instant_liquidity = BalanceState::Loading; + let tx = self.fetch_tx.clone(); + let actions_url = self.cfg.evm_actions_api_url.clone(); + tokio::spawn(async move { + let client = EvmActionsClient::new(&actions_url); + let req = InstantRedeemLiquidityRequest { + redemption_asset: asset.contract_address.clone(), + chain_id: chain, + }; + let state = match client + .instant_redeem_liquidity(&vault.basic.slug, &req) + .await + { + Ok(liq) => match U256::from_str_radix(&liq.liquidity, 10) { + Ok(raw) => BalanceState::Loaded { + raw, + decimals: liq.redemption_decimals, + }, + Err(e) => BalanceState::Error(format!("parse liquidity: {e}")), + }, + Err(e) => BalanceState::Error(e.to_string()), + }; + let _ = tx.send(FetchMsg::WithdrawInstantLiquidity(state)); + }); + } + + fn spawn_withdraw_claimable(&mut self) { + let (Some(vault), Some(chain), Some(asset), Some(wallet)) = ( + self.withdraw_form.vault.clone(), + self.withdraw_form.chain, + self.withdraw_form.redemption_asset.clone(), + self.wallet_address(), + ) else { + return; + }; + let decimals = vault.basic.decimals; + self.withdraw_form.claimable = BalanceState::Loading; + let tx = self.fetch_tx.clone(); + let actions_url = self.cfg.evm_actions_api_url.clone(); + tokio::spawn(async move { + let client = EvmActionsClient::new(&actions_url); + let req = UserChainAssetRequest { + redemption_asset: asset.contract_address.clone(), + chain_id: chain, + user: format!("{wallet:?}"), + }; + let state = match client.claim_pending(&vault.basic.slug, &req).await { + Ok(v) => parse_claimable_shares(&v, decimals), + Err(e) => BalanceState::Error(e.to_string()), + }; + let _ = tx.send(FetchMsg::WithdrawClaimable(state)); + }); + } + /// Auto-refresh check (every 60s). pub fn should_auto_refresh(&self) -> bool { self.last_refresh @@ -724,3 +1585,334 @@ fn u256_to_f64(val: U256, decimals: u8) -> f64 { val.to_string().parse::().unwrap_or(0.0) / divisor } } + +/// Shorten an address `0xabcdef…1234` for display. +pub fn short_addr(addr: &str) -> String { + if addr.len() > 12 { + format!("{}…{}", &addr[..6], &addr[addr.len() - 4..]) + } else { + addr.to_string() + } +} + +/// Format raw token units as a UI decimal string with up to `decimals` places, +/// trimming trailing zeros. +pub fn format_raw_amount(raw: U256, decimals: u8) -> String { + let d = decimals as usize; + if d == 0 { + return raw.to_string(); + } + let s = raw.to_string(); + let (whole, frac) = if s.len() <= d { + let zeros = "0".repeat(d - s.len()); + ("0".to_string(), format!("{zeros}{s}")) + } else { + let split = s.len() - d; + (s[..split].to_string(), s[split..].to_string()) + }; + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + whole + } else { + format!("{whole}.{frac}") + } +} + +/// Resolve the chain IDs supported by a vault, using the chain-name slugs from +/// the API and the central CHAINS table to map them to numeric IDs. +fn chain_ids_for_vault(vault: &VaultBasic) -> Vec { + let mut ids: Vec = vault + .chain + .iter() + .filter_map(|name| crate::config::resolve_chain_id(name).ok()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids +} + +async fn fetch_erc20_balance( + chain_id: u64, + asset_addr: &str, + wallet: Address, + decimals: u8, +) -> BalanceState { + let rpc = match rpc_url_for_chain(chain_id) { + Ok(r) => r, + Err(e) => return BalanceState::Error(e.to_string()), + }; + fetch_erc20_balance_with_rpc(&rpc, asset_addr, wallet, decimals).await +} + +async fn fetch_erc20_balance_with_rpc( + rpc: &str, + asset_addr: &str, + wallet: Address, + decimals: u8, +) -> BalanceState { + let asset: Address = match asset_addr.parse() { + Ok(a) => a, + Err(_) => return BalanceState::Error("invalid asset address".to_string()), + }; + let provider = build_provider(rpc); + let mut mc = MulticallBuilder::new(); + let idx = mc.add_call(asset, &ERC20::balanceOfCall { account: wallet }); + match mc.execute(&provider).await { + Ok(results) => match results + .get(idx) + .and_then(|r| r.decode::()) + { + Some(raw) => BalanceState::Loaded { raw, decimals }, + None => BalanceState::Error("decoded balance failed".to_string()), + }, + Err(e) => BalanceState::Error(e.to_string()), + } +} + +async fn fetch_native_balance(chain_id: u64, wallet: Address) -> BalanceState { + let rpc = match rpc_url_for_chain(chain_id) { + Ok(r) => r, + Err(e) => return BalanceState::Error(e.to_string()), + }; + let provider = build_provider(&rpc); + match provider.get_balance(wallet).await { + Ok(raw) => BalanceState::Loaded { raw, decimals: 18 }, + Err(e) => BalanceState::Error(e.to_string()), + } +} + +/// Extract the user's claimable share total from the API claim/pending body. +/// The API returns route-specific JSON; we look for the most common field. +fn parse_claimable_shares(v: &serde_json::Value, decimals: u8) -> BalanceState { + // Try direct field names that have shown up in API responses. + for key in ["claimableShares", "claimable", "shareAmount", "amount"] { + if let Some(s) = v.get(key).and_then(|x| x.as_str()) + && let Ok(raw) = U256::from_str_radix(s, 10) + { + return BalanceState::Loaded { raw, decimals }; + } + if let Some(n) = v.get(key).and_then(|x| x.as_u64()) { + return BalanceState::Loaded { + raw: U256::from(n), + decimals, + }; + } + } + BalanceState::Loaded { + raw: U256::ZERO, + decimals, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn mk_app() -> App { + let cfg = AppConfig { + rpc_url: "http://127.0.0.1:0".to_string(), + api_url: "http://127.0.0.1:0".to_string(), + evm_actions_api_url: "http://127.0.0.1:0".to_string(), + private_key: None, + output_format: crate::cli::OutputFormat::Table, + dry_run: false, + }; + App::new(cfg) + } + + fn mk_vault(slug: &str) -> VaultDetailed { + VaultDetailed { + basic: VaultBasic { + slug: slug.to_string(), + name: format!("Vault {slug}"), + symbol: "V".to_string(), + vault_type: VaultType::Nest, + vault_address: "0x0000000000000000000000000000000000000001".to_string(), + decimals: 18, + chain: vec!["plume".to_string()], + liquid_assets: vec![LiquidAsset { + symbol: "USDC".to_string(), + contract_address: "0x0000000000000000000000000000000000000002".to_string(), + chain_id: 98866, + decimals: 6, + }], + teller_contract_address: None, + accountant_address: None, + nest_share_oft_address: None, + nest_vaults: vec![], + nest_composers: vec![], + }, + tvl: None, + token_price: None, + num_holders: None, + volume_24h: None, + apy: None, + nav_apy: None, + } + } + + #[test] + fn validate_amount_required() { + let err = validate_amount("", U256::from(1000u64), 6).unwrap_err(); + assert_eq!(err, ValidationError::Required); + let err2 = validate_amount(" ", U256::from(1000u64), 6).unwrap_err(); + assert_eq!(err2, ValidationError::Required); + } + + #[test] + fn validate_amount_garbage() { + let err = validate_amount("abc", U256::from(1000u64), 6).unwrap_err(); + assert!(matches!(err, ValidationError::NotNumeric)); + } + + #[test] + fn validate_amount_exceeds() { + // 1 USDC at 6 decimals → 1_000_000 raw, cap at 500_000. + let err = validate_amount("1", U256::from(500_000u64), 6).unwrap_err(); + assert_eq!(err, ValidationError::ExceedsBalance); + } + + #[test] + fn validate_amount_valid() { + let v = validate_amount("0.5", U256::from(1_000_000u64), 6).unwrap(); + assert_eq!(v, U256::from(500_000u64)); + } + + #[test] + fn balance_state_default() { + let s = BalanceState::default(); + assert!(matches!(s, BalanceState::NotLoaded)); + } + + #[test] + fn preview_state_default() { + let s = PreviewState::default(); + assert!(matches!(s, PreviewState::NotReady)); + } + + #[test] + fn format_raw_amount_zero_decimals() { + assert_eq!(format_raw_amount(U256::from(42u64), 0), "42"); + } + + #[test] + fn format_raw_amount_six_decimals() { + assert_eq!(format_raw_amount(U256::from(1_500_000u64), 6), "1.5"); + } + + #[test] + fn format_raw_amount_eighteen_decimals_subdollar() { + // 0.0001 in 18 decimals → 1e14 + let raw = U256::from(100_000_000_000_000u64); + assert_eq!(format_raw_amount(raw, 18), "0.0001"); + } + + #[test] + fn short_addr_basic() { + assert_eq!( + short_addr("0x1234567890abcdef1234567890abcdef12345678"), + "0x1234…5678" + ); + // Already short. + assert_eq!(short_addr("0xshort"), "0xshort"); + } + + #[tokio::test] + async fn commit_modal_vault_picker_sets_form() { + let mut app = mk_app(); + let v = mk_vault("foo"); + app.commit_modal_selection(ModalKind::VaultPicker, ModalItem::Vault(Box::new(v))) + .await; + assert!(app.deposit_form.vault.is_some()); + assert_eq!(app.deposit_form.vault.as_ref().unwrap().basic.slug, "foo"); + } + + #[tokio::test] + async fn commit_modal_vault_resets_downstream() { + let mut app = mk_app(); + let v1 = mk_vault("foo"); + let v2 = mk_vault("bar"); + app.commit_modal_selection(ModalKind::VaultPicker, ModalItem::Vault(Box::new(v1))) + .await; + // Manually fill downstream — simulate state that should be cleared. + app.deposit_form.chain = Some(98866); + app.deposit_form.asset = Some(LiquidAsset { + symbol: "X".to_string(), + contract_address: "0xab".to_string(), + chain_id: 98866, + decimals: 6, + }); + app.commit_modal_selection(ModalKind::VaultPicker, ModalItem::Vault(Box::new(v2))) + .await; + assert_eq!(app.deposit_form.chain, None); + assert!(app.deposit_form.asset.is_none()); + assert!(matches!( + app.deposit_form.wallet_balance, + BalanceState::NotLoaded + )); + } + + #[tokio::test] + async fn commit_modal_chain_picker_deposit_triggers_native_balance_loading() { + let mut app = mk_app(); + // Need a wallet address; fabricate one. + app.wallet_address = Some("0x0000000000000000000000000000000000000abc".to_string()); + app.active_tab = TAB_DEPOSIT; + // Pick vault first so chain is allowed. + app.commit_modal_selection( + ModalKind::VaultPicker, + ModalItem::Vault(Box::new(mk_vault("foo"))), + ) + .await; + // Now pick a chain that's a real one (98866 = plume). + app.commit_modal_selection(ModalKind::ChainPicker, ModalItem::Chain(98866)) + .await; + assert_eq!(app.deposit_form.chain, Some(98866)); + // Native balance fetch was kicked off. + assert!(matches!( + app.deposit_form.native_balance, + BalanceState::Loading + )); + } + + #[tokio::test] + async fn commit_modal_asset_picker_deposit_triggers_wallet_balance_loading() { + let mut app = mk_app(); + app.wallet_address = Some("0x0000000000000000000000000000000000000abc".to_string()); + app.active_tab = TAB_DEPOSIT; + app.commit_modal_selection( + ModalKind::VaultPicker, + ModalItem::Vault(Box::new(mk_vault("foo"))), + ) + .await; + app.deposit_form.chain = Some(98866); + let asset = LiquidAsset { + symbol: "USDC".to_string(), + contract_address: "0x0000000000000000000000000000000000000abc".to_string(), + chain_id: 98866, + decimals: 6, + }; + app.commit_modal_selection(ModalKind::AssetPicker, ModalItem::Asset(Box::new(asset))) + .await; + assert!(app.deposit_form.asset.is_some()); + assert!(matches!( + app.deposit_form.wallet_balance, + BalanceState::Loading + )); + } + + #[tokio::test] + async fn commit_modal_withdraw_mode() { + let mut app = mk_app(); + app.commit_modal_selection( + ModalKind::WithdrawMode, + ModalItem::WithdrawMode(WithdrawModeKind::Instant), + ) + .await; + assert_eq!(app.withdraw_form.mode, Some(WithdrawModeKind::Instant)); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 02d1aab..34fd0af 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,6 @@ mod app; mod ui; +mod widgets; use eyre::Result; use ratatui::crossterm::event::Event; @@ -20,6 +21,9 @@ async fn run_app(terminal: &mut ratatui::DefaultTerminal, cfg: &AppConfig) -> Re app.load_vaults().await; loop { + // Apply any pending async fetch results before drawing. + app.drain_fetches(); + terminal.draw(|frame| ui::render(frame, &mut app))?; if let Some(Event::Key(key)) = app.poll_event()? diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 6ac3487..6d68e4c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -3,9 +3,12 @@ use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Cell, Paragraph, Row, Sparkline, Table, Tabs}; +use throbber_widgets_tui::Throbber; use super::app::{ - App, TAB_DEPOSIT, TAB_HISTORY, TAB_PORTFOLIO, TAB_TITLES, TAB_VAULTS, TAB_WITHDRAW, + App, BalanceState, DepositField, PreviewState, TAB_DEPOSIT, TAB_HISTORY, TAB_PORTFOLIO, + TAB_TITLES, TAB_VAULTS, TAB_WITHDRAW, WithdrawField, WithdrawModeKind, format_raw_amount, + short_addr, }; pub fn render(frame: &mut Frame, app: &mut App) { @@ -39,6 +42,11 @@ pub fn render(frame: &mut Frame, app: &mut App) { _ => {} } + // -- Modal overlay (layered on top of content) -- + if let Some((_, modal)) = app.modal.as_mut() { + modal.render(frame, content_area); + } + // -- Status bar -- let status_text = if app.filter_mode { format!(" Filter: {}█ | Esc: cancel Enter: apply", app.vault_filter) @@ -263,45 +271,80 @@ fn render_deposit_form(frame: &mut Frame, app: &mut App, area: ratatui::layout:: let [form_area, status_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(area); - let fields = [ - ("Vault Slug", &app.deposit_form.vault_slug, 0), - ("Chain", &app.deposit_form.chain, 1), - ("Amount", &app.deposit_form.amount, 2), + let active = app.deposit_form.active_field; + let mut lines: Vec = vec![ + field_row( + "Vault", + deposit_vault_value(app), + active == DepositField::Vault, + ), + field_row( + "Chain", + deposit_chain_value(app), + active == DepositField::Chain, + ), + field_row( + "Asset", + deposit_asset_value(app), + active == DepositField::Asset, + ), + Line::from(""), ]; - let lines: Vec = fields - .iter() - .map(|(label, value, idx)| { - let marker = if *idx == app.deposit_form.active_field { - "▶ " - } else { - " " - }; - let style = if *idx == app.deposit_form.active_field { - Style::new().fg(Color::Cyan) - } else { - Style::new() - }; - Line::from(vec![ - Span::raw(marker), - Span::styled( - format!("{label}: "), - Style::new().add_modifier(Modifier::BOLD), - ), - Span::styled( - if value.is_empty() { - "".to_string() - } else { - value.to_string() - }, - style, - ), - ]) - }) - .collect(); + // Wallet balance / native balance lines. + lines.push(balance_line( + "Wallet", + &app.deposit_form.wallet_balance, + app.deposit_form + .asset + .as_ref() + .map(|a| a.symbol.as_str()) + .unwrap_or(""), + amount_raw(app), + )); + lines.push(balance_line( + "Native", + &app.deposit_form.native_balance, + "", + None, + )); + + lines.push(Line::from("")); + + // Amount input row. + lines.push(amount_row( + "Amount", + app.deposit_form.amount.value(), + active == DepositField::Amount, + )); + if let Some(ref err) = app.deposit_form.validation_err { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("⚠ {err}"), Style::new().fg(Color::Red)), + ])); + } - let form = Paragraph::new(lines) - .block(Block::bordered().title(" Deposit | ↑↓: fields Enter: submit Esc: back ")); + // Preview row. + lines.push(preview_line(&app.deposit_form.preview)); + + // Compliance line (placeholder — actual fetch lives in CLI path). + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Compliance: ", Style::new().add_modifier(Modifier::BOLD)), + Span::styled( + "checked at submit time via CLI", + Style::new().fg(Color::DarkGray), + ), + ])); + + lines.push(Line::from("")); + + // Submit row. + lines.push(submit_row("Submit", active == DepositField::Submit)); + + let form = Paragraph::new(lines).block( + Block::bordered().title(" Deposit | ↑↓ field Enter pick/submit Ctrl+M max Esc back "), + ); frame.render_widget(form, form_area); let status = @@ -309,51 +352,343 @@ fn render_deposit_form(frame: &mut Frame, app: &mut App, area: ratatui::layout:: frame.render_widget(status, status_area); } +fn deposit_vault_value(app: &App) -> Vec> { + match &app.deposit_form.vault { + Some(v) => { + let badge = format!("[{:?}]", v.basic.vault_type).to_lowercase(); + let apy = v + .apy + .as_ref() + .and_then(|a| a.rolling_7d) + .map(|p| format!("{:.2}% 7d", p * 100.0)) + .unwrap_or_else(|| "-".to_string()); + vec![ + Span::raw(v.basic.name.clone()), + Span::raw(" "), + Span::styled(badge, Style::new().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(apy, Style::new().fg(Color::Green)), + ] + } + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +fn deposit_chain_value(app: &App) -> Vec> { + match app.deposit_form.chain { + Some(c) => { + let name = crate::config::chain_config(c) + .map(|cfg| cfg.name) + .unwrap_or("?"); + vec![Span::raw(format!("{name} ({c})"))] + } + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +fn deposit_asset_value(app: &App) -> Vec> { + match &app.deposit_form.asset { + Some(a) => vec![ + Span::raw(a.symbol.clone()), + Span::raw(" "), + Span::styled( + short_addr(&a.contract_address), + Style::new().fg(Color::DarkGray), + ), + ], + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +/// Build a labeled field row with an active-field marker. +fn field_row(label: &str, value: Vec>, active: bool) -> Line<'static> { + let mut spans: Vec> = Vec::with_capacity(2 + value.len()); + spans.push(Span::styled( + if active { "▸ " } else { " " }, + Style::new().fg(Color::Cyan), + )); + spans.push(Span::styled( + format!("{label:<8}: "), + Style::new().add_modifier(Modifier::BOLD).fg(if active { + Color::Cyan + } else { + Color::Gray + }), + )); + spans.extend(value); + Line::from(spans) +} + +fn amount_row(label: &str, value: &str, active: bool) -> Line<'static> { + let val_span = if value.is_empty() { + Span::styled("", Style::new().fg(Color::DarkGray)) + } else { + Span::raw(value.to_string()) + }; + let hint = Span::styled( + " [Ctrl+M: MAX]", + Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ); + let cursor = if active { + Span::styled("█", Style::new().fg(Color::Cyan)) + } else { + Span::raw("") + }; + Line::from(vec![ + Span::styled( + if active { "▸ " } else { " " }, + Style::new().fg(Color::Cyan), + ), + Span::styled( + format!("{label:<8}: "), + Style::new().add_modifier(Modifier::BOLD).fg(if active { + Color::Cyan + } else { + Color::Gray + }), + ), + val_span, + cursor, + hint, + ]) +} + +fn submit_row(label: &str, active: bool) -> Line<'static> { + let style = if active { + Style::new() + .bg(Color::Cyan) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::Gray).add_modifier(Modifier::BOLD) + }; + Line::from(vec![ + Span::styled( + if active { "▸ " } else { " " }, + Style::new().fg(Color::Cyan), + ), + Span::styled(format!("[ {label} ] (Enter)"), style), + ]) +} + +fn balance_line( + label: &str, + state: &BalanceState, + symbol: &str, + requested: Option, +) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{label:<8}: "), + Style::new().add_modifier(Modifier::BOLD).fg(Color::Gray), + )); + match state { + BalanceState::NotLoaded => { + spans.push(Span::styled("-", Style::new().fg(Color::DarkGray))); + } + BalanceState::Loading => { + let throbber = Throbber::default() + .label(" loading…") + .throbber_style(Style::new().fg(Color::Yellow)); + // Throbber renders as a widget over a Rect; for inline use, fall + // back to a static label so the form layout stays simple. + let _ = throbber; + spans.push(Span::styled("⠋ loading…", Style::new().fg(Color::Yellow))); + } + BalanceState::Loaded { raw, decimals } => { + let txt = format_raw_amount(*raw, *decimals); + let sym = if symbol.is_empty() { + String::new() + } else { + format!(" {symbol}") + }; + spans.push(Span::raw(format!("{txt}{sym}"))); + if let Some(req) = requested { + if req <= *raw { + spans.push(Span::styled(" ✓ enough", Style::new().fg(Color::Green))); + } else { + spans.push(Span::styled( + " ⚠ insufficient", + Style::new().fg(Color::Red), + )); + } + } + } + BalanceState::Error(e) => { + spans.push(Span::styled( + format!("error: {e}"), + Style::new().fg(Color::Red), + )); + } + } + Line::from(spans) +} + +fn preview_line(state: &PreviewState) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "Preview : ", + Style::new().add_modifier(Modifier::BOLD).fg(Color::Gray), + )); + match state { + PreviewState::NotReady => { + spans.push(Span::styled( + "fill all fields…", + Style::new().fg(Color::DarkGray), + )); + } + PreviewState::Loading => { + spans.push(Span::styled("⠋ fetching…", Style::new().fg(Color::Yellow))); + } + PreviewState::Loaded { + share_amount, + share_decimals, + } => { + let raw = alloy::primitives::U256::from_str_radix(share_amount, 10) + .unwrap_or(alloy::primitives::U256::ZERO); + let txt = format_raw_amount(raw, *share_decimals); + spans.push(Span::styled( + format!("≈ {txt} shares"), + Style::new().fg(Color::Cyan), + )); + } + PreviewState::Error(e) => { + spans.push(Span::styled( + format!("error: {e}"), + Style::new().fg(Color::Red), + )); + } + } + Line::from(spans) +} + +fn amount_raw(app: &App) -> Option { + let asset = app.deposit_form.asset.as_ref()?; + crate::util::parse_ui_amount(app.deposit_form.amount.value(), asset.decimals).ok() +} + // --------------------------------------------------------------------------- // Withdraw form // --------------------------------------------------------------------------- fn render_withdraw_form(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + // Gate the form: if the user has no positions, show a guidance message. + if app.positions.is_empty() { + let msg = Paragraph::new( + "No Nest positions — deposit first.\n\nPress Tab to switch tabs, or 'r' to refresh positions.", + ) + .block(Block::bordered().title(" Withdraw ")); + frame.render_widget(msg, area); + return; + } + let [form_area, status_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(area); - let claim_str = if app.withdraw_form.claim { - "Yes".to_string() - } else { - "No".to_string() - }; - let fields: [(&str, String, usize); 3] = [ - ("Vault Slug", app.withdraw_form.vault_slug.clone(), 0), - ("Shares", app.withdraw_form.shares.clone(), 1), - ("Claim (c to toggle)", claim_str, 2), + let active = app.withdraw_form.active_field; + let mut lines: Vec = vec![ + field_row( + "Vault", + withdraw_vault_value(app), + active == WithdrawField::Vault, + ), + field_row( + "Chain", + withdraw_chain_value(app), + active == WithdrawField::Chain, + ), + field_row( + "Asset", + withdraw_asset_value(app), + active == WithdrawField::Asset, + ), + field_row( + "Mode", + withdraw_mode_value(app), + active == WithdrawField::Mode, + ), + Line::from(""), ]; - let lines: Vec = fields - .iter() - .map(|(label, value, idx)| { - let marker = if *idx == app.withdraw_form.active_field { - "▶ " - } else { - " " - }; - let style = if *idx == app.withdraw_form.active_field { - Style::new().fg(Color::Cyan) - } else { - Style::new() - }; - Line::from(vec![ - Span::raw(marker), - Span::styled( - format!("{label}: "), - Style::new().add_modifier(Modifier::BOLD), - ), - Span::styled(value.as_str(), style), - ]) - }) - .collect(); + // Share balance line. + let symbol = app + .withdraw_form + .vault + .as_ref() + .map(|v| v.basic.symbol.as_str()) + .unwrap_or("shares"); + lines.push(balance_line( + "Shares", + &app.withdraw_form.share_balance, + symbol, + withdraw_requested_raw(app), + )); + // Instant liquidity gating. + let liq_symbol = app + .withdraw_form + .redemption_asset + .as_ref() + .map(|a| a.symbol.as_str()) + .unwrap_or(""); + lines.push(balance_line( + "Liquidity", + &app.withdraw_form.instant_liquidity, + liq_symbol, + None, + )); + lines.push(balance_line( + "Claim", + &app.withdraw_form.claimable, + symbol, + None, + )); + + lines.push(Line::from("")); + + // Shares input. + lines.push(amount_row( + "Shares", + app.withdraw_form.shares.value(), + active == WithdrawField::Shares, + )); + if let Some(ref err) = app.withdraw_form.validation_err { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("⚠ {err}"), Style::new().fg(Color::Red)), + ])); + } + + // Compliance line. + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Compliance: ", Style::new().add_modifier(Modifier::BOLD)), + Span::styled( + "checked at submit time via CLI", + Style::new().fg(Color::DarkGray), + ), + ])); + + lines.push(Line::from("")); + + // Submit row. + lines.push(submit_row("Submit", active == WithdrawField::Submit)); - let form = Paragraph::new(lines) - .block(Block::bordered().title(" Withdraw | ↑↓: fields Enter: submit Esc: back ")); + let form = Paragraph::new(lines).block( + Block::bordered().title(" Withdraw | ↑↓ field Enter pick/submit Ctrl+M max Esc back "), + ); frame.render_widget(form, form_area); let status = Paragraph::new(app.withdraw_form.status.as_str()) @@ -361,6 +696,72 @@ fn render_withdraw_form(frame: &mut Frame, app: &mut App, area: ratatui::layout: frame.render_widget(status, status_area); } +fn withdraw_vault_value(app: &App) -> Vec> { + match &app.withdraw_form.vault { + Some(v) => { + let badge = format!("[{:?}]", v.basic.vault_type).to_lowercase(); + vec![ + Span::raw(v.basic.name.clone()), + Span::raw(" "), + Span::styled(badge, Style::new().fg(Color::Yellow)), + ] + } + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +fn withdraw_chain_value(app: &App) -> Vec> { + match app.withdraw_form.chain { + Some(c) => { + let name = crate::config::chain_config(c) + .map(|cfg| cfg.name) + .unwrap_or("?"); + vec![Span::raw(format!("{name} ({c})"))] + } + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +fn withdraw_asset_value(app: &App) -> Vec> { + match &app.withdraw_form.redemption_asset { + Some(a) => vec![ + Span::raw(a.symbol.clone()), + Span::raw(" "), + Span::styled( + short_addr(&a.contract_address), + Style::new().fg(Color::DarkGray), + ), + ], + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +fn withdraw_mode_value(app: &App) -> Vec> { + match app.withdraw_form.mode { + Some(WithdrawModeKind::Request) => vec![Span::raw("Request (2-phase, cooldown)")], + Some(WithdrawModeKind::Instant) => vec![Span::raw("Instant (solver-fulfilled)")], + Some(WithdrawModeKind::Claim) => vec![Span::raw("Claim (after cooldown)")], + None => vec![Span::styled( + "", + Style::new().fg(Color::DarkGray), + )], + } +} + +fn withdraw_requested_raw(app: &App) -> Option { + let v = app.withdraw_form.vault.as_ref()?; + crate::util::parse_ui_amount(app.withdraw_form.shares.value(), v.basic.decimals).ok() +} + // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- From 40d4a4a88c9ba2eb9d91bb6a44666aef28326e9f Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 28 May 2026 18:37:46 -0400 Subject: [PATCH 2/3] feat: passive update notifier and Windows nest update self-replace (rename trick) --- Cargo.lock | 13 ++ Cargo.toml | 1 + src/cli.rs | 5 +- src/commands/update.rs | 75 ++++++++--- src/main.rs | 15 +++ src/updater.rs | 140 ++----------------- src/version_check.rs | 296 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 390 insertions(+), 155 deletions(-) create mode 100644 src/version_check.rs diff --git a/Cargo.lock b/Cargo.lock index d601e8c..fa10a8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3071,6 +3071,7 @@ dependencies = [ "tokio", "tui-input", "wiremock", + "zip", ] [[package]] @@ -6089,6 +6090,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index f8530ab..2a97d21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ hex = "0.4" sha2 = "0.10" flate2 = "1" tar = "0.4" +zip = { version = "0.6", default-features = false, features = ["deflate"] } chrono = { version = "0.4", features = ["serde"] } semver = "1" diff --git a/src/cli.rs b/src/cli.rs index 8d90912..f5f3a83 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,8 +2,6 @@ use std::sync::OnceLock; use clap::{Args, Parser, Subcommand, ValueEnum}; -use crate::updater; - /// Returns help text for --private-key that shows the wallet address (never the raw key). fn private_key_help() -> &'static str { static HELP: OnceLock = OnceLock::new(); @@ -30,8 +28,7 @@ fn derive_address(key_hex: &str) -> Option { #[derive(Parser)] #[command( name = "nest", - version = updater::version_with_update_notification(), - long_about = updater::help_with_update_notification(), + version = env!("CARGO_PKG_VERSION"), about = "Interact with Nest Vaults on Plume Network", after_help = "Powered by Plume | https://plume.org" )] diff --git a/src/commands/update.rs b/src/commands/update.rs index 8fa828a..d5ead93 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -32,14 +32,6 @@ pub async fn run(args: UpdateArgs) -> Result<()> { let triple = host_triple() .ok_or_else(|| eyre::eyre!("unsupported platform for self-update. {INSTALL_HINT}"))?; - // Self-replacing a running .exe is fiddly; not supported in v0.2.x. - if triple == "x86_64-pc-windows-msvc" { - eprintln!( - "Windows update isn't supported in v0.2.x — re-download the latest zip from https://nestagents.io/downloads/" - ); - return Ok(()); - } - let artifact = manifest.artifacts.get(triple).ok_or_else(|| { eyre::eyre!("no release artifact for `{triple}` in the manifest. {INSTALL_HINT}") })?; @@ -57,8 +49,12 @@ pub async fn run(args: UpdateArgs) -> Result<()> { install_archive(&bytes, ¤t_exe) .wrap_err_with(|| format!("install failed. {INSTALL_HINT}"))?; + // Refresh the notifier's cache so the next invocation of the freshly-installed + // binary doesn't show a stale "update available" notice. Best-effort. + let _ = crate::version_check::record_installed_version(&manifest.version); + eprintln!( - "Updated to v{}. Restart nest to use the new version.", + "Updated to v{}. The next `nest` invocation uses the new binary.", manifest.version ); Ok(()) @@ -99,9 +95,12 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { Ok(()) } -/// Extract the `nest` binary from a `.tar.gz` and atomically replace `dest`. -/// Stages the new binary alongside `dest` (same filesystem) and renames over it, -/// which is atomic on Unix and safe even while the old binary is running. +/// Extract the `nest` binary from the release archive and replace `dest`. +/// - Unix: `.tar.gz` → stage alongside `dest` (same filesystem) → `rename` (atomic). +/// - Windows: `.zip` → rename `dest` → `dest.old` (you can't overwrite a running +/// `.exe` but you *can* rename it) → write new bytes as `dest`. The `.old` +/// file is swept by `version_check::cleanup_old_exe` on the next invocation. +#[cfg(not(target_os = "windows"))] fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> { let gz = flate2::read::GzDecoder::new(archive_bytes); let mut archive = tar::Archive::new(gz); @@ -127,18 +126,55 @@ fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> { eyre::bail!("archive did not contain a 'nest' binary"); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&staged)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&staged, perms)?; - } + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&staged)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&staged, perms)?; std::fs::rename(&staged, dest).wrap_err("failed to replace the current binary")?; Ok(()) } +#[cfg(target_os = "windows")] +fn install_archive(archive_bytes: &[u8], dest: &Path) -> Result<()> { + use std::io::{Cursor, Read, Write}; + + let mut archive = + zip::ZipArchive::new(Cursor::new(archive_bytes)).wrap_err("failed to read zip archive")?; + + // Find the `nest.exe` entry — accept any path inside the zip. + let mut found_idx = None; + for i in 0..archive.len() { + let entry = archive.by_index(i).wrap_err("failed to read zip entry")?; + if entry.is_file() && entry.name().replace('\\', "/").ends_with("nest.exe") { + found_idx = Some(i); + break; + } + } + let idx = + found_idx.ok_or_else(|| eyre::eyre!("archive did not contain a 'nest.exe' binary"))?; + + let mut entry = archive.by_index(idx).wrap_err("failed to open zip entry")?; + let mut new_bytes = Vec::new(); + entry + .read_to_end(&mut new_bytes) + .wrap_err("failed to read 'nest.exe' from archive")?; + drop(entry); + drop(archive); + + // Rename trick: a running `.exe` cannot be deleted/overwritten but it CAN + // be renamed. We sweep any stale `.exe.old` from a previous update first. + let old_path = dest.with_extension("exe.old"); + let _ = std::fs::remove_file(&old_path); + std::fs::rename(dest, &old_path).wrap_err("failed to rename the current binary to .exe.old")?; + + let mut f = std::fs::File::create(dest).wrap_err("failed to create new nest.exe")?; + f.write_all(&new_bytes) + .wrap_err("failed to write new nest.exe")?; + drop(f); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -178,6 +214,7 @@ mod tests { /// Build a .tar.gz containing a `nest` file, then verify install_archive /// extracts + atomically replaces a destination file (not the real binary). + #[cfg(not(target_os = "windows"))] #[test] fn install_archive_extracts_and_replaces() { let tmp = std::env::temp_dir().join(format!("nest-update-test-{}", std::process::id())); diff --git a/src/main.rs b/src/main.rs index 6e24fb2..111be6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod tui; mod tx_bundle; mod updater; mod util; +mod version_check; #[tokio::main] async fn main() { @@ -25,9 +26,23 @@ async fn main() { async fn run() -> eyre::Result<()> { dotenvy::dotenv().ok(); + // Windows: sweep any `nest.exe.old` left behind by a previous self-update. + // No-op on other platforms. + version_check::cleanup_old_exe(); + let cli = cli::Cli::parse(); let cfg = config::AppConfig::from_cli(&cli)?; + // Passive update notifier — skipped for `update` (would be confusing) and + // `dashboard` (alt-screen swallows stderr). Never blocks; writes only to + // stderr so `-o json` stdout stays clean. + if let Some(ref cmd) = cli.command { + let skip = matches!(cmd, cli::Commands::Update(_) | cli::Commands::Dashboard); + if !skip { + version_check::check_and_notify(cli.no_color); + } + } + // No subcommand → print help and exit, even when a global env flag like // PRIVATE_KEY is set (which would otherwise defeat arg_required_else_help). let Some(command) = cli.command else { diff --git a/src/updater.rs b/src/updater.rs index 5994087..4d52297 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,18 +1,11 @@ +//! Version manifest types and helpers used by `nest update` (`commands/update.rs`). +//! The passive startup notifier lives in `src/version_check.rs`. + use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::sync::OnceLock; -use std::time::{SystemTime, UNIX_EPOCH}; use eyre::{Result, WrapErr}; use serde::Deserialize; -const CACHE_LAST_CHECK_FILE: &str = "last_check"; -const CACHE_VERSION_FILE: &str = "latest_version"; -/// Only check for updates once every 24 hours. -const UPDATE_CHECK_INTERVAL: u64 = 24 * 60 * 60; - -/// Public version manifest. Overridable via `NEST_UPDATE_MANIFEST_URL` (used by tests). const DEFAULT_MANIFEST_URL: &str = "https://nestagents.io/downloads/version.json"; /// The version manifest served at `nestagents.io/downloads/version.json`. @@ -64,16 +57,13 @@ pub fn host_triple() -> Option<&'static str> { } } -/// Returns true if `latest` is a newer semver than `current`. +/// Returns true if `latest` is a newer dotted version than `current` (uses the +/// `semver` crate; the older custom parser was replaced when v0.2.3 added the +/// notifier so the two paths share a single comparison rule). pub fn is_newer(current: &str, latest: &str) -> bool { - let parse = |v: &str| -> Option<(u64, u64, u64)> { + let parse = |v: &str| { let v = v.strip_prefix('v').unwrap_or(v); - let mut parts = v.split('.'); - Some(( - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - parts.next()?.parse().ok()?, - )) + semver::Version::parse(v).ok() }; match (parse(current), parse(latest)) { (Some(c), Some(l)) => l > c, @@ -81,119 +71,6 @@ pub fn is_newer(current: &str, latest: &str) -> bool { } } -// --------------------------------------------------------------------------- -// Startup update notification (cached for 24h, shown on --version / --help) -// --------------------------------------------------------------------------- - -fn cache_dir() -> Option { - dirs::cache_dir().map(|d| d.join("nest")) -} - -fn current_time() -> Option { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map(|d| d.as_secs()) -} - -fn should_check(last_check_file: &PathBuf) -> bool { - let Ok(contents) = fs::read_to_string(last_check_file) else { - return true; - }; - let Ok(last) = contents.trim().parse::() else { - return true; - }; - let Some(now) = current_time() else { - return false; - }; - now.saturating_sub(last) >= UPDATE_CHECK_INTERVAL -} - -fn read_cached_version(version_file: &PathBuf) -> Option { - fs::read_to_string(version_file) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -/// Fetch the latest version from the manifest, cached for 24h. The HTTP fetch runs -/// on a dedicated thread with its own runtime so it never touches the outer -/// `#[tokio::main]` runtime (this is called synchronously during clap parsing). -fn fetch_and_cache_latest() -> Option { - let cache = cache_dir()?; - fs::create_dir_all(&cache).ok()?; - - let last_check_file = cache.join(CACHE_LAST_CHECK_FILE); - let version_file = cache.join(CACHE_VERSION_FILE); - - if should_check(&last_check_file) { - let fetched = std::thread::spawn(|| { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .ok()?; - rt.block_on(async { fetch_manifest(&manifest_url()).await.ok() }) - .map(|m| m.version) - }) - .join() - .ok() - .flatten(); - - if let Some(ref latest) = fetched - && let Some(now) = current_time() - { - let _ = fs::write(&last_check_file, now.to_string()); - let _ = fs::write(&version_file, latest); - } - fetched.or_else(|| read_cached_version(&version_file)) - } else { - read_cached_version(&version_file) - } -} - -/// Returns an update-available notice if the cached latest version is newer. -pub fn check_for_updates() -> Option { - // Never hit the network during tests (clap builds the Command — and thus - // evaluates `version`/`long_about` — on every parse, including unit tests). - if cfg!(test) { - return None; - } - let current = env!("CARGO_PKG_VERSION"); - let latest = fetch_and_cache_latest()?; - if is_newer(current, &latest) { - Some(format!( - "\n A new version of nest is available: v{current} → v{latest}\n Run `nest update` to install.\n" - )) - } else { - None - } -} - -/// Thread-safe cached version string that includes an update notification. -pub fn version_with_update_notification() -> &'static str { - static VERSION: OnceLock = OnceLock::new(); - VERSION.get_or_init(|| { - let current = env!("CARGO_PKG_VERSION"); - match check_for_updates() { - Some(notification) => format!("{current}{notification}"), - None => current.to_string(), - } - }) -} - -/// Thread-safe cached help string that includes an update notification before help text. -pub fn help_with_update_notification() -> &'static str { - static HELP: OnceLock = OnceLock::new(); - HELP.get_or_init(|| { - let current = env!("CARGO_PKG_VERSION"); - let about = format!("Interact with Nest Vaults on Plume Network (v{current})"); - match check_for_updates() { - Some(notification) => format!("{notification}\n{about}"), - None => about, - } - }) -} - #[cfg(test)] mod tests { use super::*; @@ -211,7 +88,6 @@ mod tests { #[test] fn host_triple_is_known_on_this_platform() { - // CI runs on macos/linux x86_64/aarch64 — all supported. assert!(host_triple().is_some()); } diff --git a/src/version_check.rs b/src/version_check.rs new file mode 100644 index 0000000..6021209 --- /dev/null +++ b/src/version_check.rs @@ -0,0 +1,296 @@ +//! Passive update notifier. +//! +//! Runs at the top of every `nest ` invocation (except `update` and +//! `dashboard`). Reads `dirs::cache_dir()/nest-cli/version-check.json`. If a +//! newer version is cached, prints a one-line notice to **stderr**. If the +//! cache is stale (>24 h) or missing, kicks off a detached background fetch +//! of `nestagents.io/downloads/version.json` (3 s timeout) so the next +//! invocation sees a fresh value. The current invocation never waits on the +//! network and never writes to stdout (keeps `-o json` clean). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use semver::Version; +use serde::{Deserialize, Serialize}; + +const MANIFEST_URL_ENV: &str = "NEST_UPDATE_MANIFEST_URL"; +const DEFAULT_MANIFEST_URL: &str = "https://nestagents.io/downloads/version.json"; +const TTL_SECS: u64 = 24 * 3600; +const FETCH_TIMEOUT_SECS: u64 = 3; +const CACHE_FILE_NAME: &str = "version-check.json"; + +/// On-disk cache schema. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Cache { + latest_version: String, + checked_at: u64, +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn cache_path() -> Option { + dirs::cache_dir().map(|d| d.join("nest-cli").join(CACHE_FILE_NAME)) +} + +fn read_cache_at(path: &Path) -> Option { + let txt = fs::read_to_string(path).ok()?; + serde_json::from_str(&txt).ok() +} + +fn write_cache_at(path: &Path, cache: &Cache) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let txt = serde_json::to_string(cache).map_err(std::io::Error::other)?; + fs::write(path, txt) +} + +/// True iff `checked_at` is at least `ttl` seconds in the past relative to `now_ts`. +fn is_stale(checked_at: u64, now_ts: u64, ttl: u64) -> bool { + now_ts.saturating_sub(checked_at) >= ttl +} + +/// True iff `latest` (semver) is strictly greater than `current`. +fn is_newer(latest: &str, current: &str) -> bool { + match (Version::parse(latest), Version::parse(current)) { + (Ok(l), Ok(c)) => l > c, + _ => false, + } +} + +/// Public getter for the v0.2.4 TUI banner: returns the cached latest version +/// **only** if it is strictly newer than the running binary. Unused inside v0.2.3 +/// itself; exposed so the planned v0.2.4 TUI banner can read the cache without +/// re-implementing parsing. +#[allow(dead_code)] +pub(crate) fn cached_latest() -> Option { + let path = cache_path()?; + let cache = read_cache_at(&path)?; + let latest = Version::parse(&cache.latest_version).ok()?; + let current = Version::parse(env!("CARGO_PKG_VERSION")).ok()?; + if latest > current { Some(latest) } else { None } +} + +/// Windows-only: a successful `nest update` leaves the previous binary at +/// `.exe.old` (it can't be deleted while running). Sweep it on launch. +#[cfg(target_os = "windows")] +pub(crate) fn cleanup_old_exe() { + if let Ok(current) = std::env::current_exe() { + let old = current.with_extension("exe.old"); + let _ = std::fs::remove_file(&old); + } +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn cleanup_old_exe() {} + +/// Record the version we just self-installed so the very next `nest ` +/// invocation doesn't show a stale "update available" notice. +pub(crate) fn record_installed_version(version: &str) -> std::io::Result<()> { + let Some(path) = cache_path() else { + return Ok(()); + }; + write_cache_at( + &path, + &Cache { + latest_version: version.to_string(), + checked_at: now(), + }, + ) +} + +fn manifest_url() -> String { + std::env::var(MANIFEST_URL_ENV).unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string()) +} + +async fn fetch_version_string(url: &str) -> Option { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS)) + .build() + .ok()?; + let resp = client.get(url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let v = resp.json::().await.ok()?; + let s = v.get("version").and_then(|x| x.as_str())?; + Version::parse(s).ok()?; + Some(s.to_string()) +} + +/// Detached background thread that refreshes the cache file. Never blocks the +/// caller, never propagates errors. On any failure it still bumps `checked_at` +/// so we don't hammer a broken endpoint on every invocation. +fn spawn_refresh() { + std::thread::spawn(|| { + // A dedicated current-thread runtime keeps reqwest's runtime out of the + // outer `#[tokio::main]` context (avoids the runtime-drop panic). + let Ok(rt) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + rt.block_on(async move { + let fetched = fetch_version_string(&manifest_url()).await; + let Some(path) = cache_path() else { + return; + }; + let now_ts = now(); + let cache = match fetched { + Some(ver) => Cache { + latest_version: ver, + checked_at: now_ts, + }, + None => { + // Preserve existing latest_version on failure; only bump checked_at. + let existing = read_cache_at(&path) + .map(|c| c.latest_version) + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); + Cache { + latest_version: existing, + checked_at: now_ts, + } + } + }; + let _ = write_cache_at(&path, &cache); + }); + }); +} + +fn print_notice(latest: &str, current: &str, no_color: bool) { + use std::io::IsTerminal; + let marker = if !no_color && std::io::stderr().is_terminal() { + "\x1b[33m▲\x1b[0m" + } else { + "!" + }; + eprintln!("{marker} A new Nest CLI version {latest} is available (you have {current})."); + eprintln!(" Run `nest update` to upgrade."); + eprintln!(); +} + +/// Top-of-`main()` entry: prints the notice if a newer version is cached, then +/// (if the cache is stale beyond TTL or missing) spawns a detached background +/// refresh. Caller is responsible for skipping `update`/`dashboard`. +pub(crate) fn check_and_notify(no_color: bool) { + // Never hit the network or print during tests. + if cfg!(test) { + return; + } + if std::env::var("NEST_NO_UPDATE_CHECK") + .map(|v| v == "1") + .unwrap_or(false) + { + return; + } + + let path = match cache_path() { + Some(p) => p, + None => return, + }; + let cache = read_cache_at(&path); + + // Foreground notice when the cached latest is strictly newer. + let current = env!("CARGO_PKG_VERSION"); + if let Some(ref c) = cache + && is_newer(&c.latest_version, current) + { + print_notice(&c.latest_version, current, no_color); + } + + // Background refresh on stale/missing cache. + let stale = cache + .as_ref() + .map(|c| is_stale(c.checked_at, now(), TTL_SECS)) + .unwrap_or(true); + if stale { + spawn_refresh(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + fn temp_path(name: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!("nest-vc-{}-{n}-{name}", std::process::id())) + } + + #[test] + fn cache_roundtrip() { + let p = temp_path("rt"); + let c = Cache { + latest_version: "0.2.3".into(), + checked_at: 1748371200, + }; + write_cache_at(&p, &c).unwrap(); + let r = read_cache_at(&p).unwrap(); + assert_eq!(r.latest_version, "0.2.3"); + assert_eq!(r.checked_at, 1748371200); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn read_missing_returns_none() { + let p = temp_path("missing"); + let _ = std::fs::remove_file(&p); + assert!(read_cache_at(&p).is_none()); + } + + #[test] + fn read_corrupt_json_returns_none() { + let p = temp_path("corrupt"); + std::fs::write(&p, b"not json {").unwrap(); + assert!(read_cache_at(&p).is_none()); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn write_creates_missing_parent_dir() { + let p = temp_path("nestdir").join("deep").join("version-check.json"); + let cache = Cache { + latest_version: "0.2.3".into(), + checked_at: 0, + }; + write_cache_at(&p, &cache).unwrap(); + assert!(p.exists()); + let _ = std::fs::remove_dir_all(p.parent().unwrap().parent().unwrap()); + } + + #[test] + fn ttl_boundary_24h() { + let now_ts = 1_000_000; + assert!(!is_stale(now_ts, now_ts, TTL_SECS)); + assert!(!is_stale(now_ts - (TTL_SECS - 1), now_ts, TTL_SECS)); + assert!(is_stale(now_ts - TTL_SECS, now_ts, TTL_SECS)); + assert!(is_stale(now_ts - (TTL_SECS + 1), now_ts, TTL_SECS)); + // Missing / zero checked_at is treated as far past. + assert!(is_stale(0, now_ts, TTL_SECS)); + } + + #[test] + fn semver_comparison_cases() { + assert!(is_newer("0.2.3", "0.2.2")); + assert!(!is_newer("0.2.3", "0.2.3")); + assert!(!is_newer("0.2.3", "0.3.0")); + assert!(is_newer("0.3.0", "0.2.3")); + // pre-release precedence per semver spec + assert!(is_newer("1.0.0", "1.0.0-rc1")); + assert!(!is_newer("1.0.0-rc1", "1.0.0")); + assert!(is_newer("0.2.3", "0.2.3-dev")); + // garbage doesn't claim newness + assert!(!is_newer("not-semver", "0.2.3")); + assert!(!is_newer("0.2.3", "not-semver")); + } +} From d1eddf3b03bb7de59155eda78b68bce634cfd72f Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 28 May 2026 18:37:46 -0400 Subject: [PATCH 3/3] feat: Modal picker primitive and History page Chart redesign --- src/tui/app.rs | 81 ++++++---- src/tui/ui.rs | 223 +++++++++++++++++++++++---- src/tui/widgets/mod.rs | 1 + src/tui/widgets/modal.rs | 314 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 562 insertions(+), 57 deletions(-) create mode 100644 src/tui/widgets/mod.rs create mode 100644 src/tui/widgets/modal.rs diff --git a/src/tui/app.rs b/src/tui/app.rs index 83112d2..85b38c9 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -4,6 +4,7 @@ use alloy::primitives::{Address, U256}; use alloy::providers::Provider; use eyre::Result; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::text::Line; use ratatui::widgets::TableState; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; @@ -188,6 +189,8 @@ pub enum ModalKind { WithdrawMode, /// Same as VaultPicker but restricted to vaults the user has positions in. WithdrawVaultPicker, + /// Vault picker opened from the History tab via `v`. + HistoryVaultPicker, } /// The discrete withdraw modes the user can pick from the Withdraw form. @@ -294,10 +297,10 @@ pub struct App { // History pub history_vault: Option, pub history_days: u32, + /// How far back from "now" the history window ends, in days. `0` = current. + /// `[` shifts older (offset += days/2), `]` shifts newer (offset -= days/2). + pub history_offset_days: u32, pub history_data: Vec, - pub history_sparkline_apy: Vec, - pub history_sparkline_tvl: Vec, - pub history_sparkline_price: Vec, // Status pub status_message: String, @@ -338,10 +341,8 @@ impl App { withdraw_form: WithdrawForm::default(), history_vault: None, history_days: 30, + history_offset_days: 0, history_data: Vec::new(), - history_sparkline_apy: Vec::new(), - history_sparkline_tvl: Vec::new(), - history_sparkline_price: Vec::new(), status_message: "Loading...".to_string(), is_loading: false, last_refresh: None, @@ -570,30 +571,21 @@ impl App { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let start = now - (self.history_days as u64) * 86400; + let end = now.saturating_sub((self.history_offset_days as u64) * 86400); + let start = end.saturating_sub((self.history_days as u64) * 86400); let api = NestApiClient::new(&self.cfg.api_url); - match api.get_performance_history(&slug, start, now).await { + match api.get_performance_history(&slug, start, end).await { Ok(history) => { - self.history_sparkline_apy = history - .history - .iter() - .map(|p| (p.apy().unwrap_or(0.0) * 10000.0) as u64) - .collect(); - self.history_sparkline_tvl = history - .history - .iter() - .map(|p| (p.tvl().unwrap_or(0.0) / 1000.0) as u64) - .collect(); - self.history_sparkline_price = history - .history - .iter() - .map(|p| (p.price().unwrap_or(0.0) * 10000.0) as u64) - .collect(); self.status_message = format!( - "History: {} data points for {slug} ({}d)", + "History: {} data points for {slug} ({}d{})", history.history.len(), - self.history_days + self.history_days, + if self.history_offset_days > 0 { + format!(" / -{}d", self.history_offset_days) + } else { + String::new() + } ); self.history_data = history.history; } @@ -808,6 +800,11 @@ impl App { (ModalKind::WithdrawMode, ModalItem::WithdrawMode(m)) => { self.withdraw_form.mode = Some(m); } + (ModalKind::HistoryVaultPicker, ModalItem::Vault(v)) => { + self.history_vault = Some(v.basic.slug.clone()); + self.history_offset_days = 0; + self.load_history().await; + } _ => {} } } @@ -906,22 +903,56 @@ impl App { async fn handle_history_key(&mut self, key: KeyEvent) { match key.code { + // Range changes reset the window offset (anchor at "now"). KeyCode::Char('7') => { self.history_days = 7; + self.history_offset_days = 0; self.load_history().await; } KeyCode::Char('3') => { self.history_days = 30; + self.history_offset_days = 0; self.load_history().await; } KeyCode::Char('9') => { self.history_days = 90; + self.history_offset_days = 0; self.load_history().await; } KeyCode::Char('y') => { self.history_days = 365; + self.history_offset_days = 0; + self.load_history().await; + } + // Date-window navigation: half the current range per press. + KeyCode::Char('[') => { + let step = (self.history_days / 2).max(1); + self.history_offset_days = self.history_offset_days.saturating_add(step); + self.load_history().await; + } + KeyCode::Char(']') => { + let step = (self.history_days / 2).max(1); + self.history_offset_days = self.history_offset_days.saturating_sub(step); self.load_history().await; } + // `v` opens the vault picker for History. + KeyCode::Char('v') => { + let items: Vec = self + .vaults + .iter() + .map(|v| ModalItem::Vault(Box::new(v.clone()))) + .collect(); + let modal = Modal::new("Pick history vault", items, |it| match it { + ModalItem::Vault(v) => Line::from(format!( + "{} — {} ({})", + v.basic.symbol, + v.basic.name, + format!("{:?}", v.basic.vault_type).to_lowercase(), + )), + _ => Line::from(""), + }); + self.modal = Some((ModalKind::HistoryVaultPicker, modal)); + } _ => {} } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 6d68e4c..bda5e55 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,8 +1,9 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; +use ratatui::symbols; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Paragraph, Row, Sparkline, Table, Tabs}; +use ratatui::widgets::{Axis, Block, Cell, Chart, Dataset, GraphType, Paragraph, Row, Table, Tabs}; use throbber_widgets_tui::Throbber; use super::app::{ @@ -222,45 +223,203 @@ fn render_portfolio(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rec // --------------------------------------------------------------------------- fn render_history(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let footer_text = + " v: pick vault · 7/3/9/y: range · [: prev window ]: next window · r: refresh "; + let footer = Paragraph::new(footer_text).style(Style::new().fg(Color::DarkGray)); + + // Pull the cached VaultDetailed (if any) for the currently-selected slug. + let vault_detail: Option<&crate::api::types::VaultDetailed> = app + .history_vault + .as_deref() + .and_then(|slug| app.vaults.iter().find(|v| v.basic.slug == slug)); + + let range_suffix = if app.history_offset_days > 0 { + format!(" / -{}d", app.history_offset_days) + } else { + String::new() + }; + if app.history_data.is_empty() { - let msg = Paragraph::new( - "No history data. Press r to refresh.\nUse 7/3/9/y to set range (7d/30d/90d/1y).", - ) + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(area); + + let header_line = match vault_detail { + Some(v) => format!( + " {} — {} ({}) — no data in window ", + v.basic.symbol, + v.basic.name, + format!("{:?}", v.basic.vault_type).to_lowercase(), + ), + None => " History — press `v` to pick a vault ".to_string(), + }; + let header = Paragraph::new(header_line) + .style(Style::new().fg(Color::White)) + .block(Block::bordered()); + frame.render_widget(header, header_area); + + let body = Paragraph::new(format!( + "No history points. Press `v` to pick a vault, `r` to refresh, 7/3/9/y to change range. Current: {}d{range_suffix}.", + app.history_days + )) .block(Block::bordered().title(" History ")); - frame.render_widget(msg, area); + frame.render_widget(body, body_area); + frame.render_widget(footer, footer_area); return; } - let vault_name = app.history_vault.as_deref().unwrap_or("unknown"); - let title = format!( - " History: {} ({}d) | 7:7d 3:30d 9:90d y:1y ", - vault_name, app.history_days - ); - - let [apy_area, tvl_area, price_area] = Layout::vertical([ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), + let [header_area, apy_area, tvl_area, price_area, footer_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(8), + Constraint::Length(1), ]) .areas(area); - let apy_spark = Sparkline::default() - .block(Block::bordered().title(format!("{title} — APY"))) - .data(&app.history_sparkline_apy) - .style(Style::new().fg(Color::Cyan)); - frame.render_widget(apy_spark, apy_area); - - let tvl_spark = Sparkline::default() - .block(Block::bordered().title(" TVL (K) ")) - .data(&app.history_sparkline_tvl) - .style(Style::new().fg(Color::Yellow)); - frame.render_widget(tvl_spark, tvl_area); - - let price_spark = Sparkline::default() - .block(Block::bordered().title(" Price ")) - .data(&app.history_sparkline_price) - .style(Style::new().fg(Color::Green)); - frame.render_widget(price_spark, price_area); + // Header: vault identity + latest API-reported snapshot values. + let header_line = match vault_detail { + Some(v) => format!( + " {} — {} ({}) 30d APY {} 7d APY {} TVL {} Price {} [{}d{range_suffix}] ", + v.basic.symbol, + v.basic.name, + format!("{:?}", v.basic.vault_type).to_lowercase(), + format_pct(v.sec30d()), + format_pct(v.apy.as_ref().and_then(|a| a.rolling_7d)), + format_usd(v.tvl), + format_price(v.token_price), + app.history_days, + ), + None => format!( + " History ({}d{range_suffix}) — press `v` to pick a vault ", + app.history_days + ), + }; + let header = Paragraph::new(header_line) + .style(Style::new().fg(Color::White)) + .block(Block::bordered().title(" History ")); + frame.render_widget(header, header_area); + + // Datasets must own their points so they outlive each Chart construction. + let apy_pts: Vec<(f64, f64)> = app + .history_data + .iter() + .filter_map(|p| Some((p.day? as f64, p.apy()? * 100.0))) + .collect(); + let tvl_pts: Vec<(f64, f64)> = app + .history_data + .iter() + .filter_map(|p| Some((p.day? as f64, p.tvl()? / 1000.0))) + .collect(); + let price_pts: Vec<(f64, f64)> = app + .history_data + .iter() + .filter_map(|p| Some((p.day? as f64, p.price()?))) + .collect(); + + render_chart(frame, apy_area, " APY (%) ", &apy_pts, Color::Cyan, |v| { + format!("{v:.2}") + }); + render_chart( + frame, + tvl_area, + " TVL ($K) ", + &tvl_pts, + Color::Yellow, + |v| format!("{v:.0}"), + ); + render_chart( + frame, + price_area, + " Price ($) ", + &price_pts, + Color::Green, + |v| format!("{v:.4}"), + ); + + frame.render_widget(footer, footer_area); +} + +fn render_chart String>( + frame: &mut Frame, + area: ratatui::layout::Rect, + title: &str, + data: &[(f64, f64)], + color: Color, + fmt_y: F, +) { + if data.is_empty() { + let p = Paragraph::new("(no data)").block(Block::bordered().title(title.to_string())); + frame.render_widget(p, area); + return; + } + + let mut min_x = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut min_y = f64::INFINITY; + let mut max_y = f64::NEG_INFINITY; + for &(x, y) in data { + if x < min_x { + min_x = x; + } + if x > max_x { + max_x = x; + } + if y < min_y { + min_y = y; + } + if y > max_y { + max_y = y; + } + } + if (max_x - min_x).abs() < f64::EPSILON { + max_x = min_x + 1.0; + } + if (max_y - min_y).abs() < f64::EPSILON { + max_y = min_y + 1.0; + } + let mid_x = (min_x + max_x) / 2.0; + let mid_y = (min_y + max_y) / 2.0; + + let fmt_date = |ts: f64| -> String { + chrono::DateTime::::from_timestamp(ts as i64, 0) + .map(|dt| dt.format("%b %d").to_string()) + .unwrap_or_else(|| "-".to_string()) + }; + + let dataset = Dataset::default() + .name(title.trim().to_string()) + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(color)) + .data(data); + + let chart = Chart::new(vec![dataset]) + .block(Block::bordered().title(title.to_string())) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::DarkGray)) + .bounds([min_x, max_x]) + .labels(vec![ + Span::raw(fmt_date(min_x)), + Span::raw(fmt_date(mid_x)), + Span::raw(fmt_date(max_x)), + ]), + ) + .y_axis( + Axis::default() + .style(Style::default().fg(Color::DarkGray)) + .bounds([min_y, max_y]) + .labels(vec![ + Span::raw(fmt_y(min_y)), + Span::raw(fmt_y(mid_y)), + Span::raw(fmt_y(max_y)), + ]), + ); + frame.render_widget(chart, area); } // --------------------------------------------------------------------------- diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs new file mode 100644 index 0000000..6738a0f --- /dev/null +++ b/src/tui/widgets/mod.rs @@ -0,0 +1 @@ +pub mod modal; diff --git a/src/tui/widgets/modal.rs b/src/tui/widgets/modal.rs new file mode 100644 index 0000000..f69e976 --- /dev/null +++ b/src/tui/widgets/modal.rs @@ -0,0 +1,314 @@ +//! Reusable centered modal picker primitive. Used by every "select from a list" +//! interaction in the TUI (vault / chain / asset / withdraw-mode pickers). + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState}; + +/// Result of feeding a key into the modal. The owning App acts on these: +/// `Cancelled` and `Selected(_)` both implicitly close the modal. +pub enum ModalAction { + None, + Selected(T), + Cancelled, +} + +/// Generic centered picker. `T` is the row item; renderer is supplied at +/// construction so callers can format vault rows differently from chain rows. +pub struct Modal { + pub title: String, + items: Vec, + /// Selection is stored as an index into the *unfiltered* items vec. + state: ListState, + pub filter: String, + pub filter_mode: bool, + render_item: Box Line<'static>>, +} + +impl Modal { + // `new` and `selected` are exercised by tests + the planned Part 3/4 + // call sites; tolerate the lint while no production code constructs a Modal yet. + #[allow(dead_code)] + pub fn new( + title: impl Into, + items: Vec, + render: impl Fn(&T) -> Line<'static> + 'static, + ) -> Self { + let mut state = ListState::default(); + if !items.is_empty() { + state.select(Some(0)); + } + Self { + title: title.into(), + items, + state, + filter: String::new(), + filter_mode: false, + render_item: Box::new(render), + } + } + + /// Indices of `self.items` matching the current filter (case-insensitive). + fn visible_indices(&self) -> Vec { + if self.filter.is_empty() { + return (0..self.items.len()).collect(); + } + let needle = self.filter.to_lowercase(); + self.items + .iter() + .enumerate() + .filter(|(_, it)| { + let line = (self.render_item)(it); + let txt: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + txt.to_lowercase().contains(&needle) + }) + .map(|(i, _)| i) + .collect() + } + + /// If the current selection isn't in the visible set, jump to the first visible row. + fn ensure_selection_visible(&mut self) { + let indices = self.visible_indices(); + if indices.is_empty() { + self.state.select(None); + return; + } + let visible = self + .state + .selected() + .map(|i| indices.contains(&i)) + .unwrap_or(false); + if !visible { + self.state.select(Some(indices[0])); + } + } + + pub fn render(&mut self, frame: &mut Frame, container: Rect) { + let w_min: u16 = 30; + let h_min: u16 = 10; + let w_raw = ((container.width as u32) * 60 / 100) as u16; + let h_raw = ((container.height as u32) * 70 / 100) as u16; + let w = w_raw.max(w_min).min(container.width); + let h = h_raw.max(h_min).min(container.height); + let x = container.x + container.width.saturating_sub(w) / 2; + let y = container.y + container.height.saturating_sub(h) / 2; + let area = Rect { + x, + y, + width: w, + height: h, + }; + + // Clear the underlying form so the popup overlays it. + frame.render_widget(Clear, area); + + let indices = self.visible_indices(); + let items: Vec = indices + .iter() + .map(|&i| ListItem::new((self.render_item)(&self.items[i]))) + .collect(); + + // Translate the raw selection into a position inside the visible list. + let rel = self + .state + .selected() + .and_then(|raw| indices.iter().position(|i| *i == raw)); + let mut rel_state = ListState::default(); + rel_state.select(rel.or(if indices.is_empty() { None } else { Some(0) })); + + let footer = if self.filter_mode { + format!(" /{}_ Enter commit Esc cancel filter ", self.filter) + } else { + " ↑↓ select Enter commit / filter Esc cancel ".to_string() + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", self.title)) + .title_bottom(Line::from(footer)); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▸ "); + + frame.render_stateful_widget(list, area, &mut rel_state); + } + + pub fn handle_key(&mut self, key: KeyEvent) -> ModalAction { + if key.kind != KeyEventKind::Press { + return ModalAction::None; + } + if self.filter_mode { + match key.code { + KeyCode::Esc => { + self.filter.clear(); + self.filter_mode = false; + self.ensure_selection_visible(); + } + KeyCode::Enter => { + self.filter_mode = false; + return self.commit_selection(); + } + KeyCode::Backspace => { + self.filter.pop(); + self.ensure_selection_visible(); + } + KeyCode::Char(c) => { + self.filter.push(c); + self.ensure_selection_visible(); + } + _ => {} + } + return ModalAction::None; + } + match key.code { + KeyCode::Esc => ModalAction::Cancelled, + KeyCode::Char('/') => { + self.filter_mode = true; + ModalAction::None + } + KeyCode::Down => { + self.move_selection(1); + ModalAction::None + } + KeyCode::Up => { + self.move_selection(-1); + ModalAction::None + } + KeyCode::Enter => self.commit_selection(), + _ => ModalAction::None, + } + } + + fn move_selection(&mut self, delta: i32) { + let indices = self.visible_indices(); + if indices.is_empty() { + self.state.select(None); + return; + } + let current_raw = self.state.selected().unwrap_or(indices[0]); + let current_pos = indices.iter().position(|i| *i == current_raw).unwrap_or(0); + let len = indices.len() as i32; + let new_pos = ((current_pos as i32 + delta).rem_euclid(len)) as usize; + self.state.select(Some(indices[new_pos])); + } + + fn commit_selection(&self) -> ModalAction { + match self.state.selected() { + Some(i) if i < self.items.len() => ModalAction::Selected(self.items[i].clone()), + _ => ModalAction::None, + } + } + + #[allow(dead_code)] + pub fn selected(&self) -> Option<&T> { + self.state.selected().and_then(|i| self.items.get(i)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyEvent, KeyModifiers}; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn make() -> Modal { + Modal::new( + "Pick", + vec!["alpha".into(), "beta".into(), "gamma".into()], + |s: &String| Line::from(s.clone()), + ) + } + + #[test] + fn initial_selection_is_first() { + let m = make(); + assert_eq!(m.selected().map(|s| s.as_str()), Some("alpha")); + } + + #[test] + fn down_advances_then_wraps() { + let mut m = make(); + m.handle_key(key(KeyCode::Down)); + assert_eq!(m.selected().map(|s| s.as_str()), Some("beta")); + m.handle_key(key(KeyCode::Down)); + assert_eq!(m.selected().map(|s| s.as_str()), Some("gamma")); + m.handle_key(key(KeyCode::Down)); + assert_eq!(m.selected().map(|s| s.as_str()), Some("alpha")); + } + + #[test] + fn up_wraps_backward() { + let mut m = make(); + m.handle_key(key(KeyCode::Up)); + assert_eq!(m.selected().map(|s| s.as_str()), Some("gamma")); + } + + #[test] + fn enter_returns_selected() { + let mut m = make(); + let a = m.handle_key(key(KeyCode::Enter)); + assert!(matches!(a, ModalAction::Selected(ref s) if s == "alpha")); + } + + #[test] + fn esc_cancels() { + let mut m = make(); + assert!(matches!( + m.handle_key(key(KeyCode::Esc)), + ModalAction::Cancelled + )); + } + + #[test] + fn slash_enters_filter_then_chars_and_enter_commit_visible_row() { + let mut m = make(); + m.handle_key(key(KeyCode::Char('/'))); + assert!(m.filter_mode); + m.handle_key(key(KeyCode::Char('b'))); + m.handle_key(key(KeyCode::Char('e'))); + assert_eq!(m.filter, "be"); + // Only "beta" matches "be"; selection auto-moves to the visible row. + let a = m.handle_key(key(KeyCode::Enter)); + assert!(matches!(a, ModalAction::Selected(ref s) if s == "beta")); + assert!(!m.filter_mode); + } + + #[test] + fn esc_in_filter_mode_clears_filter_only() { + let mut m = make(); + m.handle_key(key(KeyCode::Char('/'))); + m.handle_key(key(KeyCode::Char('z'))); + m.handle_key(key(KeyCode::Esc)); + assert!(!m.filter_mode); + assert!(m.filter.is_empty()); + // After clearing, selection is visible again. + assert!(m.selected().is_some()); + } + + #[test] + fn empty_items_does_not_crash() { + let mut m: Modal = + Modal::new("Empty", Vec::new(), |s: &String| Line::from(s.clone())); + assert!(m.selected().is_none()); + assert!(matches!( + m.handle_key(key(KeyCode::Down)), + ModalAction::None + )); + assert!(matches!( + m.handle_key(key(KeyCode::Enter)), + ModalAction::None + )); + } +}