grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/views/question_view.rs

3338 lines
120 KiB
Rust
Raw Normal View History

//! Question view state and helpers.
//!
//! When the agent calls `AskUserQuestion`, the pager takes over the prompt
//! area and shows a structured question UI. This module contains:
//!
//! - [`QuestionViewState`] — all state for the question overlay
//! - [`QuestionSelection`] — per-question selection tracking
//! - [`QuestionFocus`] — navigation vs input mode
//!
//! No rendering or input handling here — this is pure data and helpers.
use std::collections::HashSet;
use std::time::Instant;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use xai_acp_lib::AcpResult;
use xai_grok_markdown::StreamingMarkdownRenderer;
pub use xai_grok_tools::implementations::grok_build::ask_user_question::{
AskUserQuestionMode, Question, QuestionOption,
};
use unicode_width::UnicodeWidthStr;
use crate::render::line_utils::{byte_offset_at_width, truncate_line, truncate_str};
use crate::render::wrapping::word_wrap_lines_with_joiners;
use crate::syntax::get_syntect;
use crate::theme::Theme;
use crate::theme::md_style;
use crate::views::prompt_widget::StashedPrompt;
/// Maximum description lines shown in the question chrome before truncation.
const DEFAULT_MAX_CHROME_DESC_LINES: u16 = 5;
/// Maximum preview lines shown in the question chrome before truncation.
const DEFAULT_MAX_CHROME_PREVIEW_LINES: u16 = 6;
/// Minimum number of option rows that must be visible before dynamic cap
/// reduction kicks in. Ensures the user always sees at least a few options.
const MIN_VISIBLE_OPTION_ROWS: u16 = 3;
fn hovered_bg(theme: &Theme) -> ratatui::style::Color {
theme.bg_hover
}
// ── Enums ──────────────────────────────────────────────────────────────
/// Per-question selection state.
#[derive(Debug, Clone)]
pub enum QuestionSelection {
/// Single-choice: at most one option selected.
Single(Option<usize>),
/// Multi-choice: zero or more options toggled on.
Multi(HashSet<usize>),
}
/// Focus mode within the question view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuestionFocus {
/// Cursor is on an option row or the free-form row (not typing).
/// j/k navigate, Enter selects or enters input mode.
Navigation,
/// User is typing in the TextArea (input mode).
/// @-dropdown may be open. Esc exits back to Navigation.
InputMode,
}
/// Pager-internal origin for a locally-opened question (one that was NOT
/// driven by an ACP `x.ai/ask_user_question` request).
///
/// Drives what `submit_question_answers` returns when the user submits.
/// Mutually exclusive with `QuestionViewState.response_tx`: a local
/// question never has an ACP sender.
///
/// Each variant carries the data the local handler needs to translate the
/// submitted selection into an [`crate::app::actions::Action`].
#[derive(Debug, Clone)]
pub enum LocalQuestionKind {
/// Modal opened by `/fork` to resolve the worktree question.
/// On submit, the selected option index plus the carried directive
/// are translated into an
/// [`crate::app::actions::Action::ForkAnswered`].
Fork {
/// Optional directive supplied via `/fork <directive>`. Stashed
/// here so the modal can carry it across the synchronous return
/// path back to `dispatch_fork_resolved` without a global mailbox.
directive: Option<String>,
},
/// Shown on first prompt from a non-project directory.
ProjectSelect {
/// Index-aligned with the leading question options. Direct lookup by
/// selection index.
resolved_paths: Vec<std::path::PathBuf>,
/// The original cwd (fallback on cancel/skip).
original_cwd: std::path::PathBuf,
/// The prompt text the user typed (stashed to re-send after selection).
stashed_prompt: String,
/// Option index of the "Don't ask me again" entry. Selecting it
/// continues in `original_cwd` and persists the opt-out.
dont_ask_index: usize,
},
/// Modal opened by `/new` to resolve the worktree question.
/// On submit, the selected option index is translated into an
/// [`crate::app::actions::Action::NewSessionAnswered`].
NewSession,
/// Modal shown when the user hits the credit/rate limit (403).
/// Options map to upsell URLs: upgrade tier or enable on-demand.
/// `choices` maps each option index to a telemetry choice variant.
CreditLimitUpsell {
choices: Vec<xai_grok_telemetry::events::CreditLimitChoice>,
},
/// SuperGrok upsell modal: the free-usage paywall (429 +
/// `subscription:free-usage-exhausted`) or a tier-restricted slash
/// command invocation. Upgrade options carry their URL in the option
/// `id`.
FreeUsageUpsell {
/// Telemetry source for `SuperGrokUpsellClicked` — distinguishes
/// the paywall from the restricted-command upsell.
source: xai_grok_telemetry::events::SuperGrokUpsell,
},
/// Modal shown when the shell rejects a model switch due to agent
/// type incompatibility. Carries the target model + effort so the
/// answer handler can create a new session with it.
AgentTypeMismatch {
model_id: agent_client_protocol::ModelId,
effort: Option<xai_grok_shell::sampling::types::ReasoningEffort>,
},
Synced from monorepo Changes: - Non-blocking coding-data sharing upsell banner - Consolidate remediation in Doctor - Auto mode defers fail-closed gate asks to the classifier - Coalesce marketplace list fetches - Allow removing a marketplace source by name - Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal) - Label failed workspace RPCs with error_kind - Drop redundant explicit tonic/prost deps from xai-grok-shell - Report real exit codes for completed background shells - Narrow the date-rollover reminder to date-bearing templates - Wire toolOverrides through the session and agent - Security: Bash(git:*) allowlist matches whole command chain by prefix - Split prompt-trigger telemetry and record classifier provenance - Raise connectors-manager timeout to 60s - Auto classifier honors recorded approvals for repeat actions - Apply doctor fixes in the TUI - Auto-mode classifier timeouts prompt instead of silently denying - Scope subagent completion drains to the owning session - Add the toolOverrides wire types - Set client_identifier=grok-agent-sdk - Accept both spellings of the workspace-teleport kill switch - Persist one-shot occurrence journal - Stop turns that poll the exact same tool call 16x in a row - Copy compaction checkpoint files when forking sessions - Auto-focus permission prompt from scrollback - Esc cancels the running turn in non-vim and minimal modes - List Ctrl+Z undo and redo in keyboard shortcuts - Out-of-process macOS mic capture - Show active auth mode on session-info - Install the npm binary under $GROK_HOME - Remove hover/click dead zones between dashboard items - Route startup warnings to doctor - Document [feedback.user] author identity config - Extend bang command timeout - Close combine-queued edit-hold race - Integrate relocation recovery - Expose privacy notice rollout flag - Break harness discovery ref cycle so connections can idle-evict - Shift/Alt+Enter inserts newline when editing a queued prompt - Gate project Claude permissions on folder trust - Echo response.create.event_id on response.created - Toast when session creation fails from disk full - Add shared test process lifecycle - Enable dynamic workflows by default - Add relocation transaction state machine - Add shared test sandbox - Surface auth failures on model-switch compact - Persist durable scheduler expiry - Confirm before removing extensions-modal items - Re-run compact and prompt after login when compact hit expired auth - Recap sends hosted tools under backend search
2026-07-22 19:18:53 +01:00
DoctorFix {
target: crate::app::actions::DoctorFixTarget,
plan: Box<crate::diagnostics::FixPlan>,
},
}
// ── State ──────────────────────────────────────────────────────────────
/// Complete state for the question view overlay.
///
/// Created when an `x.ai/ask_user_question` ext-method request arrives;
/// destroyed on submit, skip, or cancel.
///
/// Not `Clone` because it owns a `oneshot::Sender` for the ACP response.
#[derive(Debug)]
pub struct QuestionViewState {
/// The tool call ID of the `AskUserQuestion` invocation.
pub tool_call_id: String,
/// The questions to present.
pub questions: Vec<Question>,
/// Which question is currently shown (0-based index).
pub active_tab: usize,
/// Per-question selection state (same length as `questions`).
pub selections: Vec<QuestionSelection>,
/// Current focus mode.
pub focus: QuestionFocus,
/// Whether fullscreen mode is active (removes height cap).
pub fullscreen: bool,
/// Original prompt state, stashed on entry and restored on exit.
pub stashed_prompt: StashedPrompt,
/// Cursor position per question (index into options + freeform row).
pub per_question_cursor: Vec<usize>,
/// Scroll offset (visual lines) per question.
pub per_question_scroll: Vec<u16>,
/// Per-question freeform text (additional context). Each question has
/// its own text so switching tabs doesn't mix content.
pub per_question_freeform: Vec<String>,
/// Whether the per-question freeform answer is "selected" (included in
/// submission). Toggled by Space, auto-set when exiting InputMode with
/// text. Independent of the text content — text is preserved on untoggle.
pub per_question_freeform_selected: Vec<bool>,
// ── Cached chrome caps (recomputed on resize / question switch) ──
/// Cached cap on description lines in chrome (capped in non-fullscreen).
pub cached_desc_cap: u16,
/// Cached cap on preview lines in chrome (capped in non-fullscreen).
pub cached_preview_cap: u16,
// ── ACP response channel (TS-04) ──
/// Stashed ACP response sender. When the user submits/cancels, the
/// pager serializes the response and sends it here. `take()` ensures
/// we never send twice.
pub response_tx:
Option<tokio::sync::oneshot::Sender<AcpResult<agent_client_protocol::ExtResponse>>>,
/// Mode context from the ext-method request. Controls whether the
/// bottom panel (Chat about this / Skip interview) is shown.
pub mode: AskUserQuestionMode,
/// Bottom panel selection index (plan mode only).
/// `None` = options list has focus, `Some(0)` = Chat about this,
/// `Some(1)` = Skip interview.
pub bottom_panel_index: Option<usize>,
/// `Some` when this question was opened locally (e.g. by `/fork`)
/// instead of by an ACP `x.ai/ask_user_question` request. `None` for
/// ACP questions (preserves today's behaviour).
///
/// Mutually exclusive with `response_tx`: a local question never has
/// an ACP sender.
pub local_kind: Option<LocalQuestionKind>,
/// When this question view was created. Used to pause the turn timer
/// while the user is answering questions — the time spent in the
/// question view is subtracted from the turn elapsed display.
pub opened_at: Instant,
/// When `true`, the freeform "Other" input row is hidden. Used by
/// locally-driven questions (e.g. credit-limit upsell) that only
/// offer fixed options with no free-text fallback.
pub no_freeform: bool,
}
// ── Constructor & basic helpers ────────────────────────────────────────
impl QuestionViewState {
/// Create a new question view state.
///
/// Initializes per-question vectors (selections, cursors, scroll)
/// based on each question's type (single vs multi-select).
pub fn new(
tool_call_id: String,
questions: Vec<Question>,
stashed_prompt: StashedPrompt,
) -> Self {
Self::with_response_tx(
tool_call_id,
questions,
stashed_prompt,
None,
AskUserQuestionMode::Default,
)
}
/// Create a new question view state with an ACP response sender.
///
/// Called by the `ExtMethod` handler when a blocking `x.ai/ask_user_question`
/// request arrives from the shell coordinator.
pub fn with_response_tx(
tool_call_id: String,
questions: Vec<Question>,
stashed_prompt: StashedPrompt,
response_tx: Option<
tokio::sync::oneshot::Sender<AcpResult<agent_client_protocol::ExtResponse>>,
>,
mode: AskUserQuestionMode,
) -> Self {
let n = questions.len();
let selections: Vec<QuestionSelection> = questions
.iter()
.map(|q| {
if q.multi_select.unwrap_or(false) {
QuestionSelection::Multi(HashSet::new())
} else {
QuestionSelection::Single(None)
}
})
.collect();
Self {
tool_call_id,
questions,
active_tab: 0,
selections,
focus: QuestionFocus::Navigation,
fullscreen: false,
stashed_prompt,
per_question_cursor: vec![0; n],
per_question_scroll: vec![0; n],
per_question_freeform: vec![String::new(); n],
per_question_freeform_selected: vec![false; n],
cached_desc_cap: DEFAULT_MAX_CHROME_DESC_LINES,
cached_preview_cap: DEFAULT_MAX_CHROME_PREVIEW_LINES,
response_tx,
mode,
bottom_panel_index: None,
local_kind: None,
opened_at: Instant::now(),
no_freeform: false,
}
}
/// Builder-style helper to attach a [`LocalQuestionKind`].
///
/// Used by `open_fork_question` to mark a freshly-built
/// `QuestionViewState` as locally-driven so submit/cancel routes
/// through the synchronous `Action` path instead of the ACP
/// `response_tx`. Returns `self` so the call site can chain it on
/// the constructor.
pub fn with_local_kind(mut self, kind: LocalQuestionKind) -> Self {
self.local_kind = Some(kind);
self
}
/// Builder-style helper to hide the freeform "Other" input row.
pub fn with_no_freeform(mut self) -> Self {
self.no_freeform = true;
self
}
/// Number of items for a given question: options + 1 free-form row
/// (unless `no_freeform` is set).
pub fn total_items(&self, question_idx: usize) -> usize {
let freeform = if self.no_freeform { 0 } else { 1 };
self.questions
.get(question_idx)
.map(|q| q.options.len() + freeform)
.unwrap_or(1)
}
/// Whether the cursor is on the free-form row for the active question.
pub fn is_on_freeform_row(&self) -> bool {
if self.no_freeform {
return false;
}
let idx = self.active_tab;
self.cursor()
== self
.questions
.get(idx)
.map(|q| q.options.len())
.unwrap_or(0)
}
/// Current cursor position for the active question.
pub fn cursor(&self) -> usize {
self.per_question_cursor
.get(self.active_tab)
.copied()
.unwrap_or(0)
}
/// Set cursor position for the active question, clamped to valid range.
pub fn set_cursor(&mut self, pos: usize) {
let max = self.total_items(self.active_tab).saturating_sub(1);
let clamped = pos.min(max);
if let Some(c) = self.per_question_cursor.get_mut(self.active_tab) {
*c = clamped;
}
}
/// Adjust scroll so the cursor row is visible within `visible_h` lines.
///
/// Call this after every cursor change. `content_w` is needed to compute
/// per-option visual heights for stacked layout.
pub fn ensure_cursor_visible(&mut self, visible_h: u16, content_w: usize) {
let q_idx = self.active_tab;
let Some(question) = self.questions.get(q_idx) else {
return;
};
let cursor = self.cursor();
let heights = option_heights(question, content_w, cursor);
let scroll = self.per_question_scroll.get(q_idx).copied().unwrap_or(0);
// Compute the visual Y range of the cursor item.
let cursor_top: u16 = heights[..cursor].iter().sum();
let cursor_bottom = cursor_top + heights.get(cursor).copied().unwrap_or(1);
let mut new_scroll = scroll;
// If cursor is above the visible window, scroll up.
if cursor_top < new_scroll {
new_scroll = cursor_top;
}
// If cursor is below the visible window, scroll down.
if cursor_bottom > new_scroll + visible_h {
new_scroll = cursor_bottom.saturating_sub(visible_h);
}
if let Some(s) = self.per_question_scroll.get_mut(q_idx) {
*s = new_scroll;
}
}
/// Clamp the active question's scroll offset to the current viewport.
pub fn clamp_scroll(&mut self, visible_h: u16, content_w: usize) {
let q_idx = self.active_tab;
let Some(question) = self.questions.get(q_idx) else {
return;
};
let max_scroll = total_options_height(question, content_w, self.cursor())
.saturating_sub(self.phantom_freeform_h())
.saturating_sub(visible_h);
if let Some(s) = self.per_question_scroll.get_mut(q_idx) {
*s = (*s).min(max_scroll);
}
}
/// Height of the freeform line that [`option_heights`] /
/// [`total_options_height`] always include but which is never rendered
/// when `no_freeform` is set. Subtract this from those totals wherever
/// they feed layout or scroll limits.
pub fn phantom_freeform_h(&self) -> u16 {
if self.no_freeform { 1 } else { 0 }
}
}
/// Visual heights for each item in a question: all options, then freeform.
pub fn option_heights(question: &Question, content_w: usize, cursor: usize) -> Vec<u16> {
let prefix_w = option_prefix_w(question);
let max_lw = compute_max_label_w(&question.options, content_w);
question
.options
.iter()
.enumerate()
.map(|(i, o)| option_visual_height(o, content_w, prefix_w, max_lw, i == cursor))
.chain(std::iter::once(1u16))
.collect()
}
/// Total visual height of all option rows plus the freeform row.
pub fn total_options_height(question: &Question, content_w: usize, cursor: usize) -> u16 {
option_heights(question, content_w, cursor)
.into_iter()
.sum()
}
/// Map a visual line offset within the scrolled options list to an item index.
pub fn item_index_at_visual_line(
question: &Question,
content_w: usize,
visual_line: u16,
cursor: usize,
) -> usize {
let heights = option_heights(question, content_w, cursor);
let mut top = 0u16;
for (idx, height) in heights.iter().copied().enumerate() {
if visual_line < top + height {
return idx;
}
top += height;
}
heights.len().saturating_sub(1)
}
pub fn item_top_offset(
question: &Question,
content_w: usize,
item_index: usize,
cursor: usize,
) -> u16 {
let heights = option_heights(question, content_w, cursor);
let clamped = item_index.min(heights.len());
heights[..clamped].iter().sum()
}
pub fn scroll_offset_for_item_delta(
question: &Question,
content_w: usize,
current_scroll: u16,
delta: i32,
viewport_height: u16,
cursor: usize,
phantom_freeform_h: u16,
) -> u16 {
// Line-based scrolling: add delta directly to the scroll offset,
// clamped to [0, max_scroll]. Each visual line (including wrapped
// description lines) is independent, so we scroll by individual lines
// instead of jumping whole items.
//
// `phantom_freeform_h` (see [`QuestionViewState::phantom_freeform_h`])
// removes the never-rendered freeform line from the scrollable total
// for `no_freeform` questions.
let max_scroll = total_options_height(question, content_w, cursor)
.saturating_sub(phantom_freeform_h)
.saturating_sub(viewport_height);
((current_scroll as i32) + delta).clamp(0, max_scroll as i32) as u16
}
/// Visible option rows height within a rendered question area.
pub fn visible_options_height(
question: &Question,
area_height: u16,
content_w: usize,
preview: Option<&str>,
fullscreen: bool,
desc_cap: u16,
preview_cap: u16,
) -> u16 {
area_height.saturating_sub(chrome_height(
question,
content_w,
preview,
fullscreen,
desc_cap,
preview_cap,
))
}
/// Maximum scroll offset for the option rows in a rendered question area.
#[allow(clippy::too_many_arguments)]
pub fn max_scroll_offset(
question: &Question,
area_height: u16,
content_w: usize,
preview: Option<&str>,
fullscreen: bool,
desc_cap: u16,
preview_cap: u16,
cursor: usize,
) -> u16 {
total_options_height(question, content_w, cursor).saturating_sub(visible_options_height(
question,
area_height,
content_w,
preview,
fullscreen,
desc_cap,
preview_cap,
))
}
/// Resolve the item index for a screen row within the rendered question area.
#[allow(clippy::too_many_arguments)]
pub fn item_index_at_screen_row(
question: &Question,
area: Rect,
content_w: usize,
scroll: u16,
row: u16,
preview: Option<&str>,
fullscreen: bool,
desc_cap: u16,
preview_cap: u16,
cursor: usize,
) -> Option<usize> {
let options_start_y = area.y
+ chrome_height(
question,
content_w,
preview,
fullscreen,
desc_cap,
preview_cap,
);
let options_end_y = area.y + area.height;
if row < options_start_y || row >= options_end_y {
return None;
}
let visual_line = (row - options_start_y) + scroll;
Some(item_index_at_visual_line(
question,
content_w,
visual_line,
cursor,
))
}
// ── Layout helpers ─────────────────────────────────────────────────────
/// Compute the aligned label column width.
///
/// The column fits the longest label, capped at 60% of the available
/// width so labels are always visible while the collapsed description
/// (with its `…` affordance) keeps the remaining space. Labels longer
/// than the cap are truncated with `…` on unfocused rows and get
/// stacked/wrapped layout when focused.
pub fn compute_max_label_w(options: &[QuestionOption], content_w: usize) -> usize {
let cap = content_w * 3 / 5;
options
.iter()
.map(|o| normalize_label(&o.label).width())
.max()
.unwrap_or(0)
.min(cap)
}
/// Visual height of a single option row.
///
/// - Unfocused: always 1 line (collapsed `label description…`).
/// - Focused: full description. The label shares the first description line when
/// it fits the column (description wrapped in the column to its right); an
/// overflowing label wraps full-width with the description stacked below it,
/// both indented at `prefix_w`.
pub fn option_visual_height(
option: &QuestionOption,
content_w: usize,
prefix_w: usize,
max_label_w: usize,
focused: bool,
) -> u16 {
if !focused {
return 1;
}
let gap = 2;
let norm_label = normalize_label(&option.label);
if norm_label.width() > max_label_w {
let wide_w = content_w.saturating_sub(prefix_w).max(1);
let label_lines = wrap_label_chunks(&norm_label, wide_w).len() as u16;
let desc_lines = rendered_option_description_lines(option, wide_w).len() as u16;
(label_lines + desc_lines).max(1)
} else {
let indent = prefix_w + max_label_w + gap;
let desc_w = content_w.saturating_sub(indent).max(1);
let desc_lines = rendered_option_description_lines(option, desc_w).len() as u16;
desc_lines.max(1)
}
}
/// Inner chrome-height computation with explicit description/preview caps.
///
/// Same logic as [`chrome_height`] but accepts caps as parameters instead of
/// branching on `fullscreen`. Used by the dynamic-cap fallback in
/// [`question_view_height`].
fn chrome_height_with_dynamic_caps(
question: &Question,
content_w: usize,
preview: Option<&str>,
desc_cap: u16,
preview_cap: u16,
) -> u16 {
let wrap_w = content_w.max(1);
let (label, desc) = split_question_label_desc(&question.question);
let raw_line = Line::from(vec![Span::raw(label.to_string())]);
let label_lines = crate::render::wrapping::word_wrap_line(&raw_line, wrap_w)
.len()
.max(1) as u16;
let desc_lines = if desc.is_empty() {
0u16
} else {
rendered_option_description_lines(
&QuestionOption {
label: String::new(),
description: desc.to_string(),
preview: None,
id: None,
},
wrap_w,
)
.len() as u16
};
let preview_lines: u16 = match preview {
Some(p) if !p.is_empty() => p
.lines()
.map(|l| {
let raw = Line::from(vec![Span::raw(l.to_string())]);
crate::render::wrapping::word_wrap_line(&raw, wrap_w)
.len()
.max(1) as u16
})
.sum(),
_ => 0,
};
let desc_lines = desc_lines.min(desc_cap);
let preview_lines = preview_lines.min(preview_cap);
let preview_gap = if preview_lines > 0 { 1 } else { 0 };
// vpad(1) + label + label_gap(1) + description (if any)
// + [preview_gap(1) + preview_lines if preview exists] + gap(1)
1 + label_lines + 1 + desc_lines + preview_gap + preview_lines + 1
}
/// Chrome height for a question: vpad + label lines + gap + [description lines] + gap.
///
/// The label (first paragraph of the question text) word-wraps across
/// multiple lines. If the question contains a paragraph break (`\n\n`),
/// the remaining text is rendered as a description below the label.
/// Must match `render_question_chrome`.
///
/// When `fullscreen` is false, description and preview lines are capped to
/// `desc_cap` / `preview_cap` respectively (with room for a truncation
/// indicator). When `fullscreen` is true the caps are ignored and all
/// lines are counted.
pub fn chrome_height(
question: &Question,
content_w: usize,
preview: Option<&str>,
fullscreen: bool,
desc_cap: u16,
preview_cap: u16,
) -> u16 {
if fullscreen {
chrome_height_with_dynamic_caps(question, content_w, preview, u16::MAX, u16::MAX)
} else {
chrome_height_with_dynamic_caps(question, content_w, preview, desc_cap, preview_cap)
}
}
/// Split question text into a label (first paragraph) and description (rest).
///
/// A paragraph break is `\n\n`. If no break exists, the full text is the
/// label and the description is empty.
fn split_question_label_desc(text: &str) -> (&str, &str) {
if let Some(pos) = text.find("\n\n") {
(text[..pos].trim(), text[pos + 2..].trim())
} else {
(text.trim(), "")
}
}
// ── Selection helpers ──────────────────────────────────────────────────
impl QuestionViewState {
/// Toggle an option for a question.
///
/// - Multi: toggle in/out of the HashSet.
/// - Single: set to `Some(option_idx)`, or `None` if already selected
/// (deselect).
pub fn toggle_option(&mut self, question_idx: usize, option_idx: usize) {
let Some(sel) = self.selections.get_mut(question_idx) else {
return;
};
match sel {
QuestionSelection::Multi(set) => {
if !set.remove(&option_idx) {
set.insert(option_idx);
}
}
QuestionSelection::Single(current) => {
if *current == Some(option_idx) {
*current = None;
} else {
*current = Some(option_idx);
}
}
}
}
/// Select an option (no toggle — always selects).
///
/// - Single: set to `Some(option_idx)`.
/// - Multi: add to set.
pub fn select_option(&mut self, question_idx: usize, option_idx: usize) {
let Some(sel) = self.selections.get_mut(question_idx) else {
return;
};
match sel {
QuestionSelection::Multi(set) => {
set.insert(option_idx);
}
QuestionSelection::Single(current) => {
*current = Some(option_idx);
}
}
}
/// Activate freeform input for the active question.
///
/// Marks the freeform row as selected, clears the option selection
/// (single-select exclusivity), sets focus to `InputMode`, and returns
/// the current freeform text so the caller can load it into the prompt.
///
/// No-op returning an empty string when `no_freeform` is set: such
/// questions (e.g. the SuperGrok upsell) have no freeform row, so
/// `InputMode` must be unreachable. Callers gate on `no_freeform` /
/// [`Self::is_on_freeform_row`] too; this is defense in depth.
pub fn activate_freeform_input(&mut self) -> String {
if self.no_freeform {
return String::new();
}
let idx = self.active_tab;
if let Some(sel) = self.per_question_freeform_selected.get_mut(idx) {
*sel = true;
}
if let Some(QuestionSelection::Single(sel)) = self.selections.get_mut(idx) {
*sel = None;
}
self.focus = QuestionFocus::InputMode;
self.per_question_freeform
.get(idx)
.cloned()
.unwrap_or_default()
}
/// Preview text for the currently focused option, if any.
///
/// Returns `Some(preview)` when the cursor is on an option (not freeform)
/// and that option has a `preview` field set.
pub fn focused_preview(&self) -> Option<&str> {
let q = self.questions.get(self.active_tab)?;
let cursor = self.cursor();
let option = q.options.get(cursor)?;
option.preview.as_deref()
}
/// Labels of the selected options for a given question.
pub fn selected_labels(&self, question_idx: usize) -> Vec<String> {
let Some(sel) = self.selections.get(question_idx) else {
return Vec::new();
};
let Some(q) = self.questions.get(question_idx) else {
return Vec::new();
};
match sel {
QuestionSelection::Multi(set) => {
let mut indices: Vec<_> = set.iter().copied().collect();
indices.sort_unstable();
indices
.into_iter()
.filter_map(|i| q.options.get(i).map(|o| o.label.clone()))
.collect()
}
QuestionSelection::Single(Some(idx)) => q
.options
.get(*idx)
.map(|o| vec![o.label.clone()])
.unwrap_or_default(),
QuestionSelection::Single(None) => Vec::new(),
}
}
/// True when the active tab has any option selected, or its free-form
/// answer marked selected. Drives the graduated `Esc` back-out: when
/// nothing is selected, `Esc` (which only clears the selection) has
/// nothing to do, so it can fall through to the dashboard back-out.
pub fn active_tab_has_selection(&self) -> bool {
let idx = self.active_tab;
let option_selected = !self.selected_labels(idx).is_empty();
let freeform_selected = self
.per_question_freeform_selected
.get(idx)
.copied()
.unwrap_or(false);
option_selected || freeform_selected
}
}
// ── ACP response builders (TS-05) ─────────────────────────────────────
impl QuestionViewState {
/// Build the `Accepted` ext-method response from the current state.
///
/// Rules:
/// - Only answered questions appear in `answers` (unanswered omitted).
/// - Multi-select: labels joined with `, `.
/// - Freeform-only (no option, only typed text): label = `"Other"`,
/// typed text in `annotations[q].notes`.
/// - Preview included for single-select only, verbatim from the option.
/// - Notes included when freeform text is non-empty and selected.
pub fn build_accepted_response(
&self,
) -> xai_grok_tools::implementations::grok_build::ask_user_question::AskUserQuestionExtResponse
{
use indexmap::IndexMap;
use std::collections::HashMap;
use xai_grok_tools::implementations::grok_build::ask_user_question::{
AskUserQuestionExtResponse, QuestionAnnotation,
};
let mut answers = IndexMap::new();
let mut annotations: HashMap<String, QuestionAnnotation> = HashMap::new();
for (i, q) in self.questions.iter().enumerate() {
let labels = self.selected_labels(i);
let freeform_selected = self
.per_question_freeform_selected
.get(i)
.copied()
.unwrap_or(false);
let freeform_text = self
.per_question_freeform
.get(i)
.cloned()
.unwrap_or_default();
let has_freeform = freeform_selected && !freeform_text.trim().is_empty();
if labels.is_empty() && !has_freeform {
// Unanswered — omit from answers.
continue;
}
// Build the per-question label vec: one element for
// single-select, multiple for multi-select, or `["Other"]` when
// only the freeform input was used. The wire format carries
// these as separate elements so downstream cursor-shape
// resolvers do not have to re-split a comma-joined string.
let label_vec: Vec<String> = if labels.is_empty() && has_freeform {
vec!["Other".to_string()]
} else {
labels
};
answers.insert(q.question.clone(), label_vec);
// Build annotation if there's preview or notes.
let is_single = !q.multi_select.unwrap_or(false);
let preview = if is_single {
// Preview from selected option (single-select only).
match &self.selections[i] {
QuestionSelection::Single(Some(idx)) => {
q.options.get(*idx).and_then(|o| o.preview.clone())
}
_ => None,
}
} else {
None
};
let notes = if has_freeform {
Some(freeform_text)
} else {
None
};
if preview.is_some() || notes.is_some() {
annotations.insert(q.question.clone(), QuestionAnnotation { preview, notes });
}
}
let annotations = if annotations.is_empty() {
None
} else {
Some(annotations)
};
AskUserQuestionExtResponse::Accepted {
answers,
annotations,
}
}
/// Send the ACP ext-method response and return `true` if the response
/// was actually sent (i.e. `response_tx` was present).
///
/// After sending, `response_tx` is consumed (set to `None`) to prevent
/// double-send.
pub fn send_ext_response(
&mut self,
response: xai_grok_tools::implementations::grok_build::ask_user_question::AskUserQuestionExtResponse,
) -> bool {
let Some(tx) = self.response_tx.take() else {
return false;
};
let raw = serde_json::value::to_raw_value(&response)
.expect("AskUserQuestionExtResponse serialization should not fail");
tx.send(Ok(agent_client_protocol::ExtResponse::new(raw.into())))
.ok();
true
}
}
// ── Tab cycling ────────────────────────────────────────────────────────
impl QuestionViewState {
/// Advance to the next question (clamped, no wrap).
pub fn next_question(&mut self) {
if self.active_tab + 1 < self.questions.len() {
self.active_tab += 1;
}
}
/// Go to the previous question (clamped, no wrap).
pub fn prev_question(&mut self) {
self.active_tab = self.active_tab.saturating_sub(1);
}
}
// ── Rendering ──────────────────────────────────────────────────────────
/// Desired height for the question view overlay.
///
/// Height cap: 33% of `screen_h`, clamped to min 8, max 80%.
/// Fullscreen mode removes the cap.
///
/// When the chrome (label + description + preview) would leave fewer than
/// [`MIN_VISIBLE_OPTION_ROWS`] visible option rows, the description and
/// preview caps are dynamically reduced so at least that many rows remain
/// when the terminal is large enough for the fixed chrome overhead. On
/// extremely small terminals this is best-effort — the guarantee may not
/// hold when even zero desc/preview lines cannot free enough space.
/// The effective caps are written to `state.cached_desc_cap` /
/// `state.cached_preview_cap` so the renderer uses matching values.
pub fn question_view_height(state: &mut QuestionViewState, screen_h: u16, content_w: usize) -> u16 {
let q_idx = state.active_tab;
let Some(question) = state.questions.get(q_idx) else {
return 0;
};
// `total_options_height` unconditionally counts a 1-line freeform row;
// when `no_freeform` is set that row is never rendered, so subtract it
// from the totals below — otherwise the panel keeps a clickable dead
// row under the last option.
let phantom_freeform = state.phantom_freeform_h();
let freeform_h: u16 = 1 - phantom_freeform;
let min_options_space = MIN_VISIBLE_OPTION_ROWS + freeform_h;
if state.fullscreen {
// desc_cap/preview_cap are ignored when fullscreen=true (chrome_height
// routes to u16::MAX internally), but pass MAX for clarity.
let chrome_h = chrome_height(
question,
content_w,
state.focused_preview(),
true,
u16::MAX,
u16::MAX,
);
let total = chrome_h
+ total_options_height(question, content_w, state.cursor())
.saturating_sub(phantom_freeform);
state.cached_desc_cap = u16::MAX;
state.cached_preview_cap = u16::MAX;
return total.min(screen_h);
}
let cap = (screen_h as u32 * 33 / 100)
.max(8)
.min(screen_h as u32 * 80 / 100) as u16;
let mut effective_desc_cap = DEFAULT_MAX_CHROME_DESC_LINES;
let mut effective_preview_cap = DEFAULT_MAX_CHROME_PREVIEW_LINES;
let mut chrome_h = chrome_height_with_dynamic_caps(
question,
content_w,
state.focused_preview(),
effective_desc_cap,
effective_preview_cap,
);
if chrome_h + min_options_space > cap {
// Compute fixed overhead (vpad + label + gaps).
let (label, desc) = split_question_label_desc(&question.question);
let raw_line = Line::from(vec![Span::raw(label.to_string())]);
let label_lines = crate::render::wrapping::word_wrap_line(&raw_line, content_w.max(1))
.len()
.max(1) as u16;
let fixed_overhead = 1 + label_lines + 1 + 1; // vpad + label + blank + bottom gap
// Compute actual description line count so unused desc budget can
// be reallocated to preview instead of being wasted.
let actual_desc_lines = if desc.is_empty() {
0u16
} else {
rendered_option_description_lines(
&QuestionOption {
label: String::new(),
description: desc.to_string(),
preview: None,
id: None,
},
content_w.max(1),
)
.len() as u16
};
let content_budget = cap
.saturating_sub(fixed_overhead)
.saturating_sub(min_options_space);
effective_desc_cap = content_budget
.min(DEFAULT_MAX_CHROME_DESC_LINES)
.min(actual_desc_lines);
let remaining = content_budget.saturating_sub(effective_desc_cap);
// Reserve 1 row for the preview gap (blank separator) when preview
// text exists and there is any remaining budget for preview lines.
let preview_gap_allowance = if state.focused_preview().is_some() && remaining > 0 {
1u16
} else {
0
};
effective_preview_cap = remaining
.saturating_sub(preview_gap_allowance)
.min(DEFAULT_MAX_CHROME_PREVIEW_LINES);
// Recompute chrome with reduced caps.
chrome_h = chrome_height_with_dynamic_caps(
question,
content_w,
state.focused_preview(),
effective_desc_cap,
effective_preview_cap,
);
}
state.cached_desc_cap = effective_desc_cap;
state.cached_preview_cap = effective_preview_cap;
let total = chrome_h
+ total_options_height(question, content_w, state.cursor())
.saturating_sub(phantom_freeform);
total.min(cap)
}
/// Shortcut label for an option index: 1-9 then a-z.
///
/// Returns `'1'`..`'9'` for indices 0..8, `'a'`..`'z'` for 9..34.
/// Returns `None` for indices ≥ 35.
pub fn option_shortcut_label(idx: usize) -> Option<char> {
match idx {
0..=8 => Some((b'1' + idx as u8) as char),
9..=34 => Some((b'a' + (idx - 9) as u8) as char),
_ => None,
}
}
/// Map a pressed key character to an option index.
///
/// `'1'`..`'9'` → 0..8, `'a'`..`'f'` → 9..14.
/// Only a-f are mapped as shortcuts to avoid conflicts with navigation keys
/// (g=top, h=prev-question, j=down, k=up, l=next-question, n=next, s=skip).
pub fn option_index_for_key(c: char) -> Option<usize> {
match c {
'1'..='9' => Some((c as usize) - ('1' as usize)),
'a'..='f' => Some(9 + (c as usize) - ('a' as usize)),
_ => None,
}
}
/// Horizontal padding: 3 left (accent + pad) + 2 right (scrollbar gutter).
pub const QUESTION_VIEW_HPAD: u16 = 5;
/// Prefix width for option rows.
///
/// The shortcut column is always 1 character wide (1-9, a-z), followed by a
/// space, then the marker/radio/checkbox, then a space:
/// Multi: `X [✓] ` = 1 + 1 + 3 + 1 = 6
/// Single: `X (●) ` = 1 + 1 + 3 + 1 = 6
pub fn option_prefix_w(_question: &Question) -> usize {
6 // both multi and single use 3-char markers now
}
/// Width available for inline prompt text given the full area width.
///
/// Subtracts left padding (accent col + 2 = 3), the option prefix
/// (`"z [x] "` = 6 chars), and the prompt indicator (`" "` = 2 chars).
/// Matches the `text_w` computed during rendering so `desired_height`
/// wraps at the same width as the draw area.
pub fn inline_text_width(area_width: u16) -> u16 {
const LEFT_PAD: u16 = 3; // accent column + 2 padding
const OPTION_PREFIX_W: u16 = 6; // shortcut + marker ("z [x] ")
const PROMPT_INDICATOR_W: u16 = 2; // " "
area_width.saturating_sub(LEFT_PAD + OPTION_PREFIX_W + PROMPT_INDICATOR_W)
}
/// Normalize a label for single-line display: replace newlines with spaces.
pub(crate) fn normalize_label(label: &str) -> String {
label.replace('\n', " ").replace(" ", " ")
}
fn rendered_option_label(option: &QuestionOption, max_label_w: usize) -> String {
if max_label_w == 0 {
String::new()
} else {
truncate_str(&normalize_label(&option.label), max_label_w)
}
}
fn rendered_option_description_lines(option: &QuestionOption, width: usize) -> Vec<Line<'static>> {
if option.description.trim().is_empty() {
return Vec::new();
}
let mut renderer = StreamingMarkdownRenderer::new(md_style::style(), true);
renderer.push(&option.description);
// finish() (not render()) so the LaTeX-delimiter normalizer flushes any
// trailing held-back bytes for this complete, one-shot description.
renderer.finish(Some(get_syntect()));
let view = renderer.view();
let lines_owned: Vec<Line<'static>> = view
.lines
.iter()
.map(crate::render::line_utils::line_to_static)
.collect();
let (wrapped, _) = word_wrap_lines_with_joiners(lines_owned, width.max(1));
let mut compact = Vec::new();
let mut prev_blank = true;
for line in wrapped {
let is_blank = line.spans.iter().all(|s| s.content.trim().is_empty());
if is_blank {
if prev_blank {
continue;
}
prev_blank = true;
continue;
}
prev_blank = false;
compact.push(line);
}
compact
}
fn styled_description_lines(
option: &QuestionOption,
width: usize,
row_bg: ratatui::style::Color,
desc_fg: ratatui::style::Color,
) -> Vec<Line<'static>> {
rendered_option_description_lines(option, width)
.into_iter()
.map(|mut line| {
line.style = line.style.patch(Style::default().fg(desc_fg).bg(row_bg));
for span in &mut line.spans {
span.style = span.style.patch(Style::default().fg(desc_fg).bg(row_bg));
}
line
})
.collect()
}
/// Build a flat list of styled lines for all option rows and the freeform row.
///
/// Each visual line — including wrapped description continuation lines — is a
/// separate `Line<'static>`. The caller can render a scrolled window by
/// simply slicing `[scroll .. scroll + visible_h]`, which makes scrolling
/// smooth and line-granular.
#[allow(clippy::too_many_arguments)]
pub fn build_flat_option_lines(
question: &Question,
content_w: usize,
cursor: usize,
hovered: Option<usize>,
selections: &QuestionSelection,
theme: &Theme,
show_freeform: bool,
freeform_text: &str,
freeform_selected: bool,
panel_focused: bool,
) -> Vec<Line<'static>> {
let prefix_w = option_prefix_w(question);
let max_lw = compute_max_label_w(&question.options, content_w);
let is_multi = question.multi_select.unwrap_or(false);
let mut all_lines = Vec::new();
let hover_bg = hovered_bg(theme);
for (i, option) in question.options.iter().enumerate() {
let is_cursor_item = i == cursor;
let is_hovered_item = hovered == Some(i);
let is_selected = match selections {
QuestionSelection::Multi(set) => set.contains(&i),
QuestionSelection::Single(sel) => *sel == Some(i),
};
let embed =
crate::views::modal_window::embedded_row_style(theme, is_cursor_item && panel_focused);
// Full TUI: focused (keyboard cursor) → distinct selection bg, but
// only when the panel itself owns focus. When unfocused, drop the
// cursor-row bg so it reads as "no active selection".
// Hovered (mouse) → subtle blend.
// Normal → dark bg.
let row_bg = match embed {
Some(e) => e.bg,
None if is_cursor_item && panel_focused => theme.bg_visual,
None if is_hovered_item => hover_bg,
None => theme.bg_light,
};
build_single_option_lines(
&mut all_lines,
i,
option,
is_multi,
is_selected,
max_lw,
prefix_w,
content_w,
row_bg,
embed,
theme,
is_cursor_item,
);
}
// Freeform row — hidden in InputMode (prompt widget below replaces it).
if show_freeform {
let freeform_idx = question.options.len();
all_lines.push(build_freeform_line(
freeform_idx == cursor,
hovered == Some(freeform_idx),
freeform_text,
freeform_selected,
is_multi,
theme,
panel_focused,
));
}
all_lines
}
/// Build an indented description continuation line.
fn build_indented_desc_line(
indent: usize,
desc_line: &Line<'static>,
row_bg: ratatui::style::Color,
) -> Line<'static> {
Line::from(
std::iter::once(Span::styled(
" ".repeat(indent),
Style::default().bg(row_bg),
))
.chain(desc_line.spans.iter().cloned())
.collect::<Vec<_>>(),
)
.style(Style::default().bg(row_bg))
}
/// A collapsed description: one visual line that still shows a trailing `…`
/// affordance whenever content is hidden.
fn collapsed_description_spans(
option: &QuestionOption,
width: usize,
row_bg: ratatui::style::Color,
desc_fg: ratatui::style::Color,
) -> Vec<Span<'static>> {
if width == 0 {
return Vec::new();
}
let lines = styled_description_lines(option, width, row_bg, desc_fg);
let Some(first) = lines.first().cloned() else {
return Vec::new();
};
let has_more = lines.len() > 1;
let first_w: usize = first.spans.iter().map(|s| s.content.width()).sum();
if first_w <= width && !has_more {
return first.spans;
}
let budget = if first_w > width {
width
} else {
width.saturating_sub(1)
};
let mut spans = truncate_line(first, budget).spans;
let ends_ellipsis = spans
.last()
.is_some_and(|s| s.content.ends_with('\u{2026}'));
if !ends_ellipsis {
spans.push(Span::styled(
"\u{2026}",
Style::default().fg(desc_fg).bg(row_bg),
));
}
spans
}
/// Word-wrap an overflowing label into chunks of at most `width` columns.
fn wrap_label_chunks(label: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out = Vec::new();
let mut remaining = label;
while !remaining.is_empty() {
if remaining.width() <= width {
out.push(remaining.to_string());
break;
}
let byte_end = byte_offset_at_width(remaining, width);
let break_at = remaining[..byte_end]
.rfind(' ')
.map(|i| i + 1)
.unwrap_or(byte_end);
let break_at = if break_at == 0 {
remaining
.char_indices()
.nth(1)
.map(|(i, _)| i)
.unwrap_or(remaining.len())
} else {
break_at
};
out.push(remaining[..break_at].to_string());
remaining = remaining[break_at..].trim_start();
}
out
}
/// Build the visual lines for a single option and append them to `out`.
#[allow(clippy::too_many_arguments)]
fn build_single_option_lines(
out: &mut Vec<Line<'static>>,
idx: usize,
option: &QuestionOption,
is_multi: bool,
is_selected: bool,
max_label_w: usize,
prefix_w: usize,
content_w: usize,
row_bg: ratatui::style::Color,
embed: Option<crate::views::modal_window::EmbeddedRowStyle>,
theme: &Theme,
focused: bool,
) {
let fg = |normal| embed.map_or(normal, |e| e.fg(normal));
let shortcut_ch = option_shortcut_label(idx).unwrap_or(' ');
let num_str = format!("{shortcut_ch}");
let num_style = Style::default().fg(fg(theme.accent_user)).bg(row_bg);
let label_style = Style::default()
.fg(fg(theme.text_primary))
.bg(row_bg)
.add_modifier(if focused {
Modifier::BOLD
} else {
Modifier::empty()
});
// Build prefix spans (number + marker/checkbox)
let prefix_spans: Vec<Span<'static>> = if is_multi {
let (checkbox, cb_style) = if is_selected {
(
"[x]".to_string(),
Style::default()
.fg(fg(theme.text_primary))
.bg(row_bg)
.add_modifier(Modifier::BOLD),
)
} else {
(
"[ ]".to_string(),
Style::default().fg(fg(theme.gray)).bg(row_bg),
)
};
vec![
Span::styled(format!("{num_str} "), num_style),
Span::styled(format!("{checkbox} "), cb_style),
]
} else {
// Single-select: radio buttons (●) / (○)
let (radio, radio_style) = if is_selected {
(
format!("({})", crate::glyphs::filled_dot()), // (●) → (•) on legacy ConHost
Style::default()
.fg(fg(theme.text_primary))
.bg(row_bg)
.add_modifier(Modifier::BOLD),
)
} else {
(
"(\u{25cb})".to_string(), // (○)
Style::default().fg(fg(theme.gray)).bg(row_bg),
)
};
vec![
Span::styled(format!("{num_str} "), num_style),
Span::styled(format!("{radio} "), radio_style),
]
};
let gap = 2usize;
let indent = prefix_w + max_label_w + gap;
let desc_w = content_w.saturating_sub(indent).max(1);
if !focused {
let mut spans = prefix_spans;
let label = rendered_option_label(option, max_label_w);
let padded_label = format!("{label:<width$}", width = max_label_w);
spans.push(Span::styled(padded_label, label_style));
let desc_spans = collapsed_description_spans(option, desc_w, row_bg, fg(theme.gray));
if !desc_spans.is_empty() {
spans.push(Span::styled(" ".repeat(gap), Style::default().bg(row_bg)));
spans.extend(desc_spans);
}
out.push(Line::from(spans).style(Style::default().bg(row_bg)));
return;
}
let norm_label = normalize_label(&option.label);
if norm_label.width() > max_label_w {
let wide_w = content_w.saturating_sub(prefix_w).max(1);
let chunks = wrap_label_chunks(&norm_label, wide_w);
for (li, chunk) in chunks.into_iter().enumerate() {
if li == 0 {
let mut spans = prefix_spans.clone();
spans.push(Span::styled(chunk, label_style));
out.push(Line::from(spans).style(Style::default().bg(row_bg)));
} else {
let spans = vec![
Span::styled(" ".repeat(prefix_w), Style::default().bg(row_bg)),
Span::styled(chunk, label_style),
];
out.push(Line::from(spans).style(Style::default().bg(row_bg)));
}
}
for line in styled_description_lines(option, wide_w, row_bg, fg(theme.gray)) {
out.push(build_indented_desc_line(prefix_w, &line, row_bg));
}
} else {
let label = rendered_option_label(option, max_label_w);
let padded_label = format!("{label:<width$}", width = max_label_w);
let mut label_spans = prefix_spans;
label_spans.push(Span::styled(padded_label, label_style));
let desc_lines = styled_description_lines(option, desc_w, row_bg, fg(theme.gray));
if let Some(first_desc) = desc_lines.first()
&& !first_desc.spans.is_empty()
{
label_spans.push(Span::styled(" ".repeat(gap), Style::default().bg(row_bg)));
label_spans.extend(first_desc.spans.iter().cloned());
}
out.push(Line::from(label_spans).style(Style::default().bg(row_bg)));
for line in desc_lines.iter().skip(1) {
out.push(build_indented_desc_line(indent, line, row_bg));
}
}
}
/// Build the freeform row line.
///
/// `prefix_w` is the total prefix width used by option rows (number + marker),
/// so the freeform row aligns with the option labels.
/// `freeform_text` is the per-question freeform text — when non-empty the row
/// shows as ticked with a preview of the answer.
fn build_freeform_line(
is_cursor: bool,
is_hovered: bool,
freeform_text: &str,
is_selected: bool,
is_multi: bool,
theme: &Theme,
panel_focused: bool,
) -> Line<'static> {
// Whitespace-only freeform is treated as empty — never shown as selected.
let is_selected = is_selected && !freeform_text.trim().is_empty();
let embed = crate::views::modal_window::embedded_row_style(theme, is_cursor && panel_focused);
let fg = |normal| embed.map_or(normal, |e| e.fg(normal));
let row_bg = match embed {
Some(e) => e.bg,
None if is_cursor && panel_focused => theme.bg_visual,
None if is_hovered => hovered_bg(theme),
None => theme.bg_light,
};
// Multi-select: [x]/[ ] checkboxes. Single-select: (●)/(○) radio buttons.
// Both are 3 display cells — same as option rows.
let marker: String = if is_multi {
(if is_selected { "[x]" } else { "[ ]" }).to_string()
} else if is_selected {
format!("({})", crate::glyphs::filled_dot())
} else {
"(\u{25cb})".to_string()
};
let _marker_display_w: usize = 3;
let marker_style = if is_selected {
Style::default()
.fg(fg(theme.text_primary))
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else if is_cursor {
Style::default().fg(fg(theme.accent_user)).bg(row_bg)
} else {
Style::default().fg(fg(theme.gray)).bg(row_bg)
};
// Stable shortcut "z" — always 1 character, matching option labels.
let num_str = "z".to_string();
let num_style = Style::default().fg(fg(theme.accent_user)).bg(row_bg);
let marker_with_space = format!("{marker} ");
let has_text = !freeform_text.trim().is_empty();
let prompt_indicator = Style::default().fg(fg(theme.accent_user)).bg(row_bg);
let (label, label_style) = if is_selected && has_text {
// Show a truncated preview of the typed answer.
let first_line = freeform_text.lines().next().unwrap_or("");
let preview = truncate_str(first_line, 50);
(
preview,
Style::default().fg(fg(theme.text_primary)).bg(row_bg),
)
} else if has_text {
// Has text but not selected — show dimmed preview.
let first_line = freeform_text.lines().next().unwrap_or("");
let preview = truncate_str(first_line, 50);
(preview, Style::default().fg(fg(theme.gray)).bg(row_bg))
} else {
// Empty — show placeholder.
(
"Type your answer here".to_string(),
Style::default().fg(fg(theme.gray)).bg(row_bg),
)
};
let mut spans = vec![
Span::styled(format!("{num_str} "), num_style),
Span::styled(marker_with_space, marker_style),
];
// Show prompt indicator only when there's text (not on placeholder).
if has_text {
spans.push(Span::styled(
crate::glyphs::prompt_arrow(),
prompt_indicator,
));
}
spans.push(Span::styled(label, label_style));
Line::from(spans).style(Style::default().bg(row_bg))
}
/// Render the complete question view into the given area.
///
/// `area` is the region above the textarea allocated for the question chrome +
/// option rows. The accent `┃` line and background are rendered here.
/// Return value from [`render_question_view`] with layout info for mouse handling.
pub struct QuestionViewRenderResult {
/// Y coordinate where the scrollable options area starts (after chrome header).
pub options_start_y: u16,
/// Y coordinate where the scrollable options area ends (before freeform/inline prompt).
pub options_end_y: u16,
}
pub fn render_question_view(
buf: &mut Buffer,
area: Rect,
state: &QuestionViewState,
hovered_item: Option<usize>,
theme: &Theme,
focused: bool,
) -> QuestionViewRenderResult {
if area.height == 0 || area.width == 0 {
return QuestionViewRenderResult {
options_start_y: area.y,
options_end_y: area.y,
};
}
let q_idx = state.active_tab;
let Some(question) = state.questions.get(q_idx) else {
return QuestionViewRenderResult {
options_start_y: area.y,
options_end_y: area.y,
};
};
let content_w = area.width.saturating_sub(QUESTION_VIEW_HPAD) as usize;
// Fill background — same as the focused prompt (bg_light).
let bg = Style::default().bg(theme.bg_light);
buf.set_style(area, bg);
// Accent line ┃ on the left column — blue to match the shortcut key color
let accent_style = Style::default().fg(theme.accent_user);
for row in area.y..area.y + area.height {
if let Some(cell) = buf.cell_mut((area.x, row)) {
cell.set_symbol(crate::glyphs::accent_bar()); // ┃ → │ on legacy ConHost
cell.set_style(accent_style);
}
}
// Content area (left: accent + 2-char pad, right: 2-char pad for scrollbar)
let content_x = area.x + 3;
let content_width = area.width.saturating_sub(QUESTION_VIEW_HPAD);
let mut y = area.y;
// Vertical padding at the top.
y += 1;
// ── Question chrome (label + counter + description) ──
// Clip to the panel bottom: when the accounted height disagrees with the
// rendered height (wrap-width drift, stale caps), the chrome must degrade
// to truncation instead of writing past the area — set_line past the
// buffer bottom aborts the TUI.
y = render_question_chrome(
buf,
content_x,
y,
content_width,
area.y + area.height,
state,
question,
theme,
state.fullscreen,
state.cached_desc_cap,
state.cached_preview_cap,
);
// ── Gap ──
y += 1;
let options_start_y = y;
// ── Option rows (scrollable) + sticky freeform row ──
let visible_bottom = area.y + area.height;
let scroll = state.per_question_scroll.get(q_idx).copied().unwrap_or(0) as usize;
let cursor = state.cursor();
let is_input_mode = state.focus == QuestionFocus::InputMode;
let freeform_text = state
.per_question_freeform
.get(q_idx)
.map(|s| s.as_str())
.unwrap_or("");
let freeform_selected = state
.per_question_freeform_selected
.get(q_idx)
.copied()
.unwrap_or(false);
// Freeform row is always rendered sticky at the bottom (not in the
// scrollable list), unless in InputMode where the inline prompt replaces it.
// When `no_freeform` is set the row is hidden entirely.
let sticky_freeform = !is_input_mode && !state.no_freeform;
let freeform_h: u16 = if sticky_freeform { 1 } else { 0 };
// Build option lines WITHOUT the freeform row (it's sticky or inline).
let all_lines = build_flat_option_lines(
question,
content_w,
cursor,
hovered_item,
&state.selections[q_idx],
theme,
false, // never in scroll list
freeform_text,
freeform_selected,
focused,
);
let visible_h = visible_bottom.saturating_sub(y).saturating_sub(freeform_h) as usize;
for line in all_lines.iter().skip(scroll).take(visible_h) {
if y >= visible_bottom.saturating_sub(freeform_h) {
break;
}
let row_rect = Rect {
x: content_x,
y,
width: content_width,
height: 1,
};
buf.set_style(row_rect, line.style);
buf.set_line(content_x, y, line, content_width);
y += 1;
}
// ── Sticky freeform row at the bottom ──
if sticky_freeform {
let freeform_y = visible_bottom.saturating_sub(1);
if freeform_y >= y {
let freeform_idx = question.options.len();
let is_multi = question.multi_select.unwrap_or(false);
let _prefix_w = option_prefix_w(question);
let freeform_line = build_freeform_line(
freeform_idx == cursor,
hovered_item == Some(freeform_idx),
freeform_text,
freeform_selected,
is_multi,
theme,
focused,
);
let row_rect = Rect {
x: content_x,
y: freeform_y,
width: content_width,
height: 1,
};
buf.set_style(row_rect, freeform_line.style);
buf.set_line(content_x, freeform_y, &freeform_line, content_width);
}
}
// Unfocus dim: when this overlay is rendered while the user has
// navigated to the scrollback (or any other pane), blend foregrounds
// toward `bg_light` so the panel visually recedes. Mirrors the
// unfocused prompt widget pattern (`prompt_widget.rs:1948`).
if !focused {
crate::render::color::blend_area(buf, area, Some((theme.bg_light, 0.66)), None);
}
let options_end_y = visible_bottom.saturating_sub(freeform_h);
QuestionViewRenderResult {
options_start_y,
options_end_y,
}
}
/// Render the question view scrollbar. Call this AFTER `render_prompt_chrome`.
///
/// `scrollbar_x` is the column to render the scrollbar in — should be
/// outside the selection box border (same column as the scrollback scrollbar).
/// Returns the scrollbar track rect if one was rendered (for mouse hit-testing).
pub fn render_question_scrollbar(
buf: &mut Buffer,
scrollbar_x: u16,
state: &QuestionViewState,
theme: &Theme,
scroll_region: (u16, u16),
) -> Option<Rect> {
let q_idx = state.active_tab;
let question = state.questions.get(q_idx)?;
let (scroll_top, scroll_bottom) = scroll_region;
let visible_options_h = scroll_bottom.saturating_sub(scroll_top);
// Content width for height computation — use a reasonable estimate.
let total_option_h = {
let cw = buf.area.width.saturating_sub(QUESTION_VIEW_HPAD) as usize;
total_options_height(question, cw, state.cursor())
.saturating_sub(state.phantom_freeform_h())
};
let scroll = state.per_question_scroll.get(q_idx).copied().unwrap_or(0);
if total_option_h > visible_options_h && visible_options_h > 0 {
let scrollbar_area = Rect {
x: scrollbar_x,
y: scroll_top,
width: 1,
height: visible_options_h,
};
// Use visible colors against bg_light: dim track, bright thumb.
let track_style = Style::default().fg(theme.gray_dim).bg(theme.bg_light);
let thumb_style = Style::default().fg(theme.gray).bg(theme.bg_light);
crate::render::scrollbar::render_scrollbar_styled(
buf,
Some(scrollbar_area),
total_option_h,
visible_options_h,
scroll,
track_style,
thumb_style,
);
return Some(scrollbar_area);
}
None
}
/// Render a truncation indicator line: `... Ctrl-F to expand`.
fn render_truncation_indicator(buf: &mut Buffer, x: u16, y: u16, width: u16, theme: &Theme) {
let style = Style::default().fg(theme.gray).bg(theme.bg_light);
let indicator = Line::from(vec![
Span::styled("... ", style),
Span::styled(
"Ctrl-F",
Style::default().fg(theme.accent_user).bg(theme.bg_light),
),
Span::styled(" to expand", style),
]);
buf.set_line(x, y, &indicator, width);
}
/// Render question chrome: label line + description.
///
/// Returns the Y position after the rendered chrome.
///
/// All writes are clipped to `max_y` (exclusive): the accounted chrome height
/// (`chrome_height`) and the rendered height can drift (e.g. wrap-width
/// differences), and an unclipped `set_line` below the buffer bottom panics
/// inside ratatui. Clipping degrades to truncation instead.
#[allow(clippy::too_many_arguments)]
fn render_question_chrome(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
max_y: u16,
state: &QuestionViewState,
question: &Question,
theme: &Theme,
fullscreen: bool,
desc_cap: u16,
preview_cap: u16,
) -> u16 {
let mut cur_y = y;
let w = width as usize;
// Never write below the panel or the buffer (belt and braces: the area
// itself should already be inside the buffer, but a mis-sized area must
// degrade to truncation, not an abort).
let max_y = max_y.min(buf.area.bottom());
// Split into label (first paragraph) and description (rest).
let (label_text, desc_text) = split_question_label_desc(&question.question);
// ── Label (bold, primary text, word-wrapped) ──
let label_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let raw_line = Line::from(vec![Span::styled(label_text.to_string(), label_style)]);
let wrapped = crate::render::wrapping::word_wrap_line(&raw_line, w);
for line in &wrapped {
if cur_y >= max_y {
return cur_y;
}
buf.set_line(x, cur_y, line, width);
cur_y += 1;
}
// Blank line after label.
cur_y += 1;
// ── Description (dimmed, markdown-rendered) ──
if !desc_text.is_empty() {
let desc_lines = styled_description_lines(
&QuestionOption {
label: String::new(),
description: desc_text.to_string(),
preview: None,
id: None,
},
w,
theme.bg_light,
theme.gray,
);
let raw_desc_count = desc_lines.len() as u16;
let is_truncated = !fullscreen && raw_desc_count > desc_cap;
for (desc_rendered, line) in desc_lines.into_iter().enumerate() {
let desc_rendered = desc_rendered as u16;
if !fullscreen && desc_rendered >= desc_cap {
break;
}
if cur_y >= max_y {
return cur_y;
}
// Always render the real content line first. When truncated and
// there is room for both content and an indicator (cap >= 2),
// append the indicator after the second-to-last real line and
// break. When cap == 1 we show the single content line without
// an indicator — there is no room for both.
buf.set_line(x, cur_y, &line, width);
cur_y += 1;
if is_truncated && desc_cap >= 2 && desc_rendered == desc_cap.saturating_sub(2) {
if cur_y >= max_y {
return cur_y;
}
render_truncation_indicator(buf, x, cur_y, width, theme);
cur_y += 1;
break;
}
}
}
// ── Preview for focused option (dimmed, word-wrapped) ──
if let Some(preview_text) = state.focused_preview()
&& !preview_text.is_empty()
{
let preview_style = Style::default().fg(theme.gray).bg(theme.bg_light);
// Count total preview lines first to determine truncation.
// Uses Span::raw to match chrome_height (style doesn't affect wrapping).
let mut total_preview_count = 0u16;
for text_line in preview_text.lines() {
let raw = Line::from(vec![Span::raw(text_line.to_string())]);
total_preview_count += crate::render::wrapping::word_wrap_line(&raw, w)
.len()
.max(1) as u16;
}
// Cap to match chrome_height accounting.
let capped_count = if fullscreen {
total_preview_count
} else {
total_preview_count.min(preview_cap)
};
// Only emit the preview gap if there will be visible preview lines.
if capped_count > 0 {
cur_y += 1;
}
let is_truncated = !fullscreen && total_preview_count > preview_cap;
let mut preview_rendered = 0u16;
'preview_done: for text_line in preview_text.lines() {
let raw = Line::from(vec![Span::styled(text_line.to_string(), preview_style)]);
let wrapped = crate::render::wrapping::word_wrap_line(&raw, w);
for line in &wrapped {
if !fullscreen && preview_rendered >= preview_cap {
break 'preview_done;
}
if cur_y >= max_y {
return cur_y;
}
// Always render the real content line first. Append the
// truncation indicator only when cap >= 2 so at least one
// real preview line is visible above it.
buf.set_line(x, cur_y, line, width);
cur_y += 1;
preview_rendered += 1;
if is_truncated
&& preview_cap >= 2
&& preview_rendered == preview_cap.saturating_sub(1)
{
if cur_y >= max_y {
return cur_y;
}
render_truncation_indicator(buf, x, cur_y, width, theme);
cur_y += 1;
break 'preview_done;
}
}
}
}
cur_y
}
// ── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Synthetic long multi-line `ask_user_question` payload for layout
/// regression tests (wide wrap + multi-line option previews). Content is
/// fictional and not from a real session.
fn gb3747_question() -> Question {
Question {
question: "When renaming the shared helper module used by both the \
CLI and the desktop client, which compatibility approach \
should we take for the public config keys?"
.to_string(),
options: vec![
QuestionOption {
label: "Keep old keys as aliases for one release".to_string(),
description: "Ship dual-read of the previous key names, log a \
deprecation notice once per session, and remove \
the aliases in the following minor version."
.to_string(),
preview: Some(
"config.legacy_keys: dual-read for one release\n\
deprecation notice: once per session\n\
remove aliases: next minor\n\
tests: load both old and new keys"
.to_string(),
),
id: None,
},
QuestionOption {
label: "Break now with a clear migration note".to_string(),
description: "Drop the old keys immediately, document the rename \
in the changelog, and print a one-line hint when an \
unknown legacy key is present."
.to_string(),
preview: Some(
"config.legacy_keys: removed\n\
changelog: document rename\n\
unknown legacy key: one-line hint\n\
tests: reject old keys"
.to_string(),
),
id: None,
},
],
multi_select: None,
id: None,
}
}
/// Regression: `draw()` used to size the question panel by
/// wrapping at the full inner width while the renderer wraps at
/// `width - QUESTION_VIEW_HPAD`. The under-allocated panel let the
/// unclipped chrome walk past the buffer bottom and abort in ratatui
/// (`index outside of buffer: ... but index is (5, H)`).
///
/// Recreates that exact under-allocation (height computed at `w`,
/// render at `w - HPAD`) across terminal sizes: rendering must clip,
/// never panic. Fails on pre-fix code at e.g. 31x14 with `(5, 14)`.
#[test]
fn gb3747_regression_mis_sized_area_never_panics() {
let theme = Theme::default();
for w in 20u16..=140 {
for h in 10u16..=60 {
let mut state = QuestionViewState::new(
"tc".into(),
vec![gb3747_question()],
StashedPrompt::default(),
);
let inner_width = w.saturating_sub(4); // hpad_left 2 + hpad_right 2
// Pre-fix draw() bug: full inner width (no HPAD subtraction).
let qv_h = question_view_height(&mut state, h, inner_width as usize);
let question_footer_h: u16 = 3;
let reserved = 1 + 5 + 1 + 3; // draw()'s overcommit clamp
let prompt_height = (qv_h + question_footer_h)
.max(3)
.min(h.saturating_sub(reserved));
// The prompt slot sits directly above the shortcuts row.
let question_area = Rect {
x: 2,
y: h.saturating_sub(1).saturating_sub(prompt_height),
width: inner_width,
height: prompt_height.saturating_sub(question_footer_h),
};
let mut buf = Buffer::empty(Rect::new(0, 0, w, h));
let _ = render_question_view(&mut buf, question_area, &state, None, &theme, true);
}
}
}
/// Companion to the mis-sized-area regression: with the *fixed* accounting
/// (heights computed at the same `content_w` the renderer wraps at),
/// the chrome must fit the allocation exactly — rendering into a
/// generous buffer, the rendered chrome height equals `chrome_height`.
#[test]
fn gb3747_chrome_accounting_matches_render() {
let theme = Theme::default();
for content_w in [20usize, 35, 60, 90, 120] {
let mut state = QuestionViewState::new(
"tc".into(),
vec![gb3747_question()],
StashedPrompt::default(),
);
// Fixed convention: accounting at the render wrap width.
let _ = question_view_height(&mut state, 200, content_w);
let question = &state.questions[0];
let expected_chrome = chrome_height(
question,
content_w,
state.focused_preview(),
false,
state.cached_desc_cap,
state.cached_preview_cap,
);
let area_w = content_w as u16 + QUESTION_VIEW_HPAD;
let area = Rect::new(0, 0, area_w, 200);
let mut buf = Buffer::empty(area);
let result = render_question_view(&mut buf, area, &state, None, &theme, true);
// chrome_height counts vpad(1) + label + gap + desc + preview
// + bottom gap(1); options_start_y sits after exactly that.
assert_eq!(
result.options_start_y - area.y,
expected_chrome,
"chrome accounting vs render drift at content_w={content_w}"
);
}
}
/// Helper: build a question with N options.
fn make_question(text: &str, labels: &[&str], multi: bool) -> Question {
Question {
question: text.to_string(),
options: labels
.iter()
.map(|l| QuestionOption {
label: l.to_string(),
description: format!("Desc for {l}"),
preview: None,
id: None,
})
.collect(),
multi_select: Some(multi),
id: None,
}
}
/// Regression: on the terminal-native palette (`bg_visual = Reset`) the
/// embedded cursor row used to be indistinguishable except for a bold
/// label.
#[test]
#[serial_test::serial]
fn embedded_cursor_row_takes_selection_accent() {
use ratatui::style::Color;
struct EmbedReset;
impl Drop for EmbedReset {
fn drop(&mut self) {
crate::views::modal_window::set_embedded(false);
}
}
let _reset = EmbedReset;
crate::views::modal_window::set_embedded(true);
let theme = Theme::terminal_default();
let q = make_question("Pick one?", &["Alpha", "Beta"], false);
let lines = build_flat_option_lines(
&q,
80,
0,
None,
&QuestionSelection::Single(None),
&theme,
true,
"",
false,
true,
);
// Cursor row (option 0): every colored span carries the accent, and
// the row stays transparent.
let cursor_line = &lines[0];
assert!(
cursor_line
.spans
.iter()
.filter(|s| !s.content.trim().is_empty())
.all(|s| s.style.fg == Some(theme.fuzzy_accent)),
"cursor row must recolor all text with the selection accent, got {:?}",
cursor_line
.spans
.iter()
.map(|s| (s.content.clone(), s.style.fg))
.collect::<Vec<_>>()
);
assert!(
cursor_line
.spans
.iter()
.all(|s| s.style.bg == Some(Color::Reset) || s.style.bg.is_none()),
"embedded rows must not paint a background band"
);
// Non-cursor row keeps normal colors (label = text_primary).
let other_line = &lines[1];
assert!(
other_line
.spans
.iter()
.any(|s| s.content.contains("Beta") && s.style.fg == Some(theme.text_primary)),
"non-cursor row keeps the normal label color"
);
// Freeform row on cursor: same accent treatment.
let freeform_cursor = build_freeform_line(true, false, "", false, false, &theme, true);
assert!(
freeform_cursor
.spans
.iter()
.filter(|s| !s.content.trim().is_empty())
.all(|s| s.style.fg == Some(theme.fuzzy_accent)),
"freeform cursor row must take the accent"
);
}
#[test]
#[serial_test::serial]
fn full_tui_cursor_row_keeps_bg_visual_band() {
crate::views::modal_window::set_embedded(false);
let theme = Theme::default();
let q = make_question("Pick one?", &["Alpha", "Beta"], false);
let lines = build_flat_option_lines(
&q,
80,
0,
None,
&QuestionSelection::Single(None),
&theme,
true,
"",
false,
true,
);
assert!(
lines[0]
.spans
.iter()
.all(|s| s.style.bg == Some(theme.bg_visual)),
"full TUI cursor row paints the bg_visual band"
);
assert!(
lines[0]
.spans
.iter()
.any(|s| s.content.contains("Alpha") && s.style.fg == Some(theme.text_primary)),
"full TUI label keeps text_primary (no accent recolor)"
);
}
// ── new() ──────────────────────────────────────────────────────────
#[test]
fn new_initializes_vectors_correctly() {
let q1 = make_question("Pick one?", &["A", "B", "C"], false);
let q2 = make_question("Pick many?", &["X", "Y"], true);
let state = QuestionViewState::new(
"tc-1".into(),
vec![q1, q2],
StashedPrompt {
text: "stashed".into(),
cursor: 0,
images: Vec::new(),
chip_elements: Vec::new(),
image_counter: 0,
image_undo_stash: Vec::new(),
},
);
assert_eq!(state.questions.len(), 2);
assert_eq!(state.selections.len(), 2);
assert_eq!(state.per_question_cursor.len(), 2);
assert_eq!(state.per_question_scroll.len(), 2);
assert_eq!(state.active_tab, 0);
assert_eq!(state.stashed_prompt.text, "stashed");
// Single-choice initialized to None
assert!(matches!(
state.selections[0],
QuestionSelection::Single(None)
));
// Multi-choice initialized to empty set
assert!(matches!(
state.selections[1],
QuestionSelection::Multi(ref s) if s.is_empty()
));
// Cursors all start at 0
assert!(state.per_question_cursor.iter().all(|&c| c == 0));
assert!(state.per_question_scroll.iter().all(|&s| s == 0));
}
// ── toggle_option ──────────────────────────────────────────────────
#[test]
fn toggle_option_multi_toggles_in_out() {
let q = make_question("Pick?", &["A", "B", "C"], true);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
// Toggle on
state.toggle_option(0, 1);
assert_eq!(state.selected_labels(0), vec!["B"]);
// Toggle another on
state.toggle_option(0, 0);
let mut labels = state.selected_labels(0);
labels.sort();
assert_eq!(labels, vec!["A", "B"]);
// Toggle first one off
state.toggle_option(0, 1);
assert_eq!(state.selected_labels(0), vec!["A"]);
}
// ── select_option ──────────────────────────────────────────────────
#[test]
fn select_option_single_replaces_previous() {
let q = make_question("Pick?", &["A", "B", "C"], false);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
state.select_option(0, 0);
assert_eq!(state.selected_labels(0), vec!["A"]);
state.select_option(0, 2);
assert_eq!(state.selected_labels(0), vec!["C"]);
}
// ── selected_labels ────────────────────────────────────────────────
#[test]
fn selected_labels_mixed_selections() {
let q1 = make_question("Single?", &["X", "Y"], false);
let q2 = make_question("Multi?", &["P", "Q", "R"], true);
let mut state = QuestionViewState::new("tc".into(), vec![q1, q2], StashedPrompt::default());
state.select_option(0, 1); // Y
state.toggle_option(1, 0); // P
state.toggle_option(1, 2); // R
assert_eq!(state.selected_labels(0), vec!["Y"]);
let mut multi = state.selected_labels(1);
multi.sort();
assert_eq!(multi, vec!["P", "R"]);
}
// ── next_question / prev_question ──────────────────────────────────
#[test]
fn question_cycling_clamps_at_boundaries() {
let qs = vec![
make_question("Q1?", &["A"], false),
make_question("Q2?", &["B"], false),
make_question("Q3?", &["C"], false),
];
let mut state = QuestionViewState::new("tc".into(), qs, StashedPrompt::default());
assert_eq!(state.active_tab, 0);
state.next_question();
assert_eq!(state.active_tab, 1);
state.next_question();
assert_eq!(state.active_tab, 2);
state.next_question();
assert_eq!(state.active_tab, 2); // clamped at end
state.prev_question();
assert_eq!(state.active_tab, 1);
state.prev_question();
assert_eq!(state.active_tab, 0);
state.prev_question();
assert_eq!(state.active_tab, 0); // clamped at start
}
// ── compute_max_label_w ────────────────────────────────────────────
#[test]
fn compute_max_label_w_caps_long_labels_at_60_percent() {
let options = vec![
QuestionOption {
label: "A very long label that is way too wide for half the width here".into(),
description: String::new(),
preview: None,
id: None,
},
QuestionOption {
label: "Medium label".into(),
description: String::new(),
preview: None,
id: None,
},
QuestionOption {
label: "Short".into(),
description: String::new(),
preview: None,
id: None,
},
];
// content_w=80 → cap = 48. Longest label is 63 → capped at 48.
assert_eq!(compute_max_label_w(&options, 80), 48);
}
#[test]
fn compute_max_label_w_uses_longest_when_all_fit() {
let options = vec![
QuestionOption {
label: "Hello".into(),
description: String::new(),
preview: None,
id: None,
},
QuestionOption {
label: "World!".into(),
description: String::new(),
preview: None,
id: None,
},
];
// content_w=80 → cap = 48. Both fit. Longest = "World!" = 6.
assert_eq!(compute_max_label_w(&options, 80), 6);
}
#[test]
fn compute_max_label_w_never_zero_when_all_labels_long() {
let options = vec![
QuestionOption {
label: "Lorem ipsum dolor sit amet consectetur adipiscing!".into(),
description: "Lorem ipsum dolor sit ame".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Lorem ipsum dolor sit amet elit sed tempor".into(),
description: "Lorem ipsum dolor sit amet elit s".into(),
preview: None,
id: None,
},
];
// content_w=100 → cap = 60. Longest label = 50 → fits, column = 50.
assert_eq!(compute_max_label_w(&options, 100), 50);
}
#[test]
fn unfocused_row_with_all_long_labels_still_shows_label() {
let opt = QuestionOption {
label: "Lorem ipsum dolor sit amet consectetur adipiscing!".into(),
description: "Lorem ipsum dolor sit amet, consectetur adipiscing.".into(),
preview: None,
id: None,
};
let content_w = 100usize;
let max_label_w = compute_max_label_w(std::slice::from_ref(&opt), content_w);
assert!(max_label_w > 0);
let theme = Theme::default();
let mut lines = Vec::new();
build_single_option_lines(
&mut lines,
0,
&opt,
false,
false,
max_label_w,
6,
content_w,
theme.bg_light,
None,
&theme,
false,
);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("Lorem ipsum dolor sit amet consectetur adipiscing!"),
"unfocused row must show the label, got: {text:?}"
);
assert!(
text.contains("Lorem ipsum dolor sit amet, consectetur"),
"unfocused row should still show the collapsed description, got: {text:?}"
);
}
#[test]
fn focused_stacked_description_wraps_at_prefix_indent_not_label_column() {
let opt = QuestionOption {
label: "This is an intentionally very long option label designed to test how the \
question view handles label visibility when the text greatly exceeds the cap"
.into(),
description: "This description exists purely to stress test the option description \
rendering in the question view. It should be long enough to force \
wrapping across multiple lines on most terminal widths."
.into(),
preview: None,
id: None,
};
let content_w = 100usize;
let prefix_w = 6usize;
let max_label_w = compute_max_label_w(std::slice::from_ref(&opt), content_w);
assert!(normalize_label(&opt.label).width() > max_label_w);
let theme = Theme::default();
let mut lines = Vec::new();
build_single_option_lines(
&mut lines,
0,
&opt,
false,
false,
max_label_w,
prefix_w,
content_w,
theme.bg_light,
None,
&theme,
true,
);
let label_line_count =
wrap_label_chunks(&normalize_label(&opt.label), content_w - prefix_w).len();
let desc_lines = &lines[label_line_count..];
assert!(!desc_lines.is_empty());
let mut texts = Vec::new();
for line in desc_lines {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let leading = text.len() - text.trim_start().len();
assert_eq!(
leading, prefix_w,
"stacked description must be indented at prefix_w, not the label column: {text:?}"
);
texts.push(text);
}
assert!(
texts
.iter()
.any(|t| t.trim_end().len() > prefix_w + max_label_w),
"stacked description should use the full row width, got: {texts:?}"
);
let heights = option_visual_height(&opt, content_w, prefix_w, max_label_w, true);
assert_eq!(heights as usize, lines.len());
}
// ── is_on_freeform_row ─────────────────────────────────────────────
#[test]
fn is_on_freeform_row_returns_true_at_end() {
let q = make_question("Pick?", &["A", "B"], false);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
// Cursor at 0 → on option A, not freeform
assert!(!state.is_on_freeform_row());
// Cursor at 2 → options.len() == 2, so this is the freeform row
state.set_cursor(2);
assert!(state.is_on_freeform_row());
}
// ── cursor / set_cursor ────────────────────────────────────────────
#[test]
fn set_cursor_clamps_to_valid_range() {
let q = make_question("Pick?", &["A", "B"], false);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
// total_items = 3 (A, B, freeform), so max cursor = 2
state.set_cursor(100);
assert_eq!(state.cursor(), 2);
state.set_cursor(0);
assert_eq!(state.cursor(), 0);
}
// ── total_items ────────────────────────────────────────────────────
#[test]
fn total_items_counts_options_plus_freeform() {
let q = make_question("Pick?", &["A", "B", "C"], false);
let state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
assert_eq!(state.total_items(0), 4); // 3 options + 1 freeform
}
// ── no_freeform ────────────────────────────────────────────────────
/// `no_freeform` questions (e.g. the SuperGrok upsell) have no "Other"
/// row, so activating freeform input must be impossible: focus stays in
/// Navigation and nothing gets marked selected. Regression test for the
/// upsell modal letting the user type after clicking under the last
/// option.
#[test]
fn activate_freeform_input_is_noop_when_no_freeform() {
let q = make_question("Pick?", &["A", "B"], false);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default())
.with_no_freeform();
state.selections[0] = QuestionSelection::Single(Some(1));
let text = state.activate_freeform_input();
assert_eq!(text, "");
assert_eq!(state.focus, QuestionFocus::Navigation);
assert!(!state.per_question_freeform_selected[0]);
assert!(
matches!(state.selections[0], QuestionSelection::Single(Some(1))),
"option selection must survive"
);
}
/// The panel height for a `no_freeform` question must not reserve the
/// (never rendered) freeform row — that dead row was clickable and
/// activated freeform input on the upsell modal.
#[test]
fn question_view_height_excludes_freeform_row_when_no_freeform() {
let q = make_question("Pick?", &["A", "B", "C"], false);
let mut with_freeform =
QuestionViewState::new("tc".into(), vec![q.clone()], StashedPrompt::default());
let mut without_freeform =
QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default())
.with_no_freeform();
let h_with = question_view_height(&mut with_freeform, 50, 80);
let h_without = question_view_height(&mut without_freeform, 50, 80);
assert_eq!(
h_with,
h_without + 1,
"no_freeform panel must be exactly one row shorter"
);
// Fullscreen path too.
with_freeform.fullscreen = true;
without_freeform.fullscreen = true;
let h_with = question_view_height(&mut with_freeform, 50, 80);
let h_without = question_view_height(&mut without_freeform, 50, 80);
assert_eq!(h_with, h_without + 1);
}
// ── option_visual_height ───────────────────────────────────────────
#[test]
fn option_visual_height_unfocused_always_1() {
let opt = QuestionOption {
label: "Short".into(),
description: "A description that is longer than the available width".into(),
preview: None,
id: None,
};
assert_eq!(option_visual_height(&opt, 30, 6, 5, false), 1);
}
#[test]
fn option_visual_height_focused_wraps() {
let opt = QuestionOption {
label: "Short".into(),
description: "A description that is longer than the available width".into(),
preview: None,
id: None,
};
// content_w=30, prefix_w=6, max_label_w=5, gap=2 → indent=13, desc_w=17
let h = option_visual_height(&opt, 30, 6, 5, true);
assert!(h >= 3, "expected >= 3, got {h}");
}
// ── chrome_height ──────────────────────────────────────────────────
#[test]
fn split_question_label_desc_no_break() {
let (label, desc) = split_question_label_desc("Which database engine?");
assert_eq!(label, "Which database engine?");
assert_eq!(desc, "");
}
#[test]
fn split_question_label_desc_with_break() {
let (label, desc) =
split_question_label_desc("Which database?\n\nPick the engine for the backend.");
assert_eq!(label, "Which database?");
assert_eq!(desc, "Pick the engine for the backend.");
}
#[test]
fn chrome_height_short_question() {
// Short question, no description: vpad(1) + label(1) + gap(1) + gap(1) = 4.
let q = make_question("Which database engine?", &["A"], false);
assert_eq!(
chrome_height(
&q,
80,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
4
);
}
#[test]
fn chrome_height_with_description() {
// Question with paragraph break: label + gap + description + gap.
let q = make_question(
"Which database?\n\nChoose the primary data store for the backend service.",
&["A"],
false,
);
let desc_part = "Choose the primary data store for the backend service.";
// vpad(1) + label(1) + gap(1) + desc lines + gap(1)
let desc_lines = desc_part.len().div_ceil(80).max(1) as u16; // 1 line at width 80
assert_eq!(
chrome_height(
&q,
80,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
1 + 1 + 1 + desc_lines + 1
);
}
#[test]
fn chrome_height_wraps_long_question() {
// 60-char question at width 40 wraps to 2 lines: vpad(1) + label(2) + gap(1) + gap(1) = 5.
let q = make_question(
"Which database engine should we use for the backend service?",
&["A"],
false,
);
assert_eq!(
chrome_height(
&q,
40,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
5
);
// Same question at width 80 fits on 1 line: 4.
assert_eq!(
chrome_height(
&q,
80,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
4
);
}
#[test]
fn chrome_height_wraps_extra_long_question() {
// 150-char question wraps across multiple lines depending on width.
//
// At width 75 (typical terminal with chrome):
// ┃ Given the requirements for high availability, horizontal
// ┃ scaling, and strict ACID compliance, which database engine
// ┃ and replication topology should we adopt for the user
// ┃ accounts microservice?
// ┃
// ┃ 1 [ ] PostgreSQL ...
//
// At width 40 (narrow):
// ┃ Given the requirements for high
// ┃ availability, horizontal scaling,
// ┃ and strict ACID compliance, which
// ┃ database engine and replication
// ┃ topology should we adopt for the
// ┃ user accounts microservice?
// ┃
// ┃ 1 [ ] PostgreSQL ...
let q = make_question(
"Given the requirements for high availability, horizontal scaling, \
and strict ACID compliance, which database engine and replication \
topology should we adopt for the user accounts microservice?",
&["PostgreSQL", "CockroachDB", "TiDB"],
false,
);
// Word-wrap at width 75: 3 lines → vpad(1) + label(3) + gap(1) + gap(1) = 6
assert_eq!(
chrome_height(
&q,
75,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
6
);
// Word-wrap at width 40: 6 lines (word boundaries prevent mid-word splits)
// → vpad(1) + label(6) + gap(1) + gap(1) = 9
assert_eq!(
chrome_height(
&q,
40,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
9
);
// Word-wrap at width 200: 1 line → vpad(1) + label(1) + gap(1) + gap(1) = 4
assert_eq!(
chrome_height(
&q,
200,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
4
);
}
#[test]
fn chrome_height_with_preview() {
// Short question + preview: vpad(1) + label(1) + gap(1) + preview_gap(1) + preview(1) + gap(1) = 6.
let q = make_question("Which database?", &["A"], false);
let preview = "commit abc123: fix the bug";
assert_eq!(
chrome_height(
&q,
80,
Some(preview),
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
6
);
}
#[test]
fn chrome_height_with_long_preview() {
// Preview that wraps across 2 lines at width 40.
let q = make_question("Confirm?", &["A"], false);
let preview = "fix(auth): resolve token refresh race condition in middleware";
// Word-wrap at width 40: 2 lines → vpad(1) + label(1) + gap(1) + preview_gap(1) + preview(2) + gap(1) = 7
assert_eq!(
chrome_height(
&q,
40,
Some(preview),
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
7
);
}
#[test]
fn chrome_height_with_multiline_preview() {
// Multi-line preview: each \n-separated line is wrapped independently.
let q = make_question("Confirm?", &["A"], false);
let preview =
"fix(auth): token refresh\n\nResolves the race condition\nin the middleware layer";
// .lines() yields 4 segments (including one empty line).
// word_wrap_line returns 1 line for each, so 4 preview lines total.
// vpad(1) + label(1) + gap(1) + preview_gap(1) + preview(4) + gap(1) = 9
assert_eq!(
chrome_height(
&q,
80,
Some(preview),
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
9
);
}
#[test]
fn chrome_height_with_empty_preview() {
// Empty preview should be the same as None.
let q = make_question("Pick?", &["A"], false);
assert_eq!(
chrome_height(
&q,
80,
Some(""),
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
chrome_height(
&q,
80,
None,
false,
DEFAULT_MAX_CHROME_DESC_LINES,
DEFAULT_MAX_CHROME_PREVIEW_LINES
),
);
}
// ── focused_preview ──────────────────────────────────────────────
#[test]
fn focused_preview_returns_preview_when_on_option() {
let q = Question {
question: "Pick?".into(),
options: vec![
QuestionOption {
label: "A".into(),
description: "desc A".into(),
preview: Some("preview content".into()),
id: None,
},
QuestionOption {
label: "B".into(),
description: "desc B".into(),
preview: None,
id: None,
},
],
multi_select: Some(false),
id: None,
};
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
// Cursor at 0 → option A has preview.
assert_eq!(state.focused_preview(), Some("preview content"));
// Cursor at 1 → option B has no preview.
state.set_cursor(1);
assert_eq!(state.focused_preview(), None);
// Cursor at 2 → freeform row, no preview.
state.set_cursor(2);
assert_eq!(state.focused_preview(), None);
}
// ── toggle on Single ───────────────────────────────────────────────
#[test]
fn toggle_option_single_deselects_when_same() {
let q = make_question("Pick?", &["A", "B"], false);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
state.toggle_option(0, 1);
assert_eq!(state.selected_labels(0), vec!["B"]);
// Toggle same → deselect
state.toggle_option(0, 1);
assert!(state.selected_labels(0).is_empty());
}
#[test]
fn item_index_at_visual_line_accounts_for_wrapped_rows() {
let q = Question {
question: "Pick one".into(),
options: vec![
QuestionOption {
label: "Alpha".into(),
description: "one two three four five six seven eight nine ten eleven".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Beta".into(),
description: "short desc".into(),
preview: None,
id: None,
},
],
multi_select: Some(true),
id: None,
};
let content_w = 20;
let cursor = 0; // focus first option so it gets full height
let heights = option_heights(&q, content_w, cursor);
assert!(heights[0] > 1);
for line in 0..heights[0] {
assert_eq!(item_index_at_visual_line(&q, content_w, line, cursor), 0);
}
assert_eq!(
item_index_at_visual_line(&q, content_w, heights[0], cursor),
1
);
}
#[test]
fn clamp_scroll_limits_offset_to_viewport() {
let q = make_question("Pick?", &["A", "B", "C", "D"], true);
let mut state =
QuestionViewState::new("tc".into(), vec![q.clone()], StashedPrompt::default());
state.per_question_scroll[0] = 100;
let visible_h = 2;
let content_w = 80;
let expected_max =
total_options_height(&q, content_w, state.cursor()).saturating_sub(visible_h);
state.clamp_scroll(visible_h, content_w);
assert_eq!(state.per_question_scroll[0], expected_max);
}
// ── truncation cap tests ───────────────────────────────────────────
#[test]
fn chrome_height_caps_long_description() {
// 10-line description using CommonMark hard breaks (` \n`) so each
// logical line renders as its own visual line; bare `\n` between
// text lines is a soft break and collapses to a space.
let q = make_question(
"Q?\n\nline1 \nline2 \nline3 \nline4 \nline5 \nline6 \nline7 \nline8 \nline9 \nline10",
&["A"],
false,
);
let uncapped = chrome_height(&q, 80, None, true, 5, 6);
let capped = chrome_height(&q, 80, None, false, 5, 6);
assert!(
uncapped > capped,
"fullscreen ({uncapped}) should exceed capped ({capped})",
);
// capped: vpad(1) + label(1) + gap(1) + desc(5) + gap(1) = 9
assert_eq!(capped, 9);
}
#[test]
fn chrome_height_caps_long_preview() {
// 8-line preview at preview_cap=3 should be capped.
let q = make_question("Pick?", &["A"], false);
let preview = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8";
let uncapped = chrome_height(&q, 80, Some(preview), true, 5, 3);
let capped = chrome_height(&q, 80, Some(preview), false, 5, 3);
assert!(
uncapped > capped,
"fullscreen ({uncapped}) should exceed capped ({capped})",
);
// capped: vpad(1) + label(1) + gap(1) + preview_gap(1) + preview(3) + gap(1) = 8
assert_eq!(capped, 8);
}
#[test]
fn chrome_height_preview_cap_zero_no_gap() {
// When preview_cap=0 and !fullscreen, preview contributes 0 lines and
// no gap — matching render_question_chrome which guards the gap on
// capped_count > 0.
let q = make_question("Pick?", &["A"], false);
let preview = "some preview text";
let with_preview = chrome_height(&q, 80, Some(preview), false, 5, 0);
let without = chrome_height(&q, 80, None, false, 5, 0);
assert_eq!(with_preview, without);
}
#[test]
fn chrome_height_desc_cap_one() {
// Edge case: desc_cap=1 should still show exactly 1 description line.
let q = make_question("Q?\n\nline1\nline2\nline3", &["A"], false);
let h = chrome_height(&q, 80, None, false, 1, 6);
// vpad(1) + label(1) + gap(1) + desc(1) + gap(1) = 5
assert_eq!(h, 5);
}
// ── question_view_height / minimum visible option rows ─────────────
/// Helper: build a QuestionViewState for height tests.
fn make_state_for_height(
question_text: &str,
labels: &[&str],
fullscreen: bool,
) -> QuestionViewState {
let q = make_question(question_text, labels, false);
let mut state = QuestionViewState::new("tc".into(), vec![q], StashedPrompt::default());
state.fullscreen = fullscreen;
state
}
#[test]
fn question_view_height_large_terminal_uses_static_caps() {
// On a big terminal (80 rows), static caps work and at least
// MIN_VISIBLE_OPTION_ROWS options are visible.
let mut state = make_state_for_height(
"Which database?",
&["PostgreSQL", "MySQL", "SQLite", "CockroachDB", "TiDB"],
false,
);
let content_w = 75;
let h = question_view_height(&mut state, 80, content_w);
let chrome_h = chrome_height(
&state.questions[0],
content_w,
state.focused_preview(),
false,
state.cached_desc_cap,
state.cached_preview_cap,
);
let visible_h = h.saturating_sub(chrome_h);
assert!(
visible_h >= MIN_VISIBLE_OPTION_ROWS,
"visible_h={visible_h} < MIN_VISIBLE_OPTION_ROWS={MIN_VISIBLE_OPTION_ROWS}",
);
// Static caps should be unchanged.
assert_eq!(state.cached_desc_cap, DEFAULT_MAX_CHROME_DESC_LINES);
assert_eq!(state.cached_preview_cap, DEFAULT_MAX_CHROME_PREVIEW_LINES);
}
#[test]
fn question_view_height_small_terminal_reduces_caps() {
// On a small terminal (24 rows) with a long description, dynamic
// fallback should reduce the desc/preview caps.
let mut state = make_state_for_height(
"Which database?\n\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8",
&["PostgreSQL", "MySQL", "SQLite", "CockroachDB", "TiDB"],
false,
);
let content_w = 75;
let h = question_view_height(&mut state, 24, content_w);
// At least one cap should have been reduced from the default.
let caps_reduced = state.cached_desc_cap < DEFAULT_MAX_CHROME_DESC_LINES
|| state.cached_preview_cap < DEFAULT_MAX_CHROME_PREVIEW_LINES;
assert!(
caps_reduced,
"expected dynamic cap reduction on small terminal, desc_cap={} preview_cap={}",
state.cached_desc_cap, state.cached_preview_cap,
);
let chrome_h = chrome_height(
&state.questions[0],
content_w,
state.focused_preview(),
false,
state.cached_desc_cap,
state.cached_preview_cap,
);
let visible_h = h.saturating_sub(chrome_h);
assert!(
visible_h >= MIN_VISIBLE_OPTION_ROWS,
"visible_h={visible_h} < MIN_VISIBLE_OPTION_ROWS={MIN_VISIBLE_OPTION_ROWS} on 24-row terminal",
);
}
#[test]
fn question_view_height_fullscreen_uses_max_caps() {
let mut state = make_state_for_height(
"Which database?\n\nline1\nline2\nline3",
&["A", "B", "C"],
true,
);
let _ = question_view_height(&mut state, 80, 75);
assert_eq!(state.cached_desc_cap, u16::MAX);
assert_eq!(state.cached_preview_cap, u16::MAX);
}
// ── focus-driven option height ─────────────────────────────────────
#[test]
fn unfocused_option_is_one_line_focused_is_full() {
let opt = QuestionOption {
label: "Opt".into(),
description: "line1 \nline2 \nline3 \nline4 \nline5 \nline6".into(),
preview: None,
id: None,
};
let content_w = 40;
let prefix_w = 6;
let max_label_w = 5;
let focused_h = option_visual_height(&opt, content_w, prefix_w, max_label_w, true);
let unfocused_h = option_visual_height(&opt, content_w, prefix_w, max_label_w, false);
assert_eq!(unfocused_h, 1, "unfocused should collapse to a single line");
assert!(
focused_h >= 6,
"focused ({focused_h}) should show all six description lines",
);
}
#[test]
fn build_flat_option_lines_count_matches_option_heights_sum() {
// Verify that the number of lines produced by build_flat_option_lines
// equals the sum of option_heights (consistency check).
let q = Question {
question: "Pick one".into(),
options: vec![
QuestionOption {
label: "Alpha".into(),
description: "short".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Beta".into(),
description: "a longer description that should wrap at narrow width and produce multiple lines of text".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Gamma".into(),
description: "desc".into(),
preview: None,
id: None,
},
],
multi_select: Some(false),
id: None,
};
let content_w = 30;
let cursor = 1; // focus Beta
let theme = Theme::default();
let sel = QuestionSelection::Single(None);
let lines = build_flat_option_lines(
&q, content_w, cursor, None, &sel, &theme, true, // show freeform
"", false, true, // panel focused
);
let heights = option_heights(&q, content_w, cursor);
let expected_total: u16 = heights.iter().sum();
assert_eq!(
lines.len(),
expected_total as usize,
"flat lines ({}) should equal sum of option_heights ({expected_total})",
lines.len(),
);
}
fn line_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn single_option_lines(desc: &str, content_w: usize, focused: bool) -> Vec<Line<'static>> {
let q = Question {
question: "Pick".into(),
options: vec![QuestionOption {
label: "Yes".into(),
description: desc.into(),
preview: None,
id: None,
}],
multi_select: Some(false),
id: None,
};
let theme = Theme::default();
let sel = QuestionSelection::Single(None);
let cursor = if focused { 0 } else { 1 };
build_flat_option_lines(
&q, content_w, cursor, None, &sel, &theme, false, "", false, true,
)
}
#[test]
fn unfocused_long_description_collapses_to_one_line_with_ellipsis() {
let lines = single_option_lines(
"a very long single line description that will not fit on the row and must be \
truncated to a single line ending with an ellipsis",
40,
false,
);
assert_eq!(lines.len(), 1, "unfocused option must be a single line");
let text = line_text(&lines[0]);
assert!(text.contains('\u{2026}'), "expected ellipsis, got {text:?}");
}
#[test]
fn unfocused_multiline_description_shows_ellipsis_even_when_first_line_short() {
let lines = single_option_lines("short \nthen a second line of content", 60, false);
assert_eq!(lines.len(), 1);
let text = line_text(&lines[0]);
assert!(text.contains("short"), "first line should show: {text:?}");
assert!(
text.contains('\u{2026}'),
"expected ellipsis for more content: {text:?}",
);
}
#[test]
fn unfocused_short_description_has_no_ellipsis() {
let lines = single_option_lines("tiny", 60, false);
assert_eq!(lines.len(), 1);
let text = line_text(&lines[0]);
assert!(text.contains("tiny"));
assert!(
!text.contains('\u{2026}'),
"short fully-shown description should not get an ellipsis: {text:?}",
);
}
#[test]
fn focused_long_description_expands_to_multiple_lines() {
let lines = single_option_lines("line1 \nline2 \nline3 \nline4", 40, true);
assert!(
lines.len() >= 4,
"focused option should show all lines, got {}",
lines.len(),
);
}
#[test]
fn no_navigate_and_expand_hint_in_rendered_lines() {
let lines = single_option_lines("l1 \nl2 \nl3 \nl4 \nl5", 40, false);
for line in &lines {
let text = line_text(line);
assert!(
!text.contains("navigate & expand"),
"removed hint should not appear: {text:?}",
);
}
}
#[test]
fn flat_lines_match_heights_with_overflowing_focused_label() {
let q = Question {
question: "Pick".into(),
options: vec![
QuestionOption {
label: "A really long label that overflows the aligned column width".into(),
description: "line1 \nline2 \nline3".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Short".into(),
description: "desc".into(),
preview: None,
id: None,
},
],
multi_select: Some(false),
id: None,
};
let content_w = 30;
let cursor = 0;
let theme = Theme::default();
let sel = QuestionSelection::Single(None);
let lines = build_flat_option_lines(
&q, content_w, cursor, None, &sel, &theme, false, "", false, true,
);
let heights = option_heights(&q, content_w, cursor);
let expected: u16 = heights[..q.options.len()].iter().sum();
assert_eq!(lines.len(), expected as usize);
}
#[test]
fn collapsed_description_spans_respects_zero_width() {
let opt = QuestionOption {
label: "x".into(),
description: "some long description that definitely has content".into(),
preview: None,
id: None,
};
let theme = Theme::default();
let spans = collapsed_description_spans(&opt, 0, theme.bg_light, theme.gray);
let w: usize = spans.iter().map(|s| s.content.width()).sum();
assert_eq!(w, 0, "zero-width budget must produce no spans");
}
}