Skip to main content

schwab_cli/commands/
ui_cmd.rs

1use std::path::PathBuf;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use anyhow::{Context, Result};
6
7use crate::agent::{daemon_status, run_agent_loop};
8use crate::cli::RulesCommands;
9use crate::config::RuntimeConfig;
10use crate::rules::RulesConfig;
11use crate::safety::require_trading_approval;
12use crate::ui::agent_health::{new_shared_health, update_health, SharedAgentHealth};
13use crate::ui::discover::{list_rules_files_display, resolve_rules_file};
14use crate::ui::market_status::{self, MarketSnapshot};
15use crate::ui::menu::{list_rules_files, show_dashboard, show_rules};
16use crate::ui::spread_feed::{new_spread_snapshot, spawn_spread_mark_feed};
17use crate::ui::watch::{run_watch_tui, WatchAgentMode, WatchConfig};
18use crate::market_conditions::{spawn_market_conditions_feed, MarketConditionsSnapshot};
19
20pub async fn run_dashboard(runtime: &RuntimeConfig, file: Option<PathBuf>) -> Result<()> {
21    show_dashboard(runtime, file).await
22}
23
24pub async fn run_watch(
25    runtime: &RuntimeConfig,
26    file: Option<PathBuf>,
27    monitor_only: bool,
28) -> Result<()> {
29    if runtime.output == crate::output::OutputFormat::Json {
30        anyhow::bail!("watch is interactive only — omit --json");
31    }
32
33    let rules_path = resolve_rules_file(file, runtime.is_interactive())?;
34    let daemon = daemon_status(&rules_path);
35
36    let agent_mode = if daemon.running {
37        WatchAgentMode::External
38    } else if monitor_only {
39        WatchAgentMode::MonitorOnly
40    } else {
41        WatchAgentMode::Embedded
42    };
43
44    let will_spawn_agent = matches!(agent_mode, WatchAgentMode::Embedded);
45
46    if will_spawn_agent && !runtime.dry_run && !runtime.simulate {
47        let agent_id = RulesConfig::load(&rules_path)
48            .map(|r| r.agent_id)
49            .unwrap_or_else(|_| "agent".into());
50        require_trading_approval(
51            runtime,
52            "watch",
53            &format!("Run and watch options agent `{agent_id}`"),
54        )?;
55    }
56
57    let agent_runtime = RuntimeConfig::for_agent_trading(
58        runtime.output,
59        runtime.yes,
60        runtime.dry_run,
61        runtime.simulate,
62        runtime.trust,
63        true,
64        runtime.no_audio,
65        runtime.safety.clone(),
66        runtime.sink.clone(),
67    );
68
69    let market_snapshot = Arc::new(Mutex::new(MarketSnapshot::default()));
70    market_status::refresh_market_snapshot(runtime, &market_snapshot).await;
71    let snapshot_for_refresh = market_snapshot.clone();
72    let refresh_runtime = runtime.clone();
73    let _market_refresh = tokio::spawn(async move {
74        let mut interval = tokio::time::interval(Duration::from_secs(120));
75        interval.tick().await;
76        loop {
77            interval.tick().await;
78            market_status::refresh_market_snapshot(&refresh_runtime, &snapshot_for_refresh).await;
79        }
80    });
81
82    let market_conditions = Arc::new(Mutex::new(MarketConditionsSnapshot::default()));
83    if let Ok(market_api) = runtime.build_market_api() {
84        if let Ok(mut guard) = market_conditions.lock() {
85            crate::market_conditions::refresh_market_conditions(&market_api, &mut guard).await;
86        }
87        let _conditions_feed =
88            spawn_market_conditions_feed(market_api, market_conditions.clone());
89    }
90
91    let agent_health: Option<SharedAgentHealth> = if will_spawn_agent {
92        Some(new_shared_health())
93    } else {
94        None
95    };
96
97    let agent_handle = if will_spawn_agent {
98        let path = rules_path.clone();
99        let health = agent_health.clone().expect("health when spawning");
100        Some(tokio::spawn(async move {
101            supervise_options_agent(agent_runtime, path, health).await;
102        }))
103    } else {
104        None
105    };
106
107    // Brief pause so the first tick can start before the TUI loads.
108    if will_spawn_agent {
109        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
110    }
111
112    let spread_snapshot = new_spread_snapshot();
113    if let (Ok(market), Ok(trader)) = (
114        runtime.build_market_api(),
115        runtime.build_api(),
116    ) {
117        let _spread_feed = spawn_spread_mark_feed(
118            rules_path.clone(),
119            runtime.simulate,
120            spread_snapshot.clone(),
121            market,
122            trader,
123        );
124    }
125
126    let watch_config = WatchConfig {
127        rules_path: rules_path.clone(),
128        agent_mode,
129        market_snapshot,
130        market_conditions,
131        agent_health,
132        spread_snapshot,
133    };
134
135    let watch_result = tokio::task::spawn_blocking(move || run_watch_tui(&watch_config))
136        .await
137        .context("watch UI thread panicked")?;
138
139    if let Some(handle) = agent_handle {
140        handle.abort();
141        let _ = handle.await;
142    }
143
144    watch_result
145}
146
147/// Belt-and-suspenders: if `run_agent_loop` ever returns, restart with backoff.
148async fn supervise_options_agent(
149    rt: RuntimeConfig,
150    path: PathBuf,
151    health: SharedAgentHealth,
152) {
153    let mut restart_backoff_secs: u64 = 5;
154    loop {
155        update_health(&health, |g| {
156            g.loop_running = true;
157        });
158        let result = run_agent_loop(&rt, &path, false, Some(health.clone())).await;
159        match result {
160            Ok(()) => {
161                update_health(&health, |g| g.record_loop_stopped(None));
162                let _ = crate::agent::paths::append_agent_log(
163                    &path,
164                    "agent supervisor: loop returned Ok — stopping",
165                );
166                break;
167            }
168            Err(e) => {
169                let msg = format!("agent loop exited: {e:#}");
170                let _ = crate::agent::paths::append_agent_log(&path, &msg);
171                update_health(&health, |g| {
172                    g.record_supervisor_restart();
173                    g.record_loop_stopped(Some(msg));
174                });
175                let restarts = health.lock().map(|g| g.restart_count).unwrap_or(0);
176                let _ = crate::agent::paths::append_agent_log(
177                    &path,
178                    &format!(
179                        "agent supervisor: restarting in {restart_backoff_secs}s (restart #{restarts})"
180                    ),
181                );
182                tokio::time::sleep(Duration::from_secs(restart_backoff_secs)).await;
183                restart_backoff_secs = (restart_backoff_secs.saturating_mul(2)).min(300);
184                update_health(&health, |g| {
185                    g.loop_running = true;
186                    g.healthy = false;
187                });
188            }
189        }
190    }
191}
192
193pub async fn run_rules(runtime: &RuntimeConfig, command: RulesCommands) -> Result<()> {
194    match command {
195        RulesCommands::Show { file } => show_rules(runtime, file).await,
196        RulesCommands::List => {
197            if runtime.output == crate::output::OutputFormat::Json {
198                list_rules_files(runtime);
199            } else {
200                println!("\n{}", list_rules_files_display());
201                println!();
202            }
203            Ok(())
204        }
205    }
206}