diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 3a0293497db..e13636b00ea 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -23,8 +23,6 @@ mod conversation_menu; mod conversation_selection; mod editor_element; mod editor_interaction; -// The option-selector slice consumes this reusable editor and removes the allow. -#[allow(dead_code)] mod editor_view; mod exit_confirmation; mod inline_menu; @@ -33,6 +31,10 @@ mod input_suggestions_mode; mod keybindings; mod mcp_menu; mod model_menu; +// Not consumed yet: the TUI orchestration card slice embeds this selector +// and removes the allow. +#[allow(dead_code)] +mod option_selector; mod resume; mod skills_menu; mod slash_commands; diff --git a/crates/warp_tui/src/option_selector.rs b/crates/warp_tui/src/option_selector.rs new file mode 100644 index 00000000000..f137628f560 --- /dev/null +++ b/crates/warp_tui/src/option_selector.rs @@ -0,0 +1,1198 @@ +//! [`TuiOptionSelector`]: a reusable single-select option list for TUI +//! permission prompts, rendered from a frontend-neutral +//! [`OptionSnapshot`]. One configuration page shows a header (title, +//! "n of m" position, question), a selectable option list with viewport +//! scrolling, optional Loading/Failed/Empty status rows, and an optional +//! custom-text footer editor. +//! +//! Enter, Numpad Enter, arrows, viewport-relative digits, printable +//! characters, clicks, and wheel scrolling are handled at the element level +//! since the selector is only rendered while its host is the active blocking +//! interaction. Escape remains host policy, with an element-level fallback +//! through [`TuiOptionSelector::handle_back`]. + +use warp::tui_export::{OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus}; +use warp_search_core::inline_menu::InlineMenuSelection; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiFlex, + TuiHoverable, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiParentElement, + TuiPresentationContext, TuiScreenPoint, TuiScreenPosition, TuiSize, TuiStyle, TuiText, +}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::{ + AppContext, BlurContext, Entity, EntityId, FocusContext, TuiView, TypedActionView, ViewContext, + ViewHandle, +}; + +use crate::editor_view::{TuiEditorView, TuiEditorViewEvent}; +use crate::inline_menu::keep_selected_visible; +use crate::tui_builder::TuiUiBuilder; + +/// Maximum option rows visible at once; longer lists scroll. +pub(crate) const MAX_VISIBLE_OPTION_ROWS: usize = 6; + +/// Validation copy shown when the custom-text editor is submitted empty. +const CUSTOM_TEXT_EMPTY_ERROR: &str = "Enter a value to continue."; + +/// Renderable fields for one selector page. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct OptionSelectorPage { + /// Short field label shown in the header row. + pub(crate) field_label: String, + /// One-based position in the current page sequence: `(current, total)`. + pub(crate) position: (usize, usize), + /// Full prompt shown above the available options. + pub(crate) prompt: String, + /// Options and catalog status rendered on this page. + pub(crate) snapshot: OptionSnapshot, + /// Whether this page offers label filtering. + pub(crate) searchable: bool, +} + +impl Default for OptionSelectorPage { + fn default() -> Self { + Self { + field_label: String::new(), + position: (0, 0), + prompt: String::new(), + snapshot: OptionSnapshot { + rows: Vec::new(), + selected_id: None, + status: OptionSourceStatus::Ready, + footer: None, + }, + searchable: false, + } + } +} + +/// Events emitted to the embedding card view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiOptionSelectorEvent { + /// An enabled option row was confirmed. + Confirmed { id: String }, + /// The custom-text footer editor was submitted with a valid value. + CustomTextSubmitted { value: String }, + /// The Retry affordance of a `Failed` catalog was activated. + RetryRequested, + /// The selector asked to be dismissed (element-level Escape fallback for + /// hosts without their own Escape binding). + Dismissed, + /// The selector's intrinsic height changed. `ctx.notify()` rerenders this + /// view, but the block list may reuse a stable-width cached rich-content + /// height. The host forwards this event so the containing rich-content + /// item is marked dirty and remeasured. + LayoutInvalidated, +} + +/// User interactions dispatched from the selector's element tree. +#[derive(Clone, Debug)] +pub(crate) enum TuiOptionSelectorAction { + /// Confirm the currently selected item. + ConfirmSelected, + MoveUp, + MoveDown, + /// Select the viewport-relative item and confirm it when enabled. + SelectNumberedOption(u8), + /// Select the item at an absolute index and confirm it when enabled. + /// Dispatched by row clicks. + SelectItem(usize), + /// Scroll the viewport by whole rows without moving the selection. + ScrollBy(isize), + /// Move focus from the option list to search and seed its query. + FocusSearchAndInsert(char), + /// Element-level Escape fallback (see [`TuiOptionSelectorEvent::Dismissed`]). + Dismiss, +} + +/// One navigable entry in the selector, in display order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectorItem { + /// Index into `snapshot.rows`. + Row(usize), + /// The Retry affordance shown for a `Failed` catalog. + Retry, + /// The custom-text footer entry point. + CustomText, +} + +/// Editing phase for the custom-text footer. +#[derive(Default)] +enum CustomTextEditingState { + #[default] + Inactive, + Active { + error_visible: bool, + }, +} + +impl CustomTextEditingState { + /// Returns the active editor's validation state. + fn error_visible(&self) -> Option { + match self { + Self::Inactive => None, + Self::Active { error_visible } => Some(*error_visible), + } + } +} + +/// Editor, committed value, and editing phase for a custom-text footer. +struct CustomTextState { + editor: ViewHandle, + committed_value: Option, + editing: CustomTextEditingState, +} + +impl CustomTextState { + /// Creates inactive custom-text state around its editor view. + fn new(editor: ViewHandle) -> Self { + Self { + editor, + committed_value: None, + editing: CustomTextEditingState::Inactive, + } + } + + /// Whether the custom-text editor currently owns the interaction. + fn is_editing(&self) -> bool { + self.editing.error_visible().is_some() + } + + /// Whether the active editor shows its validation error. + fn error_is_visible(&self) -> bool { + self.editing.error_visible() == Some(true) + } + + /// Restores the committed value encoded by a snapshot. + fn sync_committed_value(&mut self, snapshot: &OptionSnapshot) { + self.committed_value = match (&snapshot.footer, &snapshot.selected_id) { + (Some(OptionFooter::CustomText { .. }), Some(selected_id)) + if !snapshot.rows.iter().any(|row| row.id == *selected_id) => + { + Some(selected_id.clone()) + } + ( + Some(OptionFooter::CustomText { .. } | OptionFooter::CreateNewAuthSecret) | None, + Some(_) | None, + ) => None, + }; + } + + /// Resets editing and synchronizes the editor with the committed value. + fn reset_editor(&mut self, ctx: &mut ViewContext) { + self.editing = CustomTextEditingState::Inactive; + let value = self.committed_value.clone().unwrap_or_default(); + self.editor + .update(ctx, |editor, ctx| editor.set_text(value, ctx)); + } + + /// Activates the editor with the last committed value. + fn begin_editing(&mut self, ctx: &mut ViewContext) { + let value = self.committed_value.clone().unwrap_or_default(); + self.editor + .update(ctx, |editor, ctx| editor.set_text(value, ctx)); + self.editing = CustomTextEditingState::Active { + error_visible: false, + }; + } + + /// Cancels editing and returns whether a validation row was removed. + fn cancel_editing(&mut self) -> Option { + let error_visible = self.editing.error_visible()?; + self.editing = CustomTextEditingState::Inactive; + Some(error_visible) + } + + /// Shows the validation error and returns whether layout changed. + fn show_validation_error(&mut self) -> bool { + if self.error_is_visible() { + return false; + }; + self.editing = CustomTextEditingState::Active { + error_visible: true, + }; + true + } + /// Clears a visible validation error and returns whether layout changed. + fn clear_validation_error(&mut self) -> bool { + if !self.error_is_visible() { + return false; + } + self.editing = CustomTextEditingState::Active { + error_visible: false, + }; + true + } + + /// Commits a value and returns whether a validation row was removed. + fn commit(&mut self, value: String) -> bool { + let error_visible = self.error_is_visible(); + self.editing = CustomTextEditingState::Inactive; + self.committed_value = Some(value); + error_visible + } +} + +/// Transient list and editor state reset when a page is replaced. +#[derive(Default)] +struct SelectorInteractionState { + selection: InlineMenuSelection, + scroll_offset: usize, + search_query: String, +} + +/// A reusable single-select option list view. See the module docs. +pub(crate) struct TuiOptionSelector { + page: OptionSelectorPage, + interaction: SelectorInteractionState, + search_field: Option>, + custom_text: CustomTextState, + /// Whether the selector itself (the list zone) is focused. + focused: bool, + /// Per-item mouse state, indexed like [`Self::items`]. Owned here (not + /// created inline during render) so hover/click state survives + /// element-tree rebuilds. + item_mouse_states: Vec, +} + +impl TuiOptionSelector { + /// Creates an empty selector; hosts call [`Self::set_page`] before render. + pub(crate) fn new(ctx: &mut ViewContext) -> Self { + let custom_text_editor = ctx.add_typed_action_tui_view(TuiEditorView::single_line); + ctx.subscribe_to_view(&custom_text_editor, |me, _, event, ctx| { + let TuiEditorViewEvent::Changed(_) = event; + if me.custom_text.clear_validation_error() { + me.invalidate_layout(ctx); + } else { + ctx.notify(); + } + }); + + Self { + page: OptionSelectorPage::default(), + interaction: SelectorInteractionState::default(), + search_field: None, + custom_text: CustomTextState::new(custom_text_editor), + focused: false, + item_mouse_states: Vec::new(), + } + } + + /// Creates and subscribes to the optional search editor. + fn add_search_field(ctx: &mut ViewContext) -> ViewHandle { + let search_field = ctx.add_typed_action_tui_view(TuiEditorView::single_line); + ctx.subscribe_to_view(&search_field, |me, _, event, ctx| { + let TuiEditorViewEvent::Changed(query) = event; + me.interaction.search_query = query.clone(); + me.interaction.selection.clear(); + me.interaction.scroll_offset = 0; + me.sync_after_items_changed(); + me.invalidate_layout(ctx); + }); + search_field + } + + /// Notifies this view and any host that caches its intrinsic height. + fn invalidate_layout(&self, ctx: &mut ViewContext) { + ctx.emit(TuiOptionSelectorEvent::LayoutInvalidated); + ctx.notify(); + } + /// Whether the optional search editor currently owns focus. + fn search_field_is_focused(&self, ctx: &AppContext) -> bool { + self.search_field + .as_ref() + .is_some_and(|field| field.as_ref(ctx).is_focused()) + } + + /// Resets all transient interaction state for the newly installed page. + fn reset_interaction_for_page(&mut self, ctx: &mut ViewContext) { + self.interaction = SelectorInteractionState::default(); + if let Some(search_field) = self.search_field.as_ref() { + search_field.update(ctx, |editor, ctx| editor.set_text("", ctx)); + } + self.custom_text.reset_editor(ctx); + self.select_id(self.page.snapshot.selected_id.clone()); + self.sync_after_items_changed(); + ctx.focus_self(); + } + + /// Replaces the current page and resets its transient interaction state. + pub(crate) fn set_page(&mut self, page: OptionSelectorPage, ctx: &mut ViewContext) { + if page.searchable && self.search_field.is_none() { + self.search_field = Some(Self::add_search_field(ctx)); + } + self.page = page; + self.custom_text.sync_committed_value(&self.page.snapshot); + self.reset_interaction_for_page(ctx); + self.invalidate_layout(ctx); + } + + /// Refreshes the snapshot in place after a live catalog change, + /// preserving the active selection when it still exists and falling back + /// to the snapshot's committed selection otherwise. + pub(crate) fn refresh_snapshot( + &mut self, + snapshot: OptionSnapshot, + ctx: &mut ViewContext, + ) { + let selected = self.selected_row_id(); + self.page.snapshot = snapshot; + self.custom_text.sync_committed_value(&self.page.snapshot); + let target = selected + .filter(|id| self.page.snapshot.rows.iter().any(|row| &row.id == id)) + .or_else(|| self.page.snapshot.selected_id.clone()); + if self.search_field_is_focused(ctx) { + self.interaction.selection.clear(); + } else { + self.select_id(target); + } + self.sync_after_items_changed(); + // A refreshed catalog can change the row count and thus the height. + self.invalidate_layout(ctx); + } + + /// Scrolls to keep `selected` visible, announcing the scroll change (it + /// toggles overflow markers, so the height may change) to the host. + fn scroll_to_keep_visible( + &mut self, + items_len: usize, + selected: usize, + ctx: &mut ViewContext, + ) { + let before = self.interaction.scroll_offset; + keep_selected_visible( + items_len, + selected, + MAX_VISIBLE_OPTION_ROWS, + &mut self.interaction.scroll_offset, + ); + if self.interaction.scroll_offset != before { + ctx.emit(TuiOptionSelectorEvent::LayoutInvalidated); + } + } + + /// Confirms the selected item (Enter): enabled rows emit + /// [`TuiOptionSelectorEvent::Confirmed`]; disabled rows are kept + /// selected so their reason stays visible. While the + /// custom-text editor is active, Enter validates and submits it instead + ///. + pub(crate) fn confirm_selected(&mut self, ctx: &mut ViewContext) -> bool { + if self.custom_text.is_editing() { + return self.submit_custom_text(ctx); + } + if self.search_field_is_focused(ctx) { + if let Some(index) = self.items().iter().position(|item| { + matches!(item, SelectorItem::Row(_)) && self.item_is_confirmable(*item) + }) { + return self.confirm_item(index, ctx); + } + return false; + } + let Some(index) = self.interaction.selection.selected_index() else { + return false; + }; + self.confirm_item(index, ctx) + } + + /// Cancels active custom-text editing, returning whether it consumed Back. + fn cancel_custom_text_editing(&mut self, ctx: &mut ViewContext) -> bool { + let Some(layout_changed) = self.custom_text.cancel_editing() else { + return false; + }; + + ctx.focus_self(); + if layout_changed { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + true + } + + /// Activates the custom editor with the last committed value. + fn begin_custom_text_editing(&mut self, ctx: &mut ViewContext) { + self.custom_text.begin_editing(ctx); + ctx.focus(&self.custom_text.editor); + ctx.notify(); + } + + /// Shows the custom-text validation error when it is not already visible. + fn show_custom_text_validation_error(&mut self, ctx: &mut ViewContext) { + if !self.custom_text.show_validation_error() { + ctx.notify(); + return; + } + self.invalidate_layout(ctx); + } + + /// Commits a validated custom-text value and exits its editor. + fn commit_custom_text(&mut self, value: String, ctx: &mut ViewContext) { + let layout_changed = self.custom_text.commit(value.clone()); + self.page.snapshot.selected_id = Some(value.clone()); + ctx.focus_self(); + ctx.emit(TuiOptionSelectorEvent::CustomTextSubmitted { value }); + if layout_changed { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + } + /// Clears focused search text, returning whether it consumed Back. + fn clear_focused_search(&mut self, ctx: &mut ViewContext) -> bool { + if !self.search_field_is_focused(ctx) || self.interaction.search_query.is_empty() { + return false; + } + + self.interaction.search_query.clear(); + if let Some(search_field) = self.search_field.as_ref() { + search_field.update(ctx, |field, ctx| field.set_text("", ctx)); + } + self.interaction.scroll_offset = 0; + self.sync_after_items_changed(); + self.invalidate_layout(ctx); + true + } + + /// Handles Escape from the embedding card, consuming it when the selector + /// has an active editor interaction to unwind. + pub(crate) fn handle_back(&mut self, ctx: &mut ViewContext) -> bool { + self.cancel_custom_text_editing(ctx) || self.clear_focused_search(ctx) + } + + /// The navigable entries, in display order. + fn items(&self) -> Vec { + let query = self.interaction.search_query.to_lowercase(); + let mut items: Vec = (0..self.page.snapshot.rows.len()) + .filter(|index| { + query.is_empty() + || self.page.snapshot.rows[*index] + .label + .to_lowercase() + .contains(&query) + }) + .map(SelectorItem::Row) + .collect(); + if matches!(self.page.snapshot.status, OptionSourceStatus::Failed { .. }) { + items.push(SelectorItem::Retry); + } + match &self.page.snapshot.footer { + Some(OptionFooter::CustomText { .. }) => items.push(SelectorItem::CustomText), + // Resource creation is out of scope in the TUI. + Some(OptionFooter::CreateNewAuthSecret) | None => {} + } + items + } + + /// Whether the item can be confirmed. Disabled rows stay selectable + /// but unconfirmable. + fn item_is_confirmable(&self, item: SelectorItem) -> bool { + match item { + SelectorItem::Row(index) => self + .page + .snapshot + .rows + .get(index) + .is_some_and(|row| row.disabled_reason.is_none()), + SelectorItem::Retry | SelectorItem::CustomText => true, + } + } + + /// The row id currently selected, when the selection is on a row. + fn selected_row_id(&self) -> Option { + let items = self.items(); + match self + .interaction + .selection + .selected_index() + .and_then(|i| items.get(i)) + { + Some(SelectorItem::Row(index)) => self + .page + .snapshot + .rows + .get(*index) + .map(|row| row.id.clone()), + Some(SelectorItem::Retry) | Some(SelectorItem::CustomText) | None => None, + } + } + + /// Moves the selection to the row with `id`, else the first item. + fn select_id(&mut self, id: Option) { + let items = self.items(); + let target = id + .and_then(|id| { + items.iter().position(|item| match item { + SelectorItem::Row(index) => self + .page + .snapshot + .rows + .get(*index) + .is_some_and(|row| row.id == id), + SelectorItem::CustomText => { + self.custom_text.committed_value.as_ref() == Some(&id) + } + SelectorItem::Retry => false, + }) + }) + .or(if items.is_empty() { None } else { Some(0) }); + self.interaction.selection.clear(); + if let Some(target) = target { + self.interaction + .selection + .select(target, items.len(), |_| true); + } + } + + /// Clamps scroll state and mouse-handle storage to the current items. + fn sync_after_items_changed(&mut self) { + let items_len = self.items().len(); + self.interaction.scroll_offset = self + .interaction + .scroll_offset + .min(items_len.saturating_sub(MAX_VISIBLE_OPTION_ROWS)); + if let Some(selected) = self.interaction.selection.selected_index() { + keep_selected_visible( + items_len, + selected, + MAX_VISIBLE_OPTION_ROWS, + &mut self.interaction.scroll_offset, + ); + } + // Handles are stable per item index across renders; grow as needed. + while self.item_mouse_states.len() < items_len { + self.item_mouse_states.push(MouseStateHandle::default()); + } + } + + /// Moves the selection one step, scrolling to keep it visible. + fn move_selection(&mut self, forward: bool, ctx: &mut ViewContext) { + if self.custom_text.is_editing() { + return; + } + let items_len = self.items().len(); + if self.search_field_is_focused(ctx) { + if items_len > 0 { + let target = if forward { 0 } else { items_len - 1 }; + self.interaction + .selection + .select(target, items_len, |_| true); + ctx.focus_self(); + self.scroll_to_keep_visible(items_len, target, ctx); + } + ctx.notify(); + return; + } + let move_to_search = self.page.searchable + && match (forward, self.interaction.selection.selected_index()) { + (false, None | Some(0)) => true, + (true, Some(index)) => index + 1 >= items_len, + (true, None) | (false, Some(_)) => false, + }; + if move_to_search { + self.interaction.selection.clear(); + self.interaction.scroll_offset = 0; + if let Some(search_field) = self.search_field.as_ref() { + ctx.focus(search_field); + } + ctx.notify(); + return; + } + if forward { + self.interaction.selection.select_next(items_len, |_| true); + } else { + self.interaction + .selection + .select_previous(items_len, |_| true); + } + if let Some(selected) = self.interaction.selection.selected_index() { + self.scroll_to_keep_visible(items_len, selected, ctx); + } + ctx.notify(); + } + + /// Confirms the item at `index` when enabled; otherwise selects it so + /// its disabled reason is surfaced. + fn confirm_item(&mut self, index: usize, ctx: &mut ViewContext) -> bool { + let items = self.items(); + let Some(item) = items.get(index).copied() else { + return false; + }; + self.interaction + .selection + .select(index, items.len(), |_| true); + self.scroll_to_keep_visible(items.len(), index, ctx); + if !self.item_is_confirmable(item) { + ctx.notify(); + return false; + } + match item { + SelectorItem::Row(row_index) => { + if let Some(row) = self.page.snapshot.rows.get(row_index) { + ctx.emit(TuiOptionSelectorEvent::Confirmed { id: row.id.clone() }); + } + } + SelectorItem::Retry => ctx.emit(TuiOptionSelectorEvent::RetryRequested), + SelectorItem::CustomText => { + self.begin_custom_text_editing(ctx); + return true; + } + } + ctx.notify(); + true + } + + /// Validates and submits the custom-text editor: the value + /// is trimmed; empty input stays editable with a concise error. + fn submit_custom_text(&mut self, ctx: &mut ViewContext) -> bool { + if !self.custom_text.is_editing() { + return false; + } + let value = self + .custom_text + .editor + .as_ref(ctx) + .text(ctx) + .trim() + .to_string(); + if value.is_empty() { + self.show_custom_text_validation_error(ctx); + false + } else { + self.commit_custom_text(value, ctx); + true + } + } + + /// Scrolls the viewport by `rows` without moving the selection + ///. + fn scroll_by(&mut self, rows: isize, ctx: &mut ViewContext) { + let items_len = self.items().len(); + let max_offset = items_len.saturating_sub(MAX_VISIBLE_OPTION_ROWS); + let before = self.interaction.scroll_offset; + self.interaction.scroll_offset = self + .interaction + .scroll_offset + .saturating_add_signed(rows) + .min(max_offset); + if self.interaction.scroll_offset != before { + self.invalidate_layout(ctx); + } else { + ctx.notify(); + } + } + + // ── Rendering ─────────────────────────────────────────────────── + + /// One header block: field label + position, then the page prompt. + fn render_header(&self, builder: &TuiUiBuilder) -> Box { + let (current, total) = self.page.position; + let title = TuiText::new(self.page.field_label.clone()) + .with_style(builder.primary_text_style()) + .truncate() + .finish(); + let previous_style = if current > 1 { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + let next_style = if current < total { + builder.primary_text_style() + } else { + builder.muted_text_style() + }; + let position = TuiText::from_spans([ + ("←".to_string(), previous_style), + (format!(" {current} "), builder.primary_text_style()), + (format!("of {total} "), builder.muted_text_style()), + ("→".to_string(), next_style), + ]) + .truncate() + .finish(); + let title_row = TuiFlex::row() + .child(title) + .flex_child(TuiFlex::row().finish()) + .child(position) + .finish(); + TuiFlex::column() + .child(title_row) + .child(TuiText::new(" ").finish()) + .child( + TuiText::new(self.page.prompt.clone()) + .with_style(builder.primary_text_style().add_modifier(Modifier::BOLD)) + .finish(), + ) + .finish() + } + + /// One option row: viewport-relative digit, label, badge, and disabled + /// reason, with the current selection rendered in bold magenta. + fn render_row( + &self, + row: &OptionRow, + digit: Option, + is_selected: bool, + builder: &TuiUiBuilder, + ) -> Box { + let disabled = row.disabled_reason.is_some(); + let label_style = if is_selected { + builder.option_selector_selected_style() + } else if disabled { + builder.dim_text_style() + } else { + builder.primary_text_style() + }; + let detail_style = if is_selected { + builder.option_selector_selected_style() + } else if disabled { + builder.dim_text_style() + } else { + builder.muted_text_style() + }; + let digit_prefix = match digit { + Some(digit) => format!("({digit}) "), + None => " ".to_string(), + }; + let mut spans = vec![ + (digit_prefix, detail_style), + (row.label.clone(), label_style), + ]; + let badge = match row.badge { + Some(OptionBadge::Default) => Some("default"), + Some(OptionBadge::Recent) => Some("recent"), + Some(OptionBadge::Connected) => Some("connected"), + None => None, + }; + if let Some(badge) = badge { + spans.push((format!(" ({badge})"), detail_style)); + } + if let Some(reason) = &row.disabled_reason { + spans.push((format!(" — {reason}"), detail_style)); + } + TuiText::from_spans(spans).truncate().finish() + } + + /// A generic single-span selectable virtual row (Retry / custom text). + fn render_virtual_row( + &self, + text: String, + digit: Option, + is_selected: bool, + style: TuiStyle, + builder: &TuiUiBuilder, + ) -> Box { + let style = if is_selected { + builder.option_selector_selected_style() + } else { + style + }; + let digit_prefix = match digit { + Some(digit) => format!("({digit}) "), + None => " ".to_string(), + }; + TuiText::from_spans([(format!("{digit_prefix}{text}"), style)]) + .truncate() + .finish() + } + + /// Renders selector-owned label/error chrome around a generic editor view. + fn render_editor_field( + &self, + prefix: String, + label: &str, + editor: &ViewHandle, + error: Option<&str>, + builder: &TuiUiBuilder, + ) -> Box { + let label = TuiText::new(format!("{prefix}{label}: ")) + .with_style(builder.muted_text_style()) + .truncate() + .finish(); + let row = TuiFlex::row() + .child(label) + .flex_child(TuiChildView::new(editor).finish()) + .finish(); + let mut content = TuiFlex::column().child(row); + if let Some(error) = error { + content.add_child( + TuiText::new(error.to_string()) + .with_style(builder.error_text_style()) + .truncate() + .finish(), + ); + } + content.finish() + } + + /// The option list: visible window of items with digit prefixes, plus + /// non-selectable status rows for Loading/Failed/Empty. + fn render_list(&self, builder: &TuiUiBuilder) -> Box { + let items = self.items(); + let mut column = TuiFlex::column(); + + let visible_end = + (self.interaction.scroll_offset + MAX_VISIBLE_OPTION_ROWS).min(items.len()); + let visible = self.interaction.scroll_offset..visible_end; + if self.page.searchable + && !self.interaction.search_query.is_empty() + && !items + .iter() + .any(|item| matches!(item, SelectorItem::Row(_))) + { + column.add_child( + TuiText::new("No matches") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + if self.interaction.scroll_offset > 0 { + column.add_child( + TuiText::new("↑") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + for (position, index) in visible.clone().enumerate() { + let item = items[index]; + let digit = (position < 9).then_some(position + 1); + let is_selected = !self.custom_text.is_editing() + && self.interaction.selection.selected_index() == Some(index); + let element = match item { + SelectorItem::Row(row_index) => { + let Some(row) = self.page.snapshot.rows.get(row_index) else { + continue; + }; + self.render_row(row, digit, is_selected, builder) + } + SelectorItem::Retry => self.render_virtual_row( + "↻ Retry".to_string(), + digit, + is_selected, + builder.error_text_style(), + builder, + ), + SelectorItem::CustomText => { + match (&self.page.snapshot.footer, self.custom_text.is_editing()) { + (Some(OptionFooter::CustomText { label }), true) => self + .render_editor_field( + digit.map_or_else( + || " ".to_string(), + |digit| format!("({digit}) "), + ), + label, + &self.custom_text.editor, + self.custom_text + .error_is_visible() + .then_some(CUSTOM_TEXT_EMPTY_ERROR), + builder, + ), + (Some(OptionFooter::CustomText { label }), false) => self + .render_virtual_row( + self.custom_text + .committed_value + .clone() + .unwrap_or_else(|| label.clone()), + digit, + is_selected, + builder.primary_text_style(), + builder, + ), + (Some(OptionFooter::CreateNewAuthSecret) | None, _) => continue, + } + } + }; + // Each visible row is clickable through its own persistent + // mouse-state handle. + let element = match self.item_mouse_states.get(index) { + Some(mouse_state) => TuiHoverable::new(mouse_state.clone(), element) + .on_click(move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::SelectItem(index)); + }) + .finish(), + None => element, + }; + column.add_child(element); + } + if visible_end < items.len() { + column.add_child( + TuiText::new("↓") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + + match &self.page.snapshot.status { + OptionSourceStatus::Ready => {} + OptionSourceStatus::Loading => { + column.add_child( + TuiText::new("Loading…") + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + OptionSourceStatus::Failed { message } => { + column.add_child( + TuiText::new(message.clone()) + .with_style(builder.error_text_style()) + .truncate() + .finish(), + ); + } + OptionSourceStatus::Empty { message } => { + column.add_child( + TuiText::new(message.clone()) + .with_style(builder.dim_text_style()) + .truncate() + .finish(), + ); + } + } + column.finish() + } +} + +impl Entity for TuiOptionSelector { + type Event = TuiOptionSelectorEvent; +} + +impl TuiView for TuiOptionSelector { + fn ui_name() -> &'static str { + "TuiOptionSelector" + } + + fn render(&self, app: &AppContext) -> Box { + let builder = TuiUiBuilder::from_app(app); + let mut content = TuiFlex::column().child(self.render_header(&builder)); + if let Some(search_field) = self + .page + .searchable + .then_some(self.search_field.as_ref()) + .flatten() + { + content.add_child(self.render_editor_field( + String::new(), + "Search", + search_field, + None, + &builder, + )); + } + content.add_child(self.render_list(&builder)); + SelectorInputElement { + child: content.finish(), + list_focused: self.focused, + searchable: self.page.searchable, + } + .finish() + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + let mut ids = vec![self.custom_text.editor.id()]; + if self.page.searchable { + ids.extend(self.search_field.iter().map(ViewHandle::id)); + } + ids + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + match focus_ctx { + FocusContext::SelfFocused => self.focused = true, + FocusContext::DescendentFocused(view_id) => { + self.focused = false; + if self + .search_field + .as_ref() + .is_some_and(|search_field| *view_id == search_field.id()) + { + self.interaction.selection.clear(); + } + } + } + ctx.notify(); + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } +} + +impl TypedActionView for TuiOptionSelector { + fn handle_action(&mut self, action: &TuiOptionSelectorAction, ctx: &mut ViewContext) { + match action { + TuiOptionSelectorAction::ConfirmSelected => { + self.confirm_selected(ctx); + } + TuiOptionSelectorAction::MoveUp => self.move_selection(false, ctx), + TuiOptionSelectorAction::MoveDown => self.move_selection(true, ctx), + TuiOptionSelectorAction::SelectNumberedOption(digit) => { + let index = self.interaction.scroll_offset + usize::from(*digit) - 1; + self.confirm_item(index, ctx); + } + TuiOptionSelectorAction::SelectItem(index) => { + self.confirm_item(*index, ctx); + } + TuiOptionSelectorAction::ScrollBy(rows) => self.scroll_by(*rows, ctx), + TuiOptionSelectorAction::FocusSearchAndInsert(c) => { + if let Some(search_field) = + self.search_field.clone().filter(|_| self.page.searchable) + { + self.interaction.search_query.push(*c); + let query = self.interaction.search_query.clone(); + search_field.update(ctx, |field, ctx| field.set_text(query, ctx)); + self.interaction.selection.clear(); + self.interaction.scroll_offset = 0; + self.sync_after_items_changed(); + ctx.focus(&search_field); + self.invalidate_layout(ctx); + } + } + TuiOptionSelectorAction::Dismiss => { + if !self.handle_back(ctx) { + ctx.emit(TuiOptionSelectorEvent::Dismissed); + } + } + } + } + + type Action = TuiOptionSelectorAction; +} + +/// Wraps the selector's rendered content and translates element-level input +/// (confirmation, arrows, digits, custom-text characters, wheel scrolling) into +/// [`TuiOptionSelectorAction`]s. +struct SelectorInputElement { + child: Box, + list_focused: bool, + searchable: bool, +} + +impl TuiElement for SelectorInputElement { + fn layout( + &mut self, + constraint: TuiConstraint, + ctx: &mut TuiLayoutContext, + app: &AppContext, + ) -> TuiSize { + self.child.layout(constraint, ctx, app) + } + + fn render( + &mut self, + origin: TuiScreenPosition, + surface: &mut TuiPaintSurface<'_>, + ctx: &mut TuiPaintContext, + ) { + self.child.render(origin, surface, ctx); + } + + fn size(&self) -> Option { + self.child.size() + } + + fn origin(&self) -> Option { + self.child.origin() + } + + fn present(&mut self, ctx: &mut TuiPresentationContext<'_>) { + self.child.present(ctx); + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + event_ctx: &mut TuiEventContext<'_>, + app: &AppContext, + ) -> bool { + if self.child.dispatch_event(event, event_ctx, app) { + return true; + } + match event { + TuiEvent::KeyDown { + keystroke, chars, .. + } => { + if keystroke.ctrl || keystroke.alt || keystroke.cmd || keystroke.meta { + return false; + } + match keystroke.key.as_str() { + "enter" | "numpadenter" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::ConfirmSelected); + true + } + "escape" => { + // Escape fallback for hosts without their own + // Escape keymap binding; the embedding card's + // `escape` binding normally consumes the key first. + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::Dismiss); + true + } + "up" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::MoveUp); + true + } + "down" => { + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::MoveDown); + true + } + key if self.list_focused => match key.parse::() { + Ok(digit @ 1..=9) => { + event_ctx.dispatch_typed_action( + TuiOptionSelectorAction::SelectNumberedOption(digit), + ); + true + } + Ok(_) => false, + Err(_) => { + let Some(c) = chars.chars().next().filter(|c| !c.is_control()) else { + return false; + }; + if self.searchable { + event_ctx.dispatch_typed_action( + TuiOptionSelectorAction::FocusSearchAndInsert(c), + ); + true + } else { + false + } + } + }, + _ => false, + } + } + TuiEvent::ScrollWheel { + position, delta, .. + } => { + let Some((origin, size)) = self.origin().zip(self.size()) else { + return false; + }; + if !event_ctx.hit_test(origin, size, *position) { + return false; + } + let (_, rows) = *delta; + if rows == 0 { + return false; + } + // Positive wheel delta scrolls the content up (toward the + // start of the list), matching the transcript's scrollable. + event_ctx.dispatch_typed_action(TuiOptionSelectorAction::ScrollBy(-rows)); + true + } + TuiEvent::Paste { .. } => false, + TuiEvent::LeftMouseDown { .. } + | TuiEvent::LeftMouseUp { .. } + | TuiEvent::LeftMouseDragged { .. } + | TuiEvent::MiddleMouseDown { .. } + | TuiEvent::RightMouseDown { .. } + | TuiEvent::MouseMoved { .. } => false, + } + } +} + +#[cfg(test)] +#[path = "option_selector_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/option_selector_tests.rs b/crates/warp_tui/src/option_selector_tests.rs new file mode 100644 index 00000000000..56e9f75014d --- /dev/null +++ b/crates/warp_tui/src/option_selector_tests.rs @@ -0,0 +1,1042 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use warp::tui_export::{ + Appearance, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityId, EntityIdMap}; +use warpui_core::elements::tui::{ + Modifier, TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, + TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, +}; +use warpui_core::keymap::Keystroke; +use warpui_core::{App, AppContext, TuiView as _, TypedActionView as _, ViewHandle}; + +use super::{ + OptionSelectorPage, SelectorItem, TuiOptionSelector, TuiOptionSelectorAction, + TuiOptionSelectorEvent, +}; +use crate::editor_element::TuiEditorAction; +use crate::editor_interaction::TuiEditorCommand; +use crate::editor_view::TuiEditorViewAction; +use crate::test_fixtures::TestHostView; +use crate::tui_builder::TuiUiBuilder; + +/// Builds an enabled row with `id` used as the label. +fn row(id: &str) -> OptionRow { + OptionRow { + id: id.to_string(), + label: id.to_string(), + harness: None, + badge: None, + disabled_reason: None, + } +} + +/// Builds a disabled row carrying `reason`. +fn disabled_row(id: &str, reason: &str) -> OptionRow { + OptionRow { + disabled_reason: Some(reason.to_string()), + ..row(id) + } +} + +/// Builds a `Ready` snapshot over `ids` with `selected` preselected. +fn snapshot(ids: &[&str], selected: Option<&str>) -> OptionSnapshot { + snapshot_of(ids.iter().map(|id| row(id)).collect(), selected) +} + +/// Builds a `Ready` snapshot from explicit rows. +fn snapshot_of(rows: Vec, selected: Option<&str>) -> OptionSnapshot { + OptionSnapshot { + rows, + selected_id: selected.map(str::to_string), + status: OptionSourceStatus::Ready, + footer: None, + } +} + +/// Builds one selector page with shared test metadata. +fn page(snapshot: OptionSnapshot, searchable: bool) -> OptionSelectorPage { + OptionSelectorPage { + field_label: "Host".to_string(), + position: (4, 6), + prompt: "Which host should run the agents?".to_string(), + snapshot, + searchable, + } +} + +type CapturedEvents = Rc>>; + +/// The captured events with `LayoutInvalidated` filtered out, for tests that +/// assert on the primary confirmation flow. +fn primary_events(events: &CapturedEvents) -> Vec { + events + .borrow() + .iter() + .filter(|event| **event != TuiOptionSelectorEvent::LayoutInvalidated) + .cloned() + .collect() +} + +/// Adds a selector in a fresh TUI window and captures its emitted events. +fn add_selector(app: &mut App) -> (ViewHandle, CapturedEvents) { + app.add_singleton_model(|_| Appearance::mock()); + let selector = app.update(|ctx| { + let (window_id, _) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ); + ctx.add_typed_action_tui_view(window_id, TuiOptionSelector::new) + }); + let events: CapturedEvents = Rc::new(RefCell::new(Vec::new())); + let events_for_subscription = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&selector, move |_, event, _| { + events_for_subscription.borrow_mut().push(event.clone()); + }); + }); + (selector, events) +} + +/// Sets the page under the shared test header. +fn set_page(app: &mut App, selector: &ViewHandle, snapshot: OptionSnapshot) { + selector.update(app, |selector, ctx| { + selector.set_page(page(snapshot, false), ctx); + }); +} +#[test] +fn search_editor_is_created_only_for_searchable_pages() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["auto"], Some("auto"))); + assert!(selector.read(&app, |selector, _| selector.search_field.is_none())); + + set_searchable_page(&mut app, &selector, snapshot(&["auto"], Some("auto"))); + assert!(selector.read(&app, |selector, _| selector.search_field.is_some())); + }); +} + +#[test] +fn searchable_page_starts_on_the_selected_row_and_digits_still_confirm() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5", "claude"], Some("gpt-5")), + ); + assert!(selected_line(&app, &selector).contains("(2) gpt-5")); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Search:"))); + + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(3), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "claude".to_string() + }] + ); + }); +} + +#[test] +fn up_from_top_focuses_search_and_down_returns_to_first_row() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5"], Some("auto")), + ); + + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .interaction + .selection + .selected_index() + .is_none())); + + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(selected_line(&app, &selector).contains("(1) auto")); + }); +} + +#[test] +fn search_and_last_option_wrap_in_both_directions() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5", "claude"], Some("auto")), + ); + + // Up from the first option focuses Search; another Up wraps to the + // last option. + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(selected_line(&app, &selector).contains("(3) claude")); + + // Down from the last option returns to Search. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .interaction + .selection + .selected_index() + .is_none())); + }); +} +#[test] +fn focused_search_filters_including_digits_and_enter_confirms_top_match() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto (genius)", "gpt-5", "claude"], Some("auto (genius)")), + ); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + edit_search( + &mut app, + &selector, + TuiEditorAction::InsertText("gpt-5".to_string()), + ); + + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("gpt-5"))); + assert!(!lines.iter().any(|line| line.contains("auto (genius)"))); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "gpt-5".to_string() + }] + ); + }); +} + +#[test] +fn typing_a_letter_from_the_list_focuses_and_seeds_search() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot(&["auto", "gpt-5"], Some("auto")), + ); + act( + &mut app, + &selector, + TuiOptionSelectorAction::FocusSearchAndInsert('g'), + ); + assert!(app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .is_focused())); + assert_eq!( + app.read(|ctx| selector + .as_ref(ctx) + .search_field + .as_ref() + .expect("searchable page has an editor") + .as_ref(ctx) + .text(ctx)), + "g" + ); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("gpt-5"))); + }); +} + +#[test] +fn search_no_matches_and_escape_clear_are_rendered_without_moving_the_field() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_searchable_page( + &mut app, + &selector, + snapshot( + &["auto", "gpt-5", "claude", "gemini", "pareto"], + Some("auto"), + ), + ); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + edit_search( + &mut app, + &selector, + TuiEditorAction::InsertText("zzz".to_string()), + ); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("Search:"))); + assert!(lines.iter().any(|line| line.contains("No matches"))); + + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(consumed); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(1) auto"))); + assert!(lines.iter().any(|line| line.contains("Search:"))); + }); +} + +/// Sets a searchable page under the shared test header. +fn set_searchable_page( + app: &mut App, + selector: &ViewHandle, + snapshot: OptionSnapshot, +) { + selector.update(app, |selector, ctx| { + selector.set_page(page(snapshot, true), ctx); + }); +} + +/// Applies a text-field action to the active custom-text child. +fn edit_custom_text( + app: &mut App, + selector: &ViewHandle, + action: TuiEditorViewAction, +) { + let field = custom_text_field(app, selector); + field.update(app, |field, ctx| field.handle_action(&action, ctx)); +} +/// Returns the selector's custom-text editor for focus and action assertions. +fn custom_text_field( + app: &App, + selector: &ViewHandle, +) -> ViewHandle { + selector.read(app, |selector, _| selector.custom_text.editor.clone()) +} + +/// Applies a shared editor action to the search child. +fn edit_search(app: &mut App, selector: &ViewHandle, action: TuiEditorAction) { + let editor = selector.read(app, |selector, _| { + selector + .search_field + .clone() + .expect("searchable page has an editor") + }); + editor.update(app, |editor, ctx| { + editor.handle_action(&TuiEditorViewAction::Editor(action), ctx); + }); +} + +#[test] +fn set_page_recovers_a_selected_custom_text_value() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_custom_selection = snapshot(&["warp"], Some("my-host")); + with_custom_selection.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + + set_page(&mut app, &selector, with_custom_selection); + + let line = selected_line(&app, &selector); + assert!(line.contains("my-host")); + assert!(!line.contains("Custom host…")); + }); +} + +/// Dispatches a selector action directly to the view. +fn act(app: &mut App, selector: &ViewHandle, action: TuiOptionSelectorAction) { + selector.update(app, |selector, ctx| selector.handle_action(&action, ctx)); +} + +/// Confirms the selected item through the selector-owned action. +fn confirm(app: &mut App, selector: &ViewHandle) { + act(app, selector, TuiOptionSelectorAction::ConfirmSelected); +} + +/// Lays out the selector's element at `width`, returning it with its area. +fn laid_out_element( + selector: &ViewHandle, + rendered_views: &mut EntityIdMap>, + width: u16, + app: &AppContext, +) -> (Box, TuiRect) { + let selector_ref = selector.as_ref(app); + rendered_views.insert( + selector_ref.custom_text.editor.id(), + selector_ref.custom_text.editor.as_ref(app).render(app), + ); + if let Some(search_field) = selector_ref.search_field.as_ref() { + rendered_views.insert(search_field.id(), search_field.as_ref(app).render(app)); + } + let mut element = selector_ref.render(app); + let size = { + let mut layout_ctx = TuiLayoutContext { rendered_views }; + element.layout( + TuiConstraint::loose(TuiSize::new(width, u16::MAX)), + &mut layout_ctx, + app, + ) + }; + let area = TuiRect::new(0, 0, size.width.max(1), size.height.max(1)); + (element, area) +} + +/// Renders the selector to a styled cell buffer at `width`. +fn render_buffer(app: &App, selector: &ViewHandle, width: u16) -> TuiBuffer { + app.read(|app| { + let mut rendered_views = EntityIdMap::default(); + let (mut element, area) = laid_out_element(selector, &mut rendered_views, width, app); + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + { + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + } + buffer + }) +} + +/// Renders the selector to trimmed lines at `width`. +fn render_lines(app: &App, selector: &ViewHandle, width: u16) -> Vec { + render_buffer(app, selector, width) + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .collect() +} + +/// The rendered line for the selector's selected item. +fn selected_line(app: &App, selector: &ViewHandle) -> String { + let needle = app.read(|app| { + let selector = selector.as_ref(app); + let index = selector + .interaction + .selection + .selected_index() + .expect("a selected item"); + let digit = index - selector.interaction.scroll_offset + 1; + let label = match selector.items()[index] { + SelectorItem::Row(row_index) => selector.page.snapshot.rows[row_index].label.clone(), + SelectorItem::Retry => "↻ Retry".to_string(), + SelectorItem::CustomText => selector + .custom_text + .committed_value + .clone() + .or_else(|| match &selector.page.snapshot.footer { + Some(OptionFooter::CustomText { label }) => Some(label.clone()), + Some(OptionFooter::CreateNewAuthSecret) | None => None, + }) + .expect("custom-text footer label"), + }; + format!("({digit}) {label}") + }); + render_lines(app, selector, 60) + .into_iter() + .find(|line| line.contains(&needle)) + .expect("a selected row") +} + +#[test] +fn renders_field_label_position_prompt_and_initial_selection() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("b"))); + let lines = render_lines(&app, &selector, 60); + // Header: field label, position in the current sequence, and prompt. + assert!(lines[0].contains("Host")); + assert!(lines[0].contains("←")); + assert!(lines[0].contains("4 of 6")); + assert!(lines[0].contains("→")); + assert!(lines[0].ends_with("← 4 of 6 →")); + assert!(lines[1].is_empty()); + assert!(lines[2].contains("Which host should run the agents?")); + // The selection starts on the snapshot's current value. + let selected = selected_line(&app, &selector); + assert!(selected.contains("(2) b")); + assert!(!selected.contains('❯')); + + let buffer = render_buffer(&app, &selector, 60); + let builder = app.read(TuiUiBuilder::from_app); + let selected = &buffer[(0, 4)]; + assert_eq!( + selected.fg, + builder + .option_selector_selected_style() + .fg + .expect("selected option has a foreground") + ); + assert!(selected.modifier.contains(Modifier::BOLD)); + }); +} + +#[test] +fn up_and_down_move_the_selection_and_enter_confirms_it() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert!(selected_line(&app, &selector).contains('b')); + act(&mut app, &selector, TuiOptionSelectorAction::MoveUp); + assert!(selected_line(&app, &selector).contains('a')); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "b".to_string() + }], + ); + }); +} + +#[test] +fn digits_confirm_the_corresponding_visible_row() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(3), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "c".to_string() + }], + ); + }); +} + +#[test] +fn digits_are_viewport_relative_in_scrolled_lists() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let ids: Vec = (0..12).map(|i| format!("row-{i}")).collect(); + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + set_page(&mut app, &selector, snapshot(&id_refs, Some("row-0"))); + // Scroll two rows down; digit 1 now confirms the third row, + // and the clipped top renders an overflow marker. + act(&mut app, &selector, TuiOptionSelectorAction::ScrollBy(2)); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.trim() == "↑")); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(1), + ); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "row-2".to_string() + }], + ); + }); +} + +#[test] +fn navigation_scrolls_to_keep_the_selection_visible() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let ids: Vec = (0..12).map(|i| format!("row-{i}")).collect(); + let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + set_page(&mut app, &selector, snapshot(&id_refs, Some("row-0"))); + for _ in 0..9 { + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + } + // The selection scrolled beyond the first viewport. + assert!(selected_line(&app, &selector).contains("row-9")); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.trim() == "↑")); + }); +} + +#[test] +fn list_viewport_shows_six_rows_and_arrow_overflow_markers() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")), + ); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(6) f"))); + assert!(!lines.iter().any(|line| line.contains("(7) g"))); + assert!(lines.iter().any(|line| line.trim() == "↓")); + + act(&mut app, &selector, TuiOptionSelectorAction::ScrollBy(2)); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.trim() == "↑")); + assert!(lines.iter().any(|line| line.contains("(1) c"))); + }); +} + +#[test] +fn disabled_rows_are_selectable_but_not_confirmable() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot_of( + vec![ + row("a"), + disabled_row("b", "Disabled by your administrator"), + ], + Some("a"), + ), + ); + // The disabled row can be selected and shows its reason + // … + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + let line = selected_line(&app, &selector); + assert!(line.contains('b')); + assert!(line.contains("Disabled by your administrator")); + // … but neither Enter, its digit, nor a click confirms it. + confirm(&mut app, &selector); + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectNumberedOption(2), + ); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(primary_events(&events).is_empty()); + }); +} + +#[test] +fn loading_and_empty_states_render_non_selectable_status_rows() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut loading = snapshot(&["Skip (advanced)"], None); + loading.status = OptionSourceStatus::Loading; + set_page(&mut app, &selector, loading); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Loading…"))); + + let mut empty = snapshot_of(Vec::new(), None); + empty.status = OptionSourceStatus::Empty { + message: "No harnesses available".to_string(), + }; + set_page(&mut app, &selector, empty); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("No harnesses available"))); + // Nothing is confirmable in an empty list. + confirm(&mut app, &selector); + assert!(primary_events(&events).is_empty()); + }); +} + +#[test] +fn failed_state_offers_a_retry_row_that_emits_retry_requested() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut failed = snapshot(&["Skip (advanced)"], None); + failed.status = OptionSourceStatus::Failed { + message: "Unable to load secrets".to_string(), + }; + set_page(&mut app, &selector, failed); + let lines = render_lines(&app, &selector, 60); + assert!(lines + .iter() + .any(|line| line.contains("Unable to load secrets"))); + assert!(lines.iter().any(|line| line.contains("Retry"))); + // The Retry row is reachable by keyboard. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::RetryRequested], + ); + }); +} + +#[test] +fn custom_text_editor_trims_validates_and_submits() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + // The footer renders and confirming it opens the one-line editor. + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Custom host…"))); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + + // Whitespace-only input stays editable with a concise error + //. + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar(' ')), + ); + confirm(&mut app, &selector); + assert!(primary_events(&events).is_empty()); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Enter a value to continue."))); + + // Valid input is trimmed and submitted. + for c in "my-host ".chars() { + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar(c)), + ); + } + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Command(TuiEditorCommand::Backspace), + ); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::CustomTextSubmitted { + value: "my-host".to_string() + }], + ); + assert!(!custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + let line = selected_line(&app, &selector); + assert!(line.contains("my-host")); + assert!(!line.contains("Custom host…")); + + // Editing the custom option again starts from the submitted value. + confirm(&mut app, &selector); + assert!(render_lines(&app, &selector, 60) + .iter() + .any(|line| line.contains("Custom host…: my-host"))); + }); +} + +#[test] +fn back_cancels_custom_text_editing_before_leaving_the_page() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + // The first Back unwinds editing and is consumed; the next one isn't. + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(consumed); + assert!(!custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + let consumed = selector.update(&mut app, |selector, ctx| selector.handle_back(ctx)); + assert!(!consumed); + }); +} + +/// Arrow navigation leaves list state untouched while custom text owns focus. +#[test] +fn arrows_do_not_navigate_the_list_while_editing_custom_text() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + let custom_text_index = 8; + + for action in [ + TuiOptionSelectorAction::MoveUp, + TuiOptionSelectorAction::MoveDown, + ] { + act( + &mut app, + &selector, + TuiOptionSelectorAction::SelectItem(custom_text_index), + ); + let before = selector.read(&app, |selector, _| { + ( + selector.interaction.selection.selected_index(), + selector.interaction.scroll_offset, + ) + }); + + act(&mut app, &selector, action); + + let after = selector.read(&app, |selector, _| { + ( + selector.interaction.selection.selected_index(), + selector.interaction.scroll_offset, + ) + }); + assert_eq!(after, before); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + } + }); +} + +#[test] +fn create_new_auth_secret_footer_is_ignored() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["Skip (advanced)"], None); + with_footer.footer = Some(OptionFooter::CreateNewAuthSecret); + set_page(&mut app, &selector, with_footer); + // Resource creation is out of scope in the TUI: the + // footer contributes no navigable item. + assert!(render_lines(&app, &selector, 60) + .iter() + .all(|line| !line.contains("New API key"))); + }); +} + +/// Page replacement invalidates any host-cached selector measurement. +#[test] +fn set_page_invalidates_layout() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + + set_page(&mut app, &selector, snapshot(&["a"], Some("a"))); + + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + }); +} +#[test] +fn layout_invalidated_is_emitted_only_when_overflow_markers_toggle() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page( + &mut app, + &selector, + snapshot(&["a", "b", "c", "d", "e", "f", "g", "h"], Some("a")), + ); + events.borrow_mut().clear(); + + // Moves within the viewport do not scroll, so nothing is emitted. + for _ in 0..5 { + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + } + assert!(!events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + + // Scrolling past the viewport reveals the `↑` marker: one event. + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| **event == TuiOptionSelectorEvent::LayoutInvalidated) + .count(), + 1, + ); + }); +} + +#[test] +fn layout_invalidated_is_emitted_when_the_custom_text_error_row_toggles() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + events.borrow_mut().clear(); + + // An empty submit adds the validation-error row. + confirm(&mut app, &selector); + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + events.borrow_mut().clear(); + + // Typing clears the error row. + edit_custom_text( + &mut app, + &selector, + TuiEditorViewAction::Editor(TuiEditorAction::InsertChar('x')), + ); + assert!(events + .borrow() + .contains(&TuiOptionSelectorEvent::LayoutInvalidated)); + }); +} + +#[test] +fn snapshot_refresh_preserves_the_selected_row() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b", "c"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + // The selected row survives a catalog refresh that reorders rows + //. + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot(snapshot(&["c", "a"], Some("a")), ctx); + }); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "c".to_string() + }], + ); + }); +} + +#[test] +fn snapshot_refresh_falls_back_to_the_selected_value_when_the_selection_vanishes() { + App::test((), |mut app| async move { + let (selector, events) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b"], Some("a"))); + act(&mut app, &selector, TuiOptionSelectorAction::MoveDown); + // "b" disappears from the catalog; the selection falls back to the + // snapshot's current value rather than silently confirming anything + //. + selector.update(&mut app, |selector, ctx| { + selector.refresh_snapshot(snapshot(&["a", "x"], Some("a")), ctx); + }); + confirm(&mut app, &selector); + assert_eq!( + primary_events(&events), + [TuiOptionSelectorEvent::Confirmed { + id: "a".to_string() + }], + ); + }); +} + +/// Dispatches `event` to the selector's freshly rendered and painted element +/// tree, returning whether it was handled. +fn dispatch(app: &App, selector: &ViewHandle, event: &TuiEvent) -> bool { + app.read(|app| { + let mut rendered_views = EntityIdMap::default(); + let (mut element, area) = laid_out_element(selector, &mut rendered_views, 60, app); + // Paint so the tree retains geometry and the scene supports hit + // testing during dispatch. + let scene = { + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + { + let mut surface = TuiPaintSurface::new(&mut buffer); + element.render(TuiScreenPosition::new(0, 0), &mut surface, &mut paint_ctx); + } + Rc::new(paint_ctx.scene.clone()) + }; + let mut event_ctx = TuiEventContext::new(scene, &mut rendered_views); + event_ctx.set_origin_view(Some(EntityId::new())); + element.dispatch_event(event, &mut event_ctx, app) + }) +} + +/// Builds an unmodified key-down event for `key`. +fn key_down(key: &str) -> TuiEvent { + TuiEvent::KeyDown { + keystroke: Keystroke { + key: key.to_string(), + ..Default::default() + }, + chars: String::new(), + details: Default::default(), + is_composing: false, + } +} + +#[test] +fn enter_and_numpad_enter_are_consumed_by_the_selector_element() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a"], Some("a"))); + for key in ["enter", "numpadenter"] { + assert!(dispatch(&app, &selector, &key_down(key)), "{key}"); + } + }); +} + +#[test] +fn paste_is_consumed_only_while_editing_custom_text() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let mut with_footer = snapshot(&["warp"], Some("warp")); + with_footer.footer = Some(OptionFooter::CustomText { + label: "Custom host…".to_string(), + }); + set_page(&mut app, &selector, with_footer); + let paste = TuiEvent::Paste { + text: "my-host\nsecond line".to_string(), + }; + // Ignored while the list (not the editor) is active. + assert!(!dispatch(&app, &selector, &paste)); + + act(&mut app, &selector, TuiOptionSelectorAction::SelectItem(1)); + assert!(custom_text_field(&app, &selector).read(&app, |field, _| field.is_focused())); + // The editor consumes the paste (only the first line's printable + // characters are inserted; the editor is single-line). + assert!(dispatch(&app, &selector, &paste)); + // The shared editor consumes the paste event, but its single-line + // policy inserts nothing when the first line is empty. + let control_only = TuiEvent::Paste { + text: "\nsecond line".to_string(), + }; + assert!(dispatch(&app, &selector, &control_only)); + }); +} + +#[test] +fn badges_render_next_to_their_rows() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + let rows = vec![ + OptionRow { + badge: Some(OptionBadge::Default), + ..row("team-host") + }, + OptionRow { + badge: Some(OptionBadge::Connected), + ..row("worker-1") + }, + OptionRow { + badge: Some(OptionBadge::Recent), + ..row("old-host") + }, + ]; + set_page(&mut app, &selector, snapshot_of(rows, Some("team-host"))); + let lines = render_lines(&app, &selector, 60); + assert!(lines.iter().any(|line| line.contains("(default)"))); + assert!(lines.iter().any(|line| line.contains("(connected)"))); + assert!(lines.iter().any(|line| line.contains("(recent)"))); + }); +} diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 197d01f9921..214a9e562cc 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -221,6 +221,15 @@ impl TuiUiBuilder { TuiStyle::default().fg(cell_color(self.warping_base_fill())) } + /// Bold magenta text for a selected option-selector row. + pub(crate) fn option_selector_selected_style(&self) -> TuiStyle { + TuiStyle::default() + .fg(cell_color(ThemeFill::from( + self.warp_theme.terminal_colors().normal.magenta, + ))) + .add_modifier(Modifier::BOLD) + } + /// Collapsible-header style while the pointer hovers it. fn hovered_header_style(&self) -> TuiStyle { self.primary_text_style().add_modifier(Modifier::BOLD) diff --git a/crates/warpui_core/src/runtime/event_conversion.rs b/crates/warpui_core/src/runtime/event_conversion.rs index ad90c0698e8..878a5f11e28 100644 --- a/crates/warpui_core/src/runtime/event_conversion.rs +++ b/crates/warpui_core/src/runtime/event_conversion.rs @@ -140,7 +140,7 @@ fn key_name(code: KeyCode, modifiers: KeyModifiers) -> Option { KeyCode::End => Some("end".to_owned()), KeyCode::PageUp => Some("pageup".to_owned()), KeyCode::PageDown => Some("pagedown".to_owned()), - KeyCode::Tab | KeyCode::BackTab => Some("\t".to_owned()), + KeyCode::Tab | KeyCode::BackTab => Some("tab".to_owned()), KeyCode::Delete => Some("delete".to_owned()), KeyCode::Insert => Some("insert".to_owned()), KeyCode::Esc => Some("escape".to_owned()), diff --git a/crates/warpui_core/src/runtime/event_conversion_tests.rs b/crates/warpui_core/src/runtime/event_conversion_tests.rs index ed36429eb95..4c673430f01 100644 --- a/crates/warpui_core/src/runtime/event_conversion_tests.rs +++ b/crates/warpui_core/src/runtime/event_conversion_tests.rs @@ -63,6 +63,14 @@ fn arrow_keys_map_to_direction_names() { assert_eq!(keystroke(KeyCode::Down, KeyModifiers::empty()).key, "down"); } +#[test] +fn tab_maps_to_the_canonical_keybinding_name() { + assert_eq!(keystroke(KeyCode::Tab, KeyModifiers::empty()).key, "tab"); + let back_tab = keystroke(KeyCode::BackTab, KeyModifiers::SHIFT); + assert_eq!(back_tab.key, "tab"); + assert!(back_tab.shift); +} + #[test] fn ctrl_modifier_is_carried_into_keystroke() { let keystroke = keystroke(KeyCode::Char('c'), KeyModifiers::CONTROL); diff --git a/specs/code-1822-tui-option-selector/TECH.md b/specs/code-1822-tui-option-selector/TECH.md new file mode 100644 index 00000000000..7d322daefc7 --- /dev/null +++ b/specs/code-1822-tui-option-selector/TECH.md @@ -0,0 +1,167 @@ +# TECH: Reusable TUI option selector over shared option snapshots + +## Context + +This slice builds on the frontend-neutral orchestration option snapshots +(base commit `d6da3b23`; see `specs/code-1822-option-snapshots/TECH.md` for the data +contract). At that base, `app/src/tui_export.rs` re-exports `OptionSnapshot`, +`OptionRow`, `OptionBadge`, `OptionSourceStatus`, and `OptionFooter`, but nothing in +`crates/warp_tui` renders them: the TUI has no single-select list primitive. + +This PR adds that primitive — `TuiOptionSelector` — on top of the generic +`TuiEditorView::single_line` supplied by the preceding stack branch. The next slice +(the TUI orchestration permission/configuration card) embeds the selector to render +its per-field configuration pages; the same primitive is intended for future +AskUserQuestion and permission prompts, which is why it is snapshot-driven and knows +nothing about orchestration edit state. + +## Proposed changes + +### `crates/warp_tui/src/option_selector.rs` + +A `TuiView` + `TypedActionView` (`TuiOptionSelector`) rendering one page: + +- `OptionSelectorPage` owns the full renderable page configuration: a short field + label, sequence position, full prompt, option snapshot, and search opt-in. The + header renders the field label on the left, right-aligned `← n of m →` navigation + state (boundary arrows muted), a blank separator row, and the bold prompt. +- Option list rendered from an `OptionSnapshot` (`warp::tui_export`): up to + `MAX_VISIBLE_OPTION_ROWS` (6) rows visible at once with `↑` / `↓` overflow markers. + Rows show a viewport-relative `(1)`-style number, the label, an optional badge + suffix (`(default)` / `(recent)` / `(connected)`), and — for disabled rows — the + `disabled_reason`. The selected row is bold magenta without an extra marker or + background. +- Optional search: `set_page(page, ctx)` lazily creates a search editor only when + `page.searchable` is true, then renders its pinned `Search:` row between the prompt + and scroll viewport. Search is not a `SelectorItem`; the list starts focused on + `selected_id` (or its first item) so digits remain immediate shortcuts. Up from the + top item focuses search, Down from search returns to the first filtered item, + Up from search selects the last filtered item, and Down from the last item + focuses search. Typing a non-digit from the list focuses and seeds search. + Filtering is case-insensitive substring matching over row labels; an empty + result renders `No matches`. The pinned search editor remains visible while + rows scroll. +- Status rows appended after the list per `OptionSourceStatus`: `Loading…` (dim), + `Failed { message }` (error style, plus a selectable `↻ Retry` virtual row that + emits `RetryRequested`), and `Empty { message }` (dim). Status rows are not + navigable. +- Footer: `OptionFooter::CustomText { label }` appends a selectable entry that, when + confirmed, embeds a one-line `TuiEditorView` in place of the entry. + Submitting a value replaces the generic footer label with that value, keeps the + footer selected, and pre-fills the value when it is edited again. A selected id + not present in the fixed rows restores this custom value when a page is rebuilt. + `OptionFooter::CreateNewAuthSecret` is ignored (resource creation is out of scope + in the TUI). + +State/API surface for the embedding host: + +- `new(ctx)` then `set_page(page, ctx)` — atomically replaces the page configuration, + resets the search query and selection to the snapshot's `selected_id` (falling back + to the first item), and discards any in-progress custom-text editing. +- `refresh_snapshot(snapshot, ctx)` — in-place catalog refresh preserving the + active selection when it still exists, else falling back to `selected_id`. +- `confirm_selected(ctx)` — the shared confirmation core used by the selector's + Enter/Numpad Enter action and by hosts that need to combine confirmation with + another interaction. Enabled rows emit `TuiOptionSelectorEvent::Confirmed { id }`; + disabled rows stay selected so their reason remains visible; while the custom-text + editor is active it validates (trimmed, non-empty — else an inline + "Enter a value to continue." error) and emits `CustomTextSubmitted { value }`. + While search owns focus, confirmation selects the first enabled filtered row, + skipping disabled matches. +- `handle_back(ctx) -> bool` — the host's Escape path: cancels active custom-text + editing and reports whether the key was consumed, so the host only leaves the page + when the selector had nothing to unwind. +- `TuiOptionSelectorEvent::LayoutInvalidated` — tells hosts with separately cached + measurements to remeasure after scrolling changes overflow markers, a catalog + refresh changes the row set, search changes the rendered rows, or the custom-text + validation row toggles. `ctx.notify()` still refreshes the child itself; the event + crosses the view boundary to invalidate the ancestor's cache, matching + `TuiAIBlockEvent::LayoutInvalidated` prior art. + +Focus and element-level input (via the private `SelectorInputElement` wrapper, active only +while the selector is rendered as the blocking interaction): +- The list and embedded editors are real focus zones. `set_page` focuses the selector; + boundary arrows move focus between the selector and search editor. +- Enter and Numpad Enter dispatch `ConfirmSelected` from the selector element, so + row, search-result, retry, and custom-text confirmation stay reusable host-agnostic + behavior. +- Up/Down move the selection, scrolling to keep it visible. Search behaves as + the final item in the cycle: Up from the first row focuses search, Up from + search selects the last row, Down from the last row focuses search, and Down + from search selects the first row. +- Digits 1-6 confirm the corresponding visible row — viewport-relative, so digit 1 is + always the top visible row after scrolling. While search owns focus, digits are + editor input instead. +- Row clicks select the row and confirm it when enabled via per-item persistent + `MouseStateHandle`s (owned by the view, per the mouse-state ownership rule). +- Wheel scrolling moves the viewport without moving the selection. +- Search and custom text use the shared `TuiEditorView`; printable characters, cursor, + selection, single-line paste, horizontal/word/line navigation, undo/redo, and + kill/yank come from the shared editor layer. Escape remains host policy with a + selector fallback: it clears a non-empty search first, cancels custom editing, or + leaves the page. +- An element-level Escape fallback emits `Dismissed` for hosts without their own + Escape binding; the embedding card's keymap normally consumes Escape first. + +Selection reuses `InlineMenuSelection` and `keep_selected_visible` from +`crates/warp_tui/src/inline_menu.rs`. + +### Generic editor dependency + +The preceding stack branch adds `TuiEditorView::single_line` and the shared editor +interaction layer documented in +`specs/code-1822-tui-generic-editor-view/TECH.md`. The generic field owns focus, +single-line insertion/replacement policy, selection, kill/yank state, model-backed +editing, one-row cursor following, and stale-viewport clamping +(`crates/warp_tui/src/editor_view.rs` (37-202); +`crates/warp_tui/src/editor_interaction.rs` (15-559)). + +This selector owns the surrounding search/custom-text labels, validation, +filtering, Enter/Escape behavior, and vertical navigation. The generic editor does +not register Up/Down or Shift-Up/Shift-Down bindings, so those keys propagate to +the selector's list/focus cycle instead of being consumed by the embedded field. + +### `crates/warp_tui/src/tui_builder.rs` + +Adds `option_selector_selected_style()`: bold, full-strength magenta text for the +selected option. The card slice adds its orchestration surface background and +remaining recipes (title glyph, selected metadata values, identity palette) itself. + +### `crates/warp_tui/src/lib.rs` + +Declares `mod option_selector` with a narrowly-scoped, commented +`#[allow(dead_code)]` on the module declaration, since nothing consumes the selector +until the card slice; that slice removes the allow. + +## Testing and validation + +- `crates/warp_tui/src/option_selector_tests.rs` covers: field label/position/prompt + rendering and initial selection from `selected_id`; Up/Down + Enter confirmation; + selector-element handling for Enter and Numpad Enter; + digit confirmation, including viewport-relative digits in scrolled lists; scrolling + to keep the selection visible with overflow markers; disabled rows being + selectable but not confirmable via Enter, digit, or click; Loading/Empty status + rows being non-selectable; the Failed state's keyboard-reachable Retry row; + custom-text trim/validate/submit, submitted-value rendering/re-editing/restoration, + and Backspace; Back cancelling custom-text editing before leaving the page; the + ignored `CreateNewAuthSecret` footer; snapshot-refresh + selection preservation and selected-value fallback; lazy search-editor creation; + `LayoutInvalidated` emission when overflow markers or custom-text validation change + rendered height; badge rendering; + and paste falling through from the list while the custom-text editor consumes it + using only the first line; + searchable pages starting on the selected row; boundary focus handoff; numeric + shortcuts remaining active from the list; digit-containing queries; filtering, + no-match rendering, Enter confirmation from focused search, and clear-on-Escape. +- Tests host the selector under `test_fixtures::TestHostView` in a headless TUI + window and render to lines (see the `tui-testing` conventions). +- Commands: `./script/format`, + `cargo nextest run -p warp_tui -E 'test(option_selector)'`, + `cargo nextest run -p warp_tui`, and + `cargo clippy -p warp_tui --tests -- -D warnings`. + +## Follow-ups + +The TUI orchestration card slice embeds `TuiOptionSelector` for its configuration +pages (host, environment, harness, model, API key, location), adds the remaining +orchestration theming recipes, and removes the module-level `allow(dead_code)`.