feat(tui): add decision card widget with keyboard navigation
Implements structured choice cards for the v0.8.43 truth-surface tracker. When Brother Whale needs user input, it surfaces a bordered card with: - A question prompt - Numbered options with (default) marker - Arrow/j/k navigation and 1-9 number-key shortcuts - Enter to confirm, Esc to cancel - Decision results surfaced as status messages The widget compiles and the keyboard routing is wired into the main event loop. Rendering overlay wire-up follows in the next commit.
This commit is contained in:
@@ -1342,6 +1342,9 @@ pub struct App {
|
||||
pub workspace_context_refreshed_at: Option<Instant>,
|
||||
/// Cached background tasks for sidebar rendering.
|
||||
pub task_panel: Vec<TaskPanelEntry>,
|
||||
/// 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<crate::tui::widgets::decision_card::DecisionCard>,
|
||||
/// 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<DecisionOption>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
impl DecisionCard {
|
||||
pub fn new(question: String, options: Vec<DecisionOption>, 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<char> = 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}…")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user