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
3338 lines
120 KiB
Rust
3338 lines
120 KiB
Rust
//! 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");
|
||
}
|
||
}
|