1use std::io::{self, Write};
2
3use crossterm::cursor::{position, MoveToColumn, MoveToNextLine, MoveUp, RestorePosition, SavePosition};
4use crossterm::event::{read, Event, KeyCode, KeyEventKind, KeyModifiers};
5use crossterm::execute;
6use crossterm::style::{Color, Print, PrintStyledContent, Stylize};
7use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size, Clear, ClearType, ScrollUp};
8
9use crate::app::bootstrap::{AppBootstrap, BinanceMode};
10use crate::app::cli::{
11 complete_shell_input_with_description, parse_shell_input, shell_help_text, ShellCompletion,
12 ShellInput,
13};
14use crate::app::output::render_command_output;
15use crate::app::runtime::AppRuntime;
16use crate::exchange::binance::client::BinanceExchange;
17
18pub fn shell_intro_panel(mode: &str, directory: &str) -> String {
19 let width = 46usize;
20 let title = format!(" >_ Sandbox Quant (v{})", env!("CARGO_PKG_VERSION"));
21 let mode_line = format!(" mode: {mode:<18} /mode to change");
22 let dir_line = format!(" directory: {directory}");
23
24 format!(
25 "╭{top}╮\n│{title:<width$}│\n│{blank:<width$}│\n│{mode_line:<width$}│\n│{dir_line:<width$}│\n╰{top}╯",
26 top = "─".repeat(width),
27 title = title,
28 blank = "",
29 mode_line = mode_line,
30 dir_line = dir_line,
31 width = width,
32 )
33}
34
35pub fn run_shell(
36 app: &mut AppBootstrap<BinanceExchange>,
37 runtime: &mut AppRuntime,
38) -> Result<(), Box<dyn std::error::Error>> {
39 let intro_panel = shell_intro_panel(mode_name(current_mode(app)), "~/project/sandbox-quant");
40 execute!(
41 io::stdout(),
42 PrintStyledContent(intro_panel.cyan().bold()),
43 Print("\n"),
44 PrintStyledContent("slash commands".dark_grey()),
45 Print("\n"),
46 Print(shell_help_text()),
47 Print("\n")
48 )?;
49
50 enable_raw_mode()?;
51 let result = loop_shell(app, runtime);
52 disable_raw_mode()?;
53 result
54}
55
56fn loop_shell(
57 app: &mut AppBootstrap<BinanceExchange>,
58 runtime: &mut AppRuntime,
59) -> Result<(), Box<dyn std::error::Error>> {
60 let mut stdout = io::stdout();
61 let mut buffer = String::new();
62 let mut completion_index = 0usize;
63 let mut rendered_menu_lines = 0usize;
64 let mut completion_query: Option<String> = None;
65 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
66
67 loop {
68 if let Event::Key(key) = read()? {
69 if key.kind != KeyEventKind::Press {
70 continue;
71 }
72 match key.code {
73 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
74 println!();
75 break;
76 }
77 KeyCode::Char(ch) => {
78 buffer.push(ch);
79 completion_index = 0;
80 completion_query = Some(buffer.clone());
81 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
82 }
83 KeyCode::Backspace => {
84 buffer.pop();
85 completion_index = 0;
86 completion_query = Some(buffer.clone());
87 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
88 }
89 KeyCode::Tab => {
90 let query = completion_query.clone().unwrap_or_else(|| buffer.clone());
91 let completions = current_completions(app, &query);
92 if completions.len() == 1 {
93 buffer = completions[0].value.clone();
94 completion_index = 0;
95 completion_query = Some(query);
96 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
97 } else if !completions.is_empty() {
98 completion_index = (completion_index + 1) % completions.len();
99 buffer = completions[completion_index].value.clone();
100 completion_query = Some(query);
101 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
102 }
103 }
104 KeyCode::Up => {
105 let query = completion_query.clone().unwrap_or_else(|| buffer.clone());
106 let completions = current_completions(app, &query);
107 if !completions.is_empty() {
108 completion_index = previous_completion_index(completions.len(), completion_index);
109 buffer = completions[completion_index].value.clone();
110 completion_query = Some(query);
111 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
112 }
113 }
114 KeyCode::Down => {
115 let query = completion_query.clone().unwrap_or_else(|| buffer.clone());
116 let completions = current_completions(app, &query);
117 if !completions.is_empty() {
118 completion_index = next_completion_index(completions.len(), completion_index);
119 buffer = completions[completion_index].value.clone();
120 completion_query = Some(query);
121 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
122 }
123 }
124 KeyCode::Enter => {
125 clear_completion_menu(&mut stdout, rendered_menu_lines)?;
126 rendered_menu_lines = 0;
127 println!();
128 let line = buffer.clone();
129 buffer.clear();
130 completion_index = 0;
131 completion_query = None;
132 match parse_shell_input(&line) {
133 Ok(ShellInput::Empty) => {}
134 Ok(ShellInput::Help) => print_plain_block(shell_help_text())?,
135 Ok(ShellInput::Exit) => break,
136 Ok(ShellInput::Mode(mode)) => {
137 match app.switch_mode(mode) {
138 Ok(()) => print_mode_switched(&mut stdout, mode)?,
139 Err(error) => print_error(&mut stdout, error)?,
140 }
141 }
142 Ok(ShellInput::Command(command)) => {
143 let rendered_command = command.clone();
144 match runtime.run(app, command) {
145 Ok(()) => print_command_output(
146 &mut stdout,
147 &rendered_command,
148 &app.portfolio_store,
149 &app.event_log,
150 )?,
151 Err(error) => print_error(&mut stdout, error)?,
152 }
153 }
154 Err(error) => print_error(&mut stdout, error)?,
155 }
156 render_shell(&mut stdout, app, &buffer, completion_index, &mut rendered_menu_lines)?;
157 }
158 _ => {}
159 }
160 }
161 }
162
163 Ok(())
164}
165
166fn render_prompt(
167 stdout: &mut io::Stdout,
168 app: &AppBootstrap<BinanceExchange>,
169 buffer: &str,
170) -> io::Result<()> {
171 let mode = current_mode(app);
172 let status = prompt_status(app);
173 execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
174 execute!(
175 stdout,
176 PrintStyledContent("●".with(mode_color(mode))),
177 Print(" "),
178 PrintStyledContent(format!("[{}]", mode_name(mode)).with(mode_color(mode)).bold()),
179 Print(" "),
180 PrintStyledContent(status.dark_grey()),
181 Print(" "),
182 PrintStyledContent("›".cyan().bold()),
183 Print(" "),
184 Print(buffer)
185 )?;
186 stdout.flush()
187}
188
189fn render_shell(
190 stdout: &mut io::Stdout,
191 app: &AppBootstrap<BinanceExchange>,
192 buffer: &str,
193 completion_index: usize,
194 rendered_menu_lines: &mut usize,
195) -> io::Result<()> {
196 let completions = current_completions(app, buffer);
197 let expected_menu_lines = if should_show_completion_menu(buffer, &completions) {
198 completions.len() + 1
199 } else {
200 0
201 };
202 let scrolled = ensure_vertical_space(stdout, expected_menu_lines)?;
203 if scrolled > 0 {
204 execute!(stdout, MoveUp(scrolled as u16))?;
205 }
206 render_prompt(stdout, app, buffer)?;
207 execute!(stdout, SavePosition)?;
208 let menu_lines = if should_show_completion_menu(buffer, &completions) {
209 print_completion_menu(stdout, &completions, completion_index)?
210 } else {
211 0
212 };
213
214 let lines_to_clear = (*rendered_menu_lines).saturating_sub(menu_lines);
215 for _ in 0..lines_to_clear {
216 execute!(
217 stdout,
218 MoveToNextLine(1),
219 MoveToColumn(0),
220 Clear(ClearType::CurrentLine)
221 )?;
222 }
223
224 *rendered_menu_lines = menu_lines;
225 execute!(stdout, RestorePosition)?;
226 stdout.flush()
227}
228
229fn clear_completion_menu(stdout: &mut io::Stdout, rendered_menu_lines: usize) -> io::Result<()> {
230 execute!(stdout, SavePosition)?;
231 for _ in 0..rendered_menu_lines {
232 execute!(
233 stdout,
234 MoveToNextLine(1),
235 MoveToColumn(0),
236 Clear(ClearType::CurrentLine)
237 )?;
238 }
239 execute!(stdout, RestorePosition)?;
240 stdout.flush()
241}
242
243fn mode_name(mode: BinanceMode) -> &'static str {
244 match mode {
245 BinanceMode::Real => "real",
246 BinanceMode::Demo => "demo",
247 }
248}
249
250fn current_mode(app: &AppBootstrap<BinanceExchange>) -> BinanceMode {
251 match app.exchange.transport_name() {
252 "demo" => BinanceMode::Demo,
253 _ => BinanceMode::Real,
254 }
255}
256
257fn mode_color(mode: BinanceMode) -> Color {
258 match mode {
259 BinanceMode::Real => Color::Green,
260 BinanceMode::Demo => Color::Yellow,
261 }
262}
263
264fn prompt_status(app: &AppBootstrap<BinanceExchange>) -> String {
265 format!(
266 "[{}|{} pos]",
267 staleness_label(app.portfolio_store.staleness),
268 app.portfolio_store.snapshot.positions.len()
269 )
270}
271
272fn staleness_label(staleness: crate::portfolio::staleness::StalenessState) -> &'static str {
273 match staleness {
274 crate::portfolio::staleness::StalenessState::Fresh => "fresh",
275 crate::portfolio::staleness::StalenessState::MarketDataStale => "market-stale",
276 crate::portfolio::staleness::StalenessState::AccountStateStale => "account-stale",
277 crate::portfolio::staleness::StalenessState::ReconciliationStale => "reconcile-stale",
278 }
279}
280
281pub fn format_completion_line(completions: &[ShellCompletion], selected: usize) -> String {
282 completions
283 .iter()
284 .enumerate()
285 .map(|(index, item)| {
286 if index == selected {
287 format!("[{}]", item.value)
288 } else {
289 item.value.clone()
290 }
291 })
292 .collect::<Vec<_>>()
293 .join(" ")
294}
295
296fn print_completion_menu(
297 stdout: &mut io::Stdout,
298 completions: &[ShellCompletion],
299 selected: usize,
300) -> io::Result<usize> {
301 execute!(
302 stdout,
303 MoveToNextLine(1),
304 MoveToColumn(0),
305 Clear(ClearType::CurrentLine),
306 PrintStyledContent("completions".dark_grey()),
307 )?;
308
309 for (index, item) in completions.iter().enumerate() {
310 execute!(stdout, MoveToNextLine(1), MoveToColumn(0), Clear(ClearType::CurrentLine))?;
311 if index == selected {
312 execute!(
313 stdout,
314 PrintStyledContent(">".cyan().bold()),
315 Print(" "),
316 PrintStyledContent(item.value.as_str().black().on_white()),
317 Print(" "),
318 PrintStyledContent(item.description.as_str().dark_grey()),
319 )?;
320 } else {
321 execute!(
322 stdout,
323 Print(" "),
324 PrintStyledContent(item.value.as_str().dark_grey()),
325 Print(" "),
326 PrintStyledContent(item.description.as_str().dark_grey()),
327 )?;
328 }
329 }
330 Ok(completions.len() + 1)
331}
332
333fn print_command_output(
334 stdout: &mut io::Stdout,
335 command: &crate::app::commands::AppCommand,
336 store: &crate::portfolio::store::PortfolioStateStore,
337 event_log: &crate::storage::event_log::EventLog,
338) -> io::Result<()> {
339 print_multiline_block(stdout, &render_command_output(command, store, event_log), true)
340}
341
342fn print_error(stdout: &mut io::Stdout, error: impl std::fmt::Display) -> io::Result<()> {
343 print_multiline_block(stdout, &format!("error: {error}"), false)
344}
345
346fn current_completions(
347 app: &AppBootstrap<BinanceExchange>,
348 buffer: &str,
349) -> Vec<ShellCompletion> {
350 let instruments = app
351 .portfolio_store
352 .snapshot
353 .positions
354 .keys()
355 .map(|instrument| instrument.0.clone())
356 .collect::<Vec<_>>();
357 complete_shell_input_with_description(buffer, &instruments)
358}
359
360fn should_show_completion_menu(buffer: &str, completions: &[ShellCompletion]) -> bool {
361 buffer.trim_start().starts_with('/') && !completions.is_empty()
362}
363
364fn begin_output_block(stdout: &mut io::Stdout) -> io::Result<()> {
365 execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
366 Ok(())
367}
368
369fn print_plain_block(text: &str) -> io::Result<()> {
370 let mut stdout = io::stdout();
371 print_multiline_block(&mut stdout, text, false)
372}
373
374fn print_mode_switched(stdout: &mut io::Stdout, mode: BinanceMode) -> io::Result<()> {
375 print_multiline_block(stdout, &format!("mode switched to {}", mode_name(mode)), false)
376}
377
378fn print_multiline_block(stdout: &mut io::Stdout, text: &str, cyan_output: bool) -> io::Result<()> {
379 for (index, line) in text.lines().enumerate() {
380 if index == 0 {
381 begin_output_block(stdout)?;
382 } else {
383 execute!(stdout, MoveToColumn(0))?;
384 }
385
386 if cyan_output {
387 writeln!(stdout, "{}", line.cyan())?;
388 } else if let Some(rest) = line.strip_prefix("error: ") {
389 writeln!(stdout, "{} {}", "error:".red().bold(), rest.red())?;
390 } else {
391 writeln!(stdout, "{line}")?;
392 }
393 }
394 Ok(())
395}
396
397fn ensure_vertical_space(stdout: &mut io::Stdout, lines_needed: usize) -> io::Result<usize> {
398 if lines_needed == 0 {
399 return Ok(0);
400 }
401
402 let (_, row) = position()?;
403 let (_, height) = size()?;
404 let overflow = scroll_lines_needed(row, height, lines_needed);
405 if overflow > 0 {
406 execute!(stdout, ScrollUp(overflow as u16))?;
407 }
408 Ok(overflow)
409}
410
411pub fn scroll_lines_needed(current_row: u16, terminal_height: u16, lines_needed: usize) -> usize {
412 if terminal_height == 0 {
413 return 0;
414 }
415
416 let last_row = terminal_height.saturating_sub(1) as usize;
417 let current_row = current_row as usize;
418 current_row.saturating_add(lines_needed).saturating_sub(last_row)
419}
420
421pub fn next_completion_index(len: usize, current: usize) -> usize {
422 if len == 0 {
423 0
424 } else {
425 (current + 1) % len
426 }
427}
428
429pub fn previous_completion_index(len: usize, current: usize) -> usize {
430 if len == 0 {
431 0
432 } else if current == 0 {
433 len - 1
434 } else {
435 current - 1
436 }
437}