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, 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            let result = run_agent_loop(&agent_runtime, &path, false, Some(health.clone())).await;
102            if let Err(e) = result {
103                let msg = format!("agent exited: {e:#}");
104                let _ = crate::agent::paths::append_agent_log(&path, &msg);
105                if let Ok(mut g) = health.lock() {
106                    g.loop_running = false;
107                    g.record_error(&msg);
108                }
109            }
110        }))
111    } else {
112        None
113    };
114
115    // Brief pause so the first tick can start before the TUI loads.
116    if will_spawn_agent {
117        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
118    }
119
120    let spread_snapshot = new_spread_snapshot();
121    if let (Ok(market), Ok(trader)) = (
122        runtime.build_market_api(),
123        runtime.build_api(),
124    ) {
125        let _spread_feed = spawn_spread_mark_feed(
126            rules_path.clone(),
127            runtime.simulate,
128            spread_snapshot.clone(),
129            market,
130            trader,
131        );
132    }
133
134    let watch_config = WatchConfig {
135        rules_path: rules_path.clone(),
136        agent_mode,
137        market_snapshot,
138        market_conditions,
139        agent_health,
140        spread_snapshot,
141    };
142
143    let watch_result = tokio::task::spawn_blocking(move || run_watch_tui(&watch_config))
144        .await
145        .context("watch UI thread panicked")?;
146
147    if let Some(handle) = agent_handle {
148        handle.abort();
149        let _ = handle.await;
150    }
151
152    watch_result
153}
154
155pub async fn run_rules(runtime: &RuntimeConfig, command: RulesCommands) -> Result<()> {
156    match command {
157        RulesCommands::Show { file } => show_rules(runtime, file).await,
158        RulesCommands::List => {
159            if runtime.output == crate::output::OutputFormat::Json {
160                list_rules_files(runtime);
161            } else {
162                println!("\n{}", list_rules_files_display());
163                println!();
164            }
165            Ok(())
166        }
167    }
168}