Skip to main content

sandbox_quant/app/
shell.rs

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}