grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/views/question_view.rs
grokkybara[bot] a5727c5960 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:22:27 +01:00

3338 lines
120 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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>,
},
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");
}
}