From c73814918972a67290a297531cb1d1f4aefd53d2 Mon Sep 17 00:00:00 2001 From: Lewis Leighton Date: Wed, 6 May 2026 10:38:12 +0100 Subject: [PATCH] feat(tui): press Enter on book cursor to prefill place-order modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Market Detail view, pressing Enter with a book cursor active (from #26 Piece 2) opens the place-order workflow pre-filled from the selected level: - side is flipped from the cursor side (Ask → BUY, Bid → SELL) — the user wants to trade against the liquidity they're inspecting, not add to it. - type is LIMIT (MARKET makes no sense at an explicitly-chosen price). - price and qty come straight from the selected book level; qty is a starting quote — the user can edit before submitting. - workflow lands on Step::Confirm so the common case is one-Enter-to- submit, with Shift+Tab still available to rewind and edit any field. Paper mode gets the same treatment via PaperOrderWorkflow::new_with_prefill (paper inverts order_type 0/1 vs live — noted inline to save the next reader a detour). Plumbing mirrors the existing pending_navigation pattern: MarketTab stages (instrument, TradePrefill) into AppState.pending_trade_from_book; app.rs consumes it after tab.on_key and materializes the workflow. Keeps the tab/app layering clean — tabs never construct workflows. Footer adapts when the cursor is active: `Esc:clear ↑↓:level Enter:trade@level k:chart D:depth(N)`. Help overlay Detail page gets the new binding. Enter without an active cursor remains inert — no accidental empty modals. Tests: 5 leaf tests on the prefill derivation (side-flip both ways, None when cursor inactive / book empty / cursor out of bounds) + 1 app-level test that exercises staging → workflow materialization. Side flip and workflow construction both mutation-verified. Closes #34 --- crates/cdcx-tui/src/app.rs | 52 +++++++++ crates/cdcx-tui/src/lib.rs | 1 + crates/cdcx-tui/src/state.rs | 6 ++ crates/cdcx-tui/src/tabs/market.rs | 108 +++++++++++++++++++ crates/cdcx-tui/src/widgets/detail_view.rs | 19 +++- crates/cdcx-tui/src/workflows/mod.rs | 19 ++++ crates/cdcx-tui/src/workflows/paper_order.rs | 21 +++- crates/cdcx-tui/src/workflows/place_order.rs | 27 ++++- 8 files changed, 247 insertions(+), 6 deletions(-) diff --git a/crates/cdcx-tui/src/app.rs b/crates/cdcx-tui/src/app.rs index 5a6c2f7..9db9012 100644 --- a/crates/cdcx-tui/src/app.rs +++ b/crates/cdcx-tui/src/app.rs @@ -379,6 +379,26 @@ impl App { .map(|tab| tab.on_key(key, &mut self.state)) .unwrap_or(false); + // Handle staged trade prefill from the Market book cursor. The + // tab staged a (instrument, prefill) pair; we materialize it into + // the appropriate workflow here because only `App` owns the + // workflow slot. + if let Some((instrument, prefill)) = self.state.pending_trade_from_book.take() { + if self.state.paper_mode { + self.workflow = Some(Box::new(PaperOrderWorkflow::new_with_prefill( + instrument, prefill, + ))); + } else { + self.workflow = Some(Box::new(PlaceOrderWorkflow::new_with_prefill( + instrument, + &self.state, + prefill, + ))); + } + self.mode = Mode::Workflow; + return; + } + // Handle cross-tab navigation requests (e.g. watchlist Enter → market detail) if let Some((target_tab, instrument)) = self.state.pending_navigation.take() { let origin_tab = TabKind::ALL[self.active_tab]; @@ -1041,6 +1061,7 @@ const HELP_PAGES: &[HelpPage] = &[ bindings: &[ ("Esc", "Clear cursor / back to table"), ("\u{2191}\u{2193}", "Move book cursor between levels"), + ("Enter", "Place LIMIT at cursor level (prefills modal)"), ("k", "Switch to candlestick chart"), ("m", "Switch to compare view"), ("D", "Cycle order-book depth (10/50/150)"), @@ -1238,6 +1259,7 @@ mod tests { volume_unit: crate::state::VolumeUnit::Usd, pending_navigation: None, pending_return_tab: None, + pending_trade_from_book: None, instrument_types: std::collections::HashMap::new(), user_connection: crate::state::ConnectionStatus::Error, isolated_positions: std::collections::HashMap::new(), @@ -1532,4 +1554,34 @@ mod tests { app.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!app.show_help, "Esc must dismiss help"); } + + /// When MarketTab stages a trade prefill in `pending_trade_from_book`, + /// the next app-level key tick must consume it and flip into + /// Workflow mode with the PlaceOrderWorkflow active. The inverse + /// (paper mode → PaperOrderWorkflow) is covered by code review + + /// manual QA; here we pin the live path which is the hot one. + #[test] + fn app_consumes_staged_trade_prefill_into_workflow() { + let mut app = make_app(); + app.state.paper_mode = false; + app.state.pending_trade_from_book = Some(( + "BTC_USDT".into(), + crate::workflows::TradePrefill { + side: "BUY", + price: "78550.2".into(), + qty: "0.125".into(), + }, + )); + + // Send any no-op key through the main dispatch path. The prefill + // handler runs after tab.on_key and must pick up the stage. + app.on_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)); + + assert!( + app.state.pending_trade_from_book.is_none(), + "prefill must be consumed" + ); + assert_eq!(app.mode, Mode::Workflow); + assert!(app.workflow.is_some(), "workflow must have been created"); + } } diff --git a/crates/cdcx-tui/src/lib.rs b/crates/cdcx-tui/src/lib.rs index 0d1cf40..87b5bc9 100644 --- a/crates/cdcx-tui/src/lib.rs +++ b/crates/cdcx-tui/src/lib.rs @@ -313,6 +313,7 @@ pub async fn run(opts: TuiOptions) -> Result<(), Box> { volume_unit: crate::state::VolumeUnit::Usd, pending_navigation: None, pending_return_tab: None, + pending_trade_from_book: None, isolated_positions: std::collections::HashMap::new(), positions_snapshot: Vec::new(), update_notice: None, diff --git a/crates/cdcx-tui/src/state.rs b/crates/cdcx-tui/src/state.rs index 7f6c3c7..3fd3808 100644 --- a/crates/cdcx-tui/src/state.rs +++ b/crates/cdcx-tui/src/state.rs @@ -124,6 +124,12 @@ pub struct AppState { pub pending_navigation: Option<(crate::tabs::TabKind, String)>, /// Return to a previous tab (e.g. Esc from detail navigated via another tab). pub pending_return_tab: Option, + /// Staged trade parameters from the Market book cursor. A non-None + /// value means "Enter was pressed on a selected book level — open the + /// place-order workflow with these pre-filled". Consumed by app.rs + /// after the tab's `on_key` returns. Mirrors the `pending_navigation` + /// pattern — tab signals intent, app.rs instantiates the modal. + pub pending_trade_from_book: Option<(String, crate::workflows::TradePrefill)>, /// instrument_name → isolation_id for currently-open isolated-margin positions. /// Populated from `user.positions` WS channel + `private/get-positions` REST responses. /// Required to add to / trim an existing isolated position without triggering error 617. diff --git a/crates/cdcx-tui/src/tabs/market.rs b/crates/cdcx-tui/src/tabs/market.rs index 1317a0b..2654346 100644 --- a/crates/cdcx-tui/src/tabs/market.rs +++ b/crates/cdcx-tui/src/tabs/market.rs @@ -368,6 +368,32 @@ impl MarketTab { format!("book.{}.{}", self.detail_instrument, self.book_depth) } + /// Build a `TradePrefill` from the current `book_cursor` position, + /// reading price + qty straight from the raw book JSON at the cursor's + /// level. Returns `None` if the cursor is inactive, the book hasn't + /// arrived, or the level has no usable price/qty strings. + /// + /// Side flip: an Ask cursor means the user wants to *buy from* that + /// ask → `side = "BUY"`. Bid cursor → `side = "SELL"`. Documented in + /// the test `prefill_side_flips_*` cases. + fn build_prefill_from_cursor(&self) -> Option { + let cursor = self.book_cursor?; + let book = self.book_data.as_ref()?.get("data")?.as_array()?.first()?; + let (side_key, side_str, idx) = match cursor { + BookCursor::Ask(i) => ("asks", "BUY", i), + BookCursor::Bid(i) => ("bids", "SELL", i), + }; + let level = book.get(side_key)?.as_array()?.get(idx)?; + let arr = level.as_array()?; + let price = arr.first()?.as_str()?.to_string(); + let qty = arr.get(1)?.as_str()?.to_string(); + Some(crate::workflows::TradePrefill { + side: side_str, + price, + qty, + }) + } + /// Re-validate `book_cursor` against the current level counts. Called /// after each `book_data` refresh — depth changes and WS snapshots can /// shrink either side, and a cursor pointing past the end would render @@ -708,6 +734,20 @@ impl Tab for MarketTab { } KeyCode::Down => self.cursor_move_down(), KeyCode::Up => self.cursor_move_up(), + KeyCode::Enter => { + // Enter with a cursor active → stage a trade prefill + // for app.rs to materialize into a workflow modal. + // Without a cursor, Enter is inert on Detail (nothing + // selectable to trade against yet). + if let Some(prefill) = self.build_prefill_from_cursor() { + state.pending_trade_from_book = + Some((self.detail_instrument.clone(), prefill)); + self.book_cursor = None; + true + } else { + false + } + } KeyCode::Char('k') => { self.book_cursor = None; self.enter_chart(state); @@ -2070,4 +2110,72 @@ mod tests { assert_eq!(bps_from_mid(99.0, 100.0), -100.0); assert_eq!(bps_from_mid(100.0, 0.0), 0.0); } + + // ---- Enter-on-cursor → prefill (Issue #34) ---- + + /// Ready a Market tab with a fake book + cursor for the prefill tests. + /// Mirrors the shape that `public/get-book` returns so the same code + /// path (`self.book_data.get("data")[0]["asks"|"bids"]`) is exercised. + fn tab_with_book_and_cursor(cursor: BookCursor) -> MarketTab { + let mut tab = MarketTab::new(); + tab.detail_instrument = "BTC_USDT".into(); + tab.view_mode = ViewMode::Detail; + tab.book_data = Some(serde_json::json!({ + "data": [{ + "asks": [["78550.2", "0.125"], ["78551.0", "0.300"], ["78552.5", "1.000"]], + "bids": [["78549.8", "0.075"], ["78549.0", "0.500"], ["78548.0", "2.000"]], + }] + })); + tab.book_cursor = Some(cursor); + tab + } + + /// Ask cursor → BUY side. The user is inspecting "who's selling at + /// this price" — Enter should set them up to buy into that offer. + #[test] + fn prefill_side_flips_buy_for_ask() { + let tab = tab_with_book_and_cursor(BookCursor::Ask(1)); + let prefill = tab.build_prefill_from_cursor().expect("prefill present"); + assert_eq!(prefill.side, "BUY"); + assert_eq!(prefill.price, "78551.0"); + assert_eq!(prefill.qty, "0.300"); + } + + /// Bid cursor → SELL side. User is inspecting liquidity on the bid; + /// Enter sets them up to sell into that bid. + #[test] + fn prefill_side_flips_sell_for_bid() { + let tab = tab_with_book_and_cursor(BookCursor::Bid(2)); + let prefill = tab.build_prefill_from_cursor().expect("prefill present"); + assert_eq!(prefill.side, "SELL"); + assert_eq!(prefill.price, "78548.0"); + assert_eq!(prefill.qty, "2.000"); + } + + /// No cursor → no prefill. Enter on Detail without ↑↓ pressed first + /// must not accidentally open a workflow with stale state. + #[test] + fn prefill_returns_none_when_cursor_inactive() { + let mut tab = tab_with_book_and_cursor(BookCursor::Ask(0)); + tab.book_cursor = None; + assert!(tab.build_prefill_from_cursor().is_none()); + } + + /// Book hasn't arrived yet → no prefill. Defends against a race + /// between entering Detail and the first REST snapshot landing. + #[test] + fn prefill_returns_none_when_book_empty() { + let mut tab = tab_with_book_and_cursor(BookCursor::Ask(0)); + tab.book_data = None; + assert!(tab.build_prefill_from_cursor().is_none()); + } + + /// Cursor points past the end of its side → None rather than panic. + /// The `clamp_book_cursor` path normally prevents this, but we defend + /// at the leaf in case a clamp is skipped or races a book update. + #[test] + fn prefill_returns_none_when_cursor_out_of_bounds() { + let tab = tab_with_book_and_cursor(BookCursor::Ask(99)); + assert!(tab.build_prefill_from_cursor().is_none()); + } } diff --git a/crates/cdcx-tui/src/widgets/detail_view.rs b/crates/cdcx-tui/src/widgets/detail_view.rs index 21c774b..b1f379e 100644 --- a/crates/cdcx-tui/src/widgets/detail_view.rs +++ b/crates/cdcx-tui/src/widgets/detail_view.rs @@ -101,10 +101,21 @@ pub fn draw_detail( // Footer frame.render_widget( Paragraph::new(Line::from(Span::styled( - format!( - "Esc:back \u{2191}\u{2193}:level k:chart D:depth({}) t:trade", - book_depth - ), + // Footer content shifts when the cursor is active: Enter + // becomes meaningful (trade at selected level) and takes + // priority over the trade-anywhere 't' binding. Users without + // an active cursor see the original hint. + if book_cursor.is_some() { + format!( + "Esc:clear \u{2191}\u{2193}:level Enter:trade@level k:chart D:depth({})", + book_depth + ) + } else { + format!( + "Esc:back \u{2191}\u{2193}:level k:chart D:depth({}) t:trade", + book_depth + ) + }, Style::default().fg(state.theme.colors.muted), ))), footer_area, diff --git a/crates/cdcx-tui/src/workflows/mod.rs b/crates/cdcx-tui/src/workflows/mod.rs index ae11b9b..e1c7c9b 100644 --- a/crates/cdcx-tui/src/workflows/mod.rs +++ b/crates/cdcx-tui/src/workflows/mod.rs @@ -18,6 +18,25 @@ pub enum WorkflowResult { Cancel, } +/// Pre-populated order parameters handed from the Market book-cursor into +/// the place-order workflow. Kept as a flat value type so the constructor +/// signature stays cheap and the shape is trivial to assert in tests. +/// Only LIMIT orders: MARKET prefill makes no semantic sense when the +/// user explicitly picked a price on the book. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TradePrefill { + /// "BUY" or "SELL" — already flipped from the cursor side by the caller + /// (Ask cursor → BUY, Bid cursor → SELL). + pub side: &'static str, + /// Raw price string from the selected book level. Kept as a string so + /// the exchange's native tick precision is preserved (parsing to f64 + /// and back would mangle trailing zeros and crypto decimals). + pub price: String, + /// Default quantity = the selected level's qty. The user is expected + /// to override this; it's a starting quote, not a commitment. + pub qty: String, +} + pub trait Workflow { fn on_key(&mut self, key: KeyEvent, state: &mut AppState) -> WorkflowResult; fn draw(&self, frame: &mut Frame, area: Rect, state: &AppState); diff --git a/crates/cdcx-tui/src/workflows/paper_order.rs b/crates/cdcx-tui/src/workflows/paper_order.rs index 10128be..01bca25 100644 --- a/crates/cdcx-tui/src/workflows/paper_order.rs +++ b/crates/cdcx-tui/src/workflows/paper_order.rs @@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use crate::state::AppState; -use crate::workflows::{modal_area, Workflow, WorkflowResult}; +use crate::workflows::{modal_area, TradePrefill, Workflow, WorkflowResult}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Step { @@ -47,6 +47,25 @@ impl PaperOrderWorkflow { } } + /// Build a paper workflow pre-filled from the Market book cursor. + /// Mirrors `PlaceOrderWorkflow::new_with_prefill`. Paper's order_type + /// encoding is inverted (0 = MARKET, 1 = LIMIT) vs live, so the LIMIT + /// mapping differs — worth a comment to save the next reader a trip + /// to the Step enum. + pub fn new_with_prefill(instrument: String, prefill: TradePrefill) -> Self { + Self { + instrument, + step: Step::Confirm, + side: if prefill.side == "SELL" { 1 } else { 0 }, + order_type: 1, // LIMIT (paper inverts: 0 = MARKET, 1 = LIMIT) + price_input: prefill.price, + qty_input: prefill.qty, + result_msg: None, + result_ok: false, + error: None, + } + } + fn side_str(&self) -> &'static str { if self.side == 0 { "BUY" diff --git a/crates/cdcx-tui/src/workflows/place_order.rs b/crates/cdcx-tui/src/workflows/place_order.rs index cdd4604..2938991 100644 --- a/crates/cdcx-tui/src/workflows/place_order.rs +++ b/crates/cdcx-tui/src/workflows/place_order.rs @@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use crate::state::{AppState, RestRequest}; -use crate::workflows::{modal_area, Workflow, WorkflowResult}; +use crate::workflows::{modal_area, TradePrefill, Workflow, WorkflowResult}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Step { @@ -65,6 +65,31 @@ impl PlaceOrderWorkflow { } } + /// Build a workflow pre-filled from the Market book cursor. Skips + /// straight to `Step::Confirm` so the user sees the full summary and + /// presses Enter once to submit; they can Shift+Tab back to edit any + /// field. LIMIT-only — the prefill DTO enforces this (see + /// `workflows::TradePrefill`). + pub fn new_with_prefill(instrument: String, state: &AppState, prefill: TradePrefill) -> Self { + let isolated_default = state + .instrument_types + .get(&instrument) + .map(|t| requires_isolated_margin(t)) + .unwrap_or(false); + Self { + instrument_input: instrument.clone(), + instrument, + step: Step::Confirm, + side: if prefill.side == "SELL" { 1 } else { 0 }, + order_type: 0, // LIMIT + price_input: prefill.price, + qty_input: prefill.qty, + error: None, + isolated_margin: isolated_default, + rejection: None, + } + } + fn side_str(&self) -> &'static str { if self.side == 0 { "BUY"