From 4d549f8daf55cc782e13f5547e0d6df5c207365b Mon Sep 17 00:00:00 2001 From: harryalbert Date: Mon, 13 Jul 2026 16:10:25 -0400 Subject: [PATCH] Generalize TUI inline menu routing Co-Authored-By: Oz --- crates/warp_tui/src/inline_menu.rs | 163 ++++++++++++------- crates/warp_tui/src/inline_menu_tests.rs | 44 ++--- crates/warp_tui/src/input/view.rs | 44 +++-- crates/warp_tui/src/input/view_tests.rs | 6 +- crates/warp_tui/src/slash_commands.rs | 6 +- crates/warp_tui/src/terminal_session_view.rs | 15 +- 6 files changed, 160 insertions(+), 118 deletions(-) diff --git a/crates/warp_tui/src/inline_menu.rs b/crates/warp_tui/src/inline_menu.rs index d92da89a984..527de01e209 100644 --- a/crates/warp_tui/src/inline_menu.rs +++ b/crates/warp_tui/src/inline_menu.rs @@ -1,5 +1,6 @@ //! Reusable active-menu routing and character-cell presentation for TUI inline menus. use std::ops::Range; +use std::rc::Rc; use string_offset::CharOffset; use warp::tui_export::AcceptSlashCommandOrSavedPrompt; @@ -8,7 +9,7 @@ use warpui_core::elements::tui::{ TuiLayoutContext, TuiPaintContext, TuiRect, TuiSize, TuiText, }; use warpui_core::elements::CrossAxisAlignment; -use warpui_core::{AppContext, ModelAsRef, ModelHandle, UpdateModel}; +use warpui_core::{AppContext, ModelHandle}; use crate::slash_commands::TuiSlashCommandModel; use crate::tui_builder::TuiUiBuilder; @@ -31,6 +32,9 @@ pub(crate) enum TuiInlineMenuRowStyle { SlashCommand, } +pub(crate) const MAX_INLINE_MENU_ROWS: u16 = 10; +const INLINE_MENU_BORDER_ROWS: usize = 2; + /// A presentation-only row in a TUI inline menu. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TuiInlineMenuRow { @@ -78,21 +82,37 @@ pub(crate) enum TuiInlineMenuAccepted { SlashCommand(AcceptSlashCommandOrSavedPrompt), } -/// The active TUI inline menu. -/// -/// This is the input and placement boundary shared by menu kinds. Each variant -/// retains its own query state and accepted action, while all variants expose -/// the same navigation, dismissal, snapshot, and rendering behavior. -#[derive(Clone)] -pub(crate) enum TuiInlineMenu { - SlashCommands(ModelHandle), +/// Type-erased operations shared by TUI inline-menu model handles. +pub(crate) trait TuiInlineMenuHandle { + /// Returns whether this menu is open. + fn is_open(&self, ctx: &AppContext) -> bool; + /// Returns the input range highlighted by this menu. + fn input_highlight_range(&self, ctx: &AppContext) -> Option>; + /// Returns the input argument hint shown by this menu. + fn input_argument_hint_text(&self, ctx: &AppContext) -> Option<&'static str>; + /// Moves selection to the previous row. + fn select_previous(&self, ctx: &mut AppContext); + /// Moves selection to the next row. + fn select_next(&self, ctx: &mut AppContext); + /// Accepts the selected row. + fn accept(&self, ctx: &mut AppContext) -> Option; + /// Dismisses the menu. + fn dismiss(&self, ctx: &mut AppContext); + /// Returns the menu's presentation snapshot. + fn snapshot(&self, ctx: &AppContext) -> Option; } +/// Cloneable type-erased handle for one TUI inline menu. +#[derive(Clone)] +pub(crate) struct TuiInlineMenu(Rc); + impl TuiInlineMenu { + /// Erases a concrete menu-model handle behind the shared routing interface. + pub(crate) fn new(handle: impl TuiInlineMenuHandle + 'static) -> Self { + Self(Rc::new(handle)) + } pub(crate) fn is_open(&self, ctx: &AppContext) -> bool { - match self { - Self::SlashCommands(model) => model.as_ref(ctx).is_open(), - } + self.0.is_open(ctx) } pub(crate) fn render(&self, ctx: &AppContext) -> Option> { @@ -100,56 +120,65 @@ impl TuiInlineMenu { .map(|snapshot| render_inline_menu(&snapshot, &TuiUiBuilder::from_app(ctx))) } pub(crate) fn input_highlight_range(&self, ctx: &AppContext) -> Option> { - match self { - Self::SlashCommands(model) => model.as_ref(ctx).highlighted_prefix_range(), - } + self.0.input_highlight_range(ctx) } pub(crate) fn input_argument_hint_text(&self, ctx: &AppContext) -> Option<&'static str> { - match self { - Self::SlashCommands(model) => model.as_ref(ctx).argument_hint_text(), - } + self.0.input_argument_hint_text(ctx) } - pub(crate) fn select_previous(&self, ctx: &mut impl UpdateModel) { - match self { - Self::SlashCommands(model) => { - model.update(ctx, |model, ctx| model.select_previous(ctx)); - } - } + pub(crate) fn select_previous(&self, ctx: &mut AppContext) { + self.0.select_previous(ctx); } - pub(crate) fn select_next(&self, ctx: &mut impl UpdateModel) { - match self { - Self::SlashCommands(model) => { - model.update(ctx, |model, ctx| model.select_next(ctx)); - } - } + pub(crate) fn select_next(&self, ctx: &mut AppContext) { + self.0.select_next(ctx); } - pub(crate) fn accept( - &self, - ctx: &mut (impl ModelAsRef + UpdateModel), - ) -> Option { - match self { - Self::SlashCommands(model) => model - .update(ctx, |model, ctx| model.accept_selected(ctx)) - .map(TuiInlineMenuAccepted::SlashCommand), - } + pub(crate) fn accept(&self, ctx: &mut AppContext) -> Option { + self.0.accept(ctx) } - pub(crate) fn dismiss(&self, ctx: &mut impl UpdateModel) { - match self { - Self::SlashCommands(model) => { - model.update(ctx, |model, ctx| model.dismiss(ctx)); - } - } + pub(crate) fn dismiss(&self, ctx: &mut AppContext) { + self.0.dismiss(ctx); } fn snapshot(&self, ctx: &AppContext) -> Option { - match self { - Self::SlashCommands(model) => model.as_ref(ctx).snapshot(), - } + self.0.snapshot(ctx) + } +} + +impl TuiInlineMenuHandle for ModelHandle { + fn is_open(&self, ctx: &AppContext) -> bool { + self.as_ref(ctx).is_open() + } + fn input_highlight_range(&self, ctx: &AppContext) -> Option> { + self.as_ref(ctx).highlighted_prefix_range() + } + + fn input_argument_hint_text(&self, ctx: &AppContext) -> Option<&'static str> { + self.as_ref(ctx).argument_hint_text() + } + + fn select_previous(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.select_previous(ctx)); + } + + fn select_next(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.select_next(ctx)); + } + + fn accept(&self, ctx: &mut AppContext) -> Option { + self.update(ctx, |model, ctx| model.accept_selected(ctx)) + .map(TuiInlineMenuAccepted::SlashCommand) + } + + fn dismiss(&self, ctx: &mut AppContext) { + self.update(ctx, |model, ctx| model.dismiss(ctx)); + } + + fn snapshot(&self, ctx: &AppContext) -> Option { + self.as_ref(ctx).snapshot() } } @@ -195,6 +224,29 @@ impl TuiElement for TuiInlineMenuElement { } } +/// Returns the result rows available after reserving border and header chrome. +pub(crate) const fn result_row_capacity( + allocated_height: u16, + has_title: bool, + has_tabs: bool, +) -> usize { + let title_rows = if has_title { 1 } else { 0 }; + let tab_rows = if has_tabs { 1 } else { 0 }; + (allocated_height as usize).saturating_sub(INLINE_MENU_BORDER_ROWS + title_rows + tab_rows) +} + +fn visible_result_capacity(snapshot: &TuiInlineMenuSnapshot, allocated_height: u16) -> usize { + let has_title = snapshot + .header + .as_ref() + .is_some_and(|header| header.title.is_some()); + let has_tabs = snapshot + .header + .as_ref() + .is_some_and(|header| !header.tabs.is_empty()); + result_row_capacity(allocated_height, has_title, has_tabs).min(snapshot.max_visible_rows) +} + fn build_inline_menu( snapshot: &TuiInlineMenuSnapshot, builder: &TuiUiBuilder, @@ -202,7 +254,7 @@ fn build_inline_menu( allocated_height: u16, ) -> Box { let slash_command_columns = tui_two_column_layout( - usize::from(allocated_width), + usize::from(allocated_width.saturating_sub(2)), snapshot.rows.iter().filter_map(|row| { if row.style != TuiInlineMenuRowStyle::SlashCommand { return None; @@ -269,16 +321,9 @@ fn build_inline_menu( } } - column.finish() -} - -fn visible_result_capacity(snapshot: &TuiInlineMenuSnapshot, allocated_height: u16) -> usize { - let header_rows = snapshot.header.as_ref().map_or(0, |header| { - usize::from(header.title.is_some()) + usize::from(!header.tabs.is_empty()) - }); - usize::from(allocated_height) - .saturating_sub(header_rows) - .min(snapshot.max_visible_rows) + TuiContainer::new(column.finish()) + .with_border_style(builder.accent_border_style()) + .finish() } /// Clamps stale scroll offsets and moves the viewport only as far as needed to diff --git a/crates/warp_tui/src/inline_menu_tests.rs b/crates/warp_tui/src/inline_menu_tests.rs index 3f2d13fcc63..5d62fd2d82b 100644 --- a/crates/warp_tui/src/inline_menu_tests.rs +++ b/crates/warp_tui/src/inline_menu_tests.rs @@ -162,8 +162,9 @@ fn conversation_like_snapshot_keeps_selection_visible_within_production_height() let rendered = lines.join("\n"); assert!(rendered.contains("Conversations")); assert!(rendered.contains("[All] Pinned")); - assert!(rendered.contains("Conversation 0")); - assert!(rendered.contains("Conversation 1")); + assert!(!rendered.contains("Conversation 0")); + assert!(!rendered.contains("Conversation 1")); + assert!(rendered.contains("Conversation 2")); assert!(rendered.contains("Conversation 7")); } @@ -197,34 +198,34 @@ fn slash_command_rows_match_figma_layout_and_colors() { let mut presenter = TuiPresenter::new(); let frame = presenter.present_element( render_inline_menu(&snapshot, &builder), - TuiRect::new(0, 0, 50, 2), + TuiRect::new(0, 0, 52, 4), ctx, ); let lines = frame.buffer.to_lines(); - assert!(lines[0].starts_with("/agent Start")); - assert!(lines[1].starts_with("/plan Create")); + assert!(lines[1].starts_with("│/agent Start")); + assert!(lines[2].starts_with("│/plan Create")); assert_eq!( - frame.buffer[(0, 0)].bg, + frame.buffer[(1, 1)].bg, builder.slash_command_selection_background() ); assert_eq!( - frame.buffer[(0, 0)].fg, + frame.buffer[(1, 1)].fg, builder .slash_command_selection_text_style() .fg .expect("selected slash-command text has a foreground") ); - assert!(frame.buffer[(0, 0)].modifier.contains(Modifier::BOLD)); + assert!(frame.buffer[(1, 1)].modifier.contains(Modifier::BOLD)); assert_eq!( - frame.buffer[(0, 1)].fg, + frame.buffer[(1, 2)].fg, builder .slash_command_text_style() .fg .expect("slash-command text has a foreground") ); assert_eq!( - frame.buffer[(29, 1)].fg, + frame.buffer[(30, 2)].fg, builder .primary_text_style() .fg @@ -250,7 +251,7 @@ fn long_slash_command_titles_are_ellipsized_before_the_description() { status: None, }); - assert!(lines[0].starts_with("/respond-to-pr-comments-i... Walk users")); + assert!(lines[1].starts_with("│/respond-to-pr-comments-i... Walk users")); } #[test] @@ -269,12 +270,13 @@ fn wide_slash_command_rows_expand_to_show_long_titles() { max_visible_rows: 8, status: None, }, - 80, - 1, + 82, + 3, ); - assert!(lines[0] - .starts_with("/respond-to-pr-comments-in-blocklist Walk users through PR review comments")); + assert!(lines[1].starts_with( + "│/respond-to-pr-comments-in-blocklist Walk users through PR review comments" + )); } #[test] @@ -293,11 +295,11 @@ fn boundary_width_preserves_useful_title_and_description_columns() { max_visible_rows: 8, status: None, }, - 20, - 1, + 22, + 3, ); - assert!(lines[0].starts_with("/agent Start a new")); + assert!(lines[1].starts_with("│/agent Start a new")); } #[test] @@ -316,9 +318,9 @@ fn narrow_slash_command_rows_use_the_full_width_for_titles() { max_visible_rows: 8, status: None, }, - 19, - 1, + 21, + 3, ); - assert_eq!(lines[0], "/123456789012345..."); + assert_eq!(lines[1], "│/123456789012345...│"); } diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index 6cc30394c03..9c1125ee258 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -566,8 +566,8 @@ pub struct TuiInputView { model: ModelHandle, /// Shared input-mode state driving `!` shell-mode handling. input_mode: ModelHandle, - /// Optional generalized inline menu used to route prioritized menu actions. - inline_menu: Option, + /// Generalized inline menus used to route prioritized menu actions. + inline_menus: Vec, /// Single-entry kill buffer for `Ctrl+K` / `Ctrl+U` / `Ctrl+Y`. kill_buffer: KillBuffer, /// Maximum number of visible rows before the input scrolls. @@ -598,7 +598,7 @@ impl TuiInputView { pub(crate) fn new( model: ModelHandle, input_mode: ModelHandle, - inline_menu: Option, + inline_menus: Vec, ctx: &mut ViewContext, ) -> Self { ctx.subscribe_to_model(&model, |_, _, event, ctx| { @@ -612,7 +612,7 @@ impl TuiInputView { Self { model, input_mode, - inline_menu, + inline_menus, kill_buffer: KillBuffer::default(), max_visible_rows: 6, prefix_mouse_state: MouseStateHandle::default(), @@ -651,8 +651,7 @@ impl TuiInputView { let builder = TuiUiBuilder::from_app(ctx); let mut styles = TuiEditorStyles::default(); if let Some(range) = self - .inline_menu - .as_ref() + .active_inline_menu(ctx) .and_then(|inline_menu| inline_menu.input_highlight_range(ctx)) { styles @@ -667,8 +666,7 @@ impl TuiInputView { event_ctx.dispatch_typed_action(TuiInputAction::from(action)) }); if let Some(hint_text) = self - .inline_menu - .as_ref() + .active_inline_menu(ctx) .and_then(|inline_menu| inline_menu.input_argument_hint_text(ctx)) { element = element.with_trailing_ghost_text(hint_text, builder.dim_text_style()); @@ -741,12 +739,7 @@ impl TuiView for TuiInputView { } fn keymap_context(&self, ctx: &AppContext) -> keymap::Context { - input_keymap_context( - self.inline_menu - .as_ref() - .is_some_and(|inline_menu| inline_menu.is_open(ctx)) - || self.is_shell_mode(ctx), - ) + input_keymap_context(self.active_inline_menu(ctx).is_some() || self.is_shell_mode(ctx)) } } @@ -1110,12 +1103,9 @@ impl TuiInputView { ) { return false; } - let Some(inline_menu) = self.inline_menu.clone() else { + let Some(inline_menu) = self.active_inline_menu(ctx) else { return false; }; - if !inline_menu.is_open(ctx) { - return false; - } match action { TuiInputAction::MoveUp => { @@ -1144,13 +1134,12 @@ impl TuiInputView { /// order. New input modes should be added after the inline-menu branch so /// one Escape always closes the most local surface first. fn handle_escape(&mut self, ctx: &mut ViewContext) -> bool { - if let Some(inline_menu) = self.inline_menu.clone() { - if inline_menu.is_open(ctx) { - inline_menu.dismiss(ctx); - ctx.notify(); - return true; - } + if let Some(inline_menu) = self.active_inline_menu(ctx) { + inline_menu.dismiss(ctx); + ctx.notify(); + return true; } + if self.is_shell_mode(ctx) { self.exit_shell_mode(ctx); return true; @@ -1158,6 +1147,13 @@ impl TuiInputView { false } + fn active_inline_menu(&self, ctx: &AppContext) -> Option { + self.inline_menus + .iter() + .find(|menu| menu.is_open(ctx)) + .cloned() + } + // ── Kill / yank ─────────────────────────────────────────────────────────── // // The kill *edits* (visual-row range computation and deletion) live on diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index a05e8b57bae..1527cfb46a7 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -127,7 +127,7 @@ fn build_view(ctx: &mut AppContext) -> ViewHandle { }, |ctx| { let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx)); - TuiInputView::new(model, input_mode, None, ctx) + TuiInputView::new(model, input_mode, Vec::new(), ctx) }, ); view @@ -157,13 +157,13 @@ fn build_view_with_inline_menu( .collect(); let menu_model = ctx.add_model(|_| TuiSlashCommandModel::new_for_test(input_model.clone(), mixer, rows, 0)); - let inline_menu = TuiInlineMenu::SlashCommands(menu_model.clone()); + let inline_menu = TuiInlineMenu::new(menu_model.clone()); let (_window_id, view) = ctx.add_tui_window( AddWindowOptions { window_style: WindowStyle::NotStealFocus, ..Default::default() }, - move |ctx| TuiInputView::new(input_model, input_mode, Some(inline_menu), ctx), + move |ctx| TuiInputView::new(input_model, input_mode, vec![inline_menu], ctx), ); (view, menu_model, ids) } diff --git a/crates/warp_tui/src/slash_commands.rs b/crates/warp_tui/src/slash_commands.rs index 69c1c00412d..d660d089804 100644 --- a/crates/warp_tui/src/slash_commands.rs +++ b/crates/warp_tui/src/slash_commands.rs @@ -22,11 +22,11 @@ use warp_search_core::inline_menu::{ use warpui_core::{AppContext, Entity, ModelContext, ModelHandle}; use crate::inline_menu::{ - keep_selected_visible, TuiInlineMenuRow, TuiInlineMenuRowStyle, TuiInlineMenuSnapshot, - TuiInlineMenuStatus, + keep_selected_visible, result_row_capacity, TuiInlineMenuRow, TuiInlineMenuRowStyle, + TuiInlineMenuSnapshot, TuiInlineMenuStatus, MAX_INLINE_MENU_ROWS, }; -const MAX_VISIBLE_ROWS: usize = 8; +const MAX_VISIBLE_ROWS: usize = result_row_capacity(MAX_INLINE_MENU_ROWS, false, false); #[allow(dead_code)] #[derive(Debug, Clone)] diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index f203b2801a1..f4f092a32b6 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -49,7 +49,7 @@ use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; use crate::conversation_selection::TuiConversationSelection; use crate::exit_confirmation::{ExitConfirmation, CTRL_C_EXIT_WINDOW}; -use crate::inline_menu::TuiInlineMenu; +use crate::inline_menu::{TuiInlineMenu, MAX_INLINE_MENU_ROWS}; use crate::input::{TuiInputView, TuiInputViewEvent}; use crate::input_mode_policy::{self, TuiInputModePolicy}; use crate::keybindings::TUI_BINDING_GROUP; @@ -66,7 +66,6 @@ use crate::zero_state::render_zero_state; /// Width used before the first layout pass pushes the real terminal width into the editor. const INITIAL_INPUT_WIDTH: u16 = 80; const MAX_INPUT_TEXT_ROWS: u16 = 6; -const MAX_INLINE_MENU_ROWS: u16 = 10; /// The footer hint shown while the ctrl-c exit confirmation is armed. const CTRL_C_EXIT_HINT: &str = "ctrl-c again to exit"; @@ -174,7 +173,7 @@ pub(crate) enum TuiTerminalSessionAction { pub(crate) struct TuiTerminalSessionView { transcript: ViewHandle, input_view: ViewHandle, - inline_menu: TuiInlineMenu, + inline_menus: Vec, slash_commands_source: ModelHandle, conversation_selection: ConversationSelectionHandle, ai_action_model: ModelHandle, @@ -386,13 +385,13 @@ impl TuiTerminalSessionView { }); let input_mode_for_input_view = ai_input_model.clone(); - let inline_menu = TuiInlineMenu::SlashCommands(slash_commands.clone()); - let inline_menu_for_input = inline_menu.clone(); + let inline_menus = vec![TuiInlineMenu::new(slash_commands.clone())]; + let inline_menus_for_input = inline_menus.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( input_editor_model, input_mode_for_input_view, - Some(inline_menu_for_input), + inline_menus_for_input, ctx, ) }); @@ -597,7 +596,7 @@ impl TuiTerminalSessionView { Self { transcript, input_view, - inline_menu, + inline_menus, slash_commands_source, conversation_selection, ai_action_model: action_model, @@ -1441,7 +1440,7 @@ impl TuiView for TuiTerminalSessionView { } ConversationRestoreState::Idle => {} } - let inline_menu = self.inline_menu.render(ctx); + let inline_menu = self.inline_menus.iter().find_map(|menu| menu.render(ctx)); // The border takes the shell-mode accent while in shell mode. let builder = TuiUiBuilder::from_app(ctx); let border_style = if self.is_shell_mode(ctx) {