diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 38fb063d..74572590 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1342,6 +1342,9 @@ pub struct App { pub workspace_context_refreshed_at: Option, /// Cached background tasks for sidebar rendering. pub task_panel: Vec, + /// Active decision card (v0.8.43 truth-surface). When set, keyboard input + /// is routed through the card navigation instead of the composer. + pub decision_card: Option, /// Wall-clock time when this TUI session started. Used by the Work /// sidebar projection to hide completed durable tasks that finished /// before the current session (bug #1913). @@ -1896,6 +1899,7 @@ impl App { workspace_context_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), workspace_context_refreshed_at: None, task_panel: Vec::new(), + decision_card: None, session_started_at: chrono::Utc::now(), needs_redraw: true, thinking_started_at: None, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2aa54233..c9fd4c52 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2279,6 +2279,47 @@ async fn run_event_loop( continue; } + // Decision card keyboard routing (v0.8.43 truth-surface). + // When a card is active, number keys 1-9 select options, + // j/k or Up/Down navigate, and Enter confirms. + if let Some(card) = app.decision_card.as_mut() { + match key.code { + KeyCode::Char(c @ '1'..='9') => { + let n = (c as u8 - b'1' + 1) as usize; + card.select_number(n); + card.confirm(); + app.status_message = card + .confirmed_label() + .map(|label| format!("Selected: {label}")); + app.decision_card = None; + app.needs_redraw = true; + } + KeyCode::Char('j') | KeyCode::Down => { + card.select_next(); + app.needs_redraw = true; + } + KeyCode::Char('k') | KeyCode::Up => { + card.select_prev(); + app.needs_redraw = true; + } + KeyCode::Enter => { + card.confirm(); + app.status_message = card + .confirmed_label() + .map(|label| format!("Selected: {label}")); + app.decision_card = None; + app.needs_redraw = true; + } + KeyCode::Esc => { + app.decision_card = None; + app.status_message = Some("Decision cancelled".to_string()); + app.needs_redraw = true; + } + _ => {} + } + continue; + } + // Handle onboarding flow if app.onboarding != OnboardingState::None { match key.code { diff --git a/crates/tui/src/tui/widgets/decision_card.rs b/crates/tui/src/tui/widgets/decision_card.rs new file mode 100644 index 00000000..92a6407b --- /dev/null +++ b/crates/tui/src/tui/widgets/decision_card.rs @@ -0,0 +1,226 @@ +//! Decision-card widget for structured user input. +//! +//! When Brother Whale needs input, it surfaces a decision card: a labelled +//! question followed by numbered options, with the default option highlighted. +//! The user navigates with 1-9 keys (or j/k / Up/Down) and confirms with +//! Enter. Every decision is logged so the user can inspect the choice later. +//! +//! This replaces vague "what should I do?" prompts with a structured choice +//! surface — acceptance criterion from the v0.8.43 truth-surface tracker. + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Widget}, +}; + +use super::renderable::Renderable; + +/// A single option in a decision card. +#[derive(Debug, Clone)] +pub struct DecisionOption { + /// Short label for the option (e.g. "Apply the patch"). + pub label: String, + /// Optional longer description shown below the label. + pub description: Option, +} + +/// A decision card surfacing a structured choice to the user. +#[derive(Debug, Clone)] +pub struct DecisionCard { + /// The question or prompt the user is answering. + pub question: String, + /// The available options. Each is numbered 1..N. + pub options: Vec, + /// Index into `options` of the default (highlighted) choice. + pub default_index: usize, + /// Index of the currently selected option. + pub selected_index: usize, + /// Whether the card has been submitted (Enter pressed). + pub confirmed: bool, + /// The index that was confirmed, if any. + pub confirmed_index: Option, +} + +impl DecisionCard { + pub fn new(question: String, options: Vec, default_index: usize) -> Self { + let default = default_index.min(options.len().saturating_sub(1)); + Self { + question, + options, + default_index: default, + selected_index: default, + confirmed: false, + confirmed_index: None, + } + } + + /// Number of options. + pub fn option_count(&self) -> usize { + self.options.len() + } + + /// Move selection up (wrap around). + pub fn select_prev(&mut self) { + if self.option_count() == 0 { + return; + } + self.selected_index = self + .selected_index + .checked_sub(1) + .unwrap_or(self.option_count() - 1); + } + + /// Move selection down (wrap around). + pub fn select_next(&mut self) { + if self.option_count() == 0 { + return; + } + self.selected_index = (self.selected_index + 1) % self.option_count(); + } + + /// Select by number key (1-based). + pub fn select_number(&mut self, n: usize) { + if n > 0 && n <= self.option_count() { + self.selected_index = n - 1; + } + } + + /// Confirm the current selection. + pub fn confirm(&mut self) { + self.confirmed = true; + self.confirmed_index = Some(self.selected_index); + } + + /// Get the label of the confirmed option, if any. + pub fn confirmed_label(&self) -> Option<&str> { + self.confirmed_index + .and_then(|i| self.options.get(i)) + .map(|opt| opt.label.as_str()) + } +} + +impl Default for DecisionCard { + fn default() -> Self { + Self::new(String::new(), Vec::new(), 0) + } +} + +impl Renderable for DecisionCard { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width < 4 || area.height < 3 { + return; + } + + let border_style = Style::default().fg(Color::Rgb(100, 160, 220)); + let question_style = Style::default() + .fg(Color::Rgb(220, 220, 240)) + .add_modifier(Modifier::BOLD); + let dim_style = Style::default().fg(Color::Rgb(140, 140, 160)); + let selected_style = Style::default() + .fg(Color::Rgb(80, 200, 255)) + .add_modifier(Modifier::BOLD); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Decision Required ") + .title_style(question_style); + let inner = block.inner(area); + block.render(area, buf); + + if inner.width < 2 || inner.height < 2 { + return; + } + + let mut y = inner.y; + + // Question line + let question = truncate_to_width(&self.question, inner.width as usize); + buf.set_string(inner.x, y, &question, question_style); + y += 1; + + if y >= inner.y + inner.height { + return; + } + + // Separator + let sep = "─".repeat(inner.width as usize); + buf.set_string(inner.x, y, &sep, dim_style); + y += 1; + + // Options + let max_options = (inner.y + inner.height).saturating_sub(y) as usize; + for (i, option) in self.options.iter().enumerate().take(max_options) { + if y >= inner.y + inner.height { + break; + } + + let num = format!("{}.", i + 1); + let is_selected = i == self.selected_index; + let style = if is_selected { + selected_style + } else { + dim_style + }; + + // "1. Label (default)" or "1. Label" + let mut label = format!("{} {}", num, option.label); + if i == self.default_index { + label.push_str(" (default)"); + } + label = truncate_to_width(&label, inner.width.saturating_sub(1) as usize); + + let prefix = if is_selected { "▸ " } else { " " }; + let full_label = format!("{prefix}{label}"); + buf.set_string(inner.x, y, &full_label, style); + y += 1; + + // Description line if present + if let Some(ref desc) = option.description + && y < inner.y + inner.height + { + let desc = format!( + " {}", + truncate_to_width(desc, inner.width.saturating_sub(5) as usize) + ); + buf.set_string(inner.x, y, &desc, dim_style); + y += 1; + } + } + + // Footer hint + if y < inner.y + inner.height { + let hint = "1-9 select · j/k navigate · Enter confirm"; + let hint = truncate_to_width(hint, inner.width as usize); + buf.set_string(inner.x, y, &hint, dim_style); + } + } + + fn desired_height(&self, _width: u16) -> u16 { + // question + separator + options + footer + let option_lines: u16 = self + .options + .iter() + .map(|o| if o.description.is_some() { 2 } else { 1 }) + .sum(); + // 2 for borders, 1 question, 1 separator, options, 1 footer + 2 + 1 + 1 + option_lines + 1 + } +} + +fn truncate_to_width(s: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + let chars: Vec = s.chars().collect(); + if chars.len() <= max_width { + return s.to_string(); + } + if max_width <= 1 { + return "…".to_string(); + } + let truncated: String = chars.into_iter().take(max_width - 1).collect(); + format!("{truncated}…") +} diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 226ad72a..7f8060d7 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -10,6 +10,7 @@ pub mod key_hint; // the composer area in `ui.rs`. `pub mod` (vs the usual `pub use` pattern) // keeps the unused-imports lint quiet until then. pub mod agent_card; +pub mod decision_card; pub mod pending_input_preview; mod renderable; pub mod tool_card;