From db9f02db27b75173eb7ee237255125aad0ac2b57 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:27:40 -0500 Subject: [PATCH] feat(tui): legacy overlay removal, welcome box cap, familiar idle animations (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the four items from the 2026-06 audit (P2 #9, #11-13) tracked in issue #61: - Remove render_simple_help_overlay and render_legacy_history_search along with the legacy App.show_help / App.history_search state they rendered; the overlays in overlays.rs are now the only help / history-search paths. - Cap the welcome box at 120 columns so it no longer stretches edge-to-edge on very wide terminals. - Add a CompanionPose::Idle blink/sway loop: familiars with eyes (kitty, cody, echo) blink once per ~12s idle cycle, the others spark, and every glyph sways one column for half the cycle. The eye-socket color is now per-familiar instead of hardcoded violet-300. - Typeahead source badges: file references gain a [context] badge, and prompt history is now surfaced as passive [history] suggestions (prefix match, newest first, Tab to accept — never auto-selected so Enter still submits the typed prompt). Closes #61 Co-Authored-By: Claude Fable 5 --- src-rust/crates/cli/src/main.rs | 1 - src-rust/crates/tui/src/agents_view.rs | 6 +- src-rust/crates/tui/src/app.rs | 215 ++---------------- src-rust/crates/tui/src/familiar_card.rs | 30 ++- src-rust/crates/tui/src/familiar_theme.rs | 54 +++-- src-rust/crates/tui/src/lib.rs | 34 +-- src-rust/crates/tui/src/mascot.rs | 114 ++++++++-- src-rust/crates/tui/src/prompt_input.rs | 119 +++++++++- src-rust/crates/tui/src/render.rs | 258 +++++++--------------- 9 files changed, 365 insertions(+), 466 deletions(-) diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 9d3fb3b0..b45e82aa 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -3392,7 +3392,6 @@ async fn run_interactive( if !app.is_streaming && app.permission_request.is_none() && !app.history_search_overlay.visible - && app.history_search.is_none() => { if app.key_input_dialog.visible { // Paste into API key input dialog diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index b1a4be2a..fbfbb469 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -1206,7 +1206,11 @@ fn render_agent_detail(def: &AgentDefinition, area: Rect, buf: &mut Buffer) { if let Some(id) = def.source.strip_prefix("coven:familiar:") { let daemon = coven_shared::load_familiars().unwrap_or_default(); let theme = familiar_theme::resolve(id, &daemon); - for line in familiar_card::render_card(&theme, CardSize::Standard, None) { + for line in familiar_card::render_card( + &theme, + CardSize::Standard, + &crate::mascot::CompanionPose::Static, + ) { lines.push(line); } lines.push(Line::default()); diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 5365c7b1..5e17930f 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -961,61 +961,6 @@ pub struct TurnMetadata { pub interrupted: bool, } -/// State for Ctrl+R history search mode (legacy inline struct, kept for test -/// compatibility — the overlay version lives in `overlays::HistorySearchOverlay`). -#[derive(Debug, Clone)] -pub struct HistorySearch { - pub query: String, - /// Indices into `input_history` that match the current query. - pub matches: Vec, - /// Which match is currently highlighted. - pub selected: usize, -} - -impl HistorySearch { - pub fn new() -> Self { - Self { - query: String::new(), - matches: Vec::new(), - selected: 0, - } - } - - /// Re-compute matches against the given history slice. - pub fn update_matches(&mut self, history: &[String]) { - let q = self.query.to_lowercase(); - self.matches = history - .iter() - .enumerate() - .filter_map(|(i, s)| { - if s.to_lowercase().contains(&q) { - Some(i) - } else { - None - } - }) - .collect(); - // Clamp selected to valid range - if !self.matches.is_empty() && self.selected >= self.matches.len() { - self.selected = self.matches.len() - 1; - } - } - - /// Return the currently selected history entry, if any. - pub fn current_entry<'a>(&self, history: &'a [String]) -> Option<&'a str> { - self.matches - .get(self.selected) - .and_then(|&i| history.get(i)) - .map(String::as_str) - } -} - -impl Default for HistorySearch { - fn default() -> Self { - Self::new() - } -} - /// Attempt to copy text to the system clipboard using trusted platform clipboard helpers. /// Returns true if successful. pub fn try_copy_to_clipboard(text: &str) -> bool { @@ -1212,7 +1157,6 @@ pub struct App { /// Randomly chosen thinking verb shown next to the spinner while streaming. pub spinner_verb: Option, pub should_exit: bool, - pub show_help: bool, // Extended state pub tool_use_blocks: Vec, @@ -1243,7 +1187,6 @@ pub struct App { /// and tool list to match the newly-selected agent. pub agent_mode_changed: bool, pub agent_status: Vec<(String, String)>, - pub history_search: Option, pub keybindings: KeybindingResolver, // Cursor position within input (byte offset) @@ -1752,7 +1695,6 @@ impl App { status_message: None, spinner_verb: None, should_exit: false, - show_help: false, tool_use_blocks: Vec::new(), permission_request: None, frame_count: 0, @@ -1769,7 +1711,6 @@ impl App { agent_mode_changed: false, accent_color: ACCENT_BUILD, agent_status: Vec::new(), - history_search: None, keybindings: KeybindingResolver::new(&user_keybindings), cursor_pos: 0, auto_scroll: true, @@ -2341,7 +2282,7 @@ impl App { /// Update the familiar pose for this render frame. /// - /// The glyph itself is static — this just toggles between `Static` and + /// Toggles between `Idle { frame }` (blink/sway idle loop) and /// `Loading { frame }` so the eye-spinner kicks in when the assistant has /// gone quiet for 3+ seconds. Call once per frame before rendering. pub fn tick_companion_pose(&mut self) { @@ -2355,7 +2296,9 @@ impl App { frame: self.frame_count, } } else { - crate::mascot::CompanionPose::Static + crate::mascot::CompanionPose::Idle { + frame: self.frame_count, + } }; } @@ -2824,7 +2767,6 @@ impl App { "help" => { // Open the help overlay (same as pressing `?` or F1). if !self.help_overlay.visible { - self.show_help = true; self.help_overlay.toggle(); } true @@ -2864,9 +2806,7 @@ impl App { || self.rewind_flow.visible || self.tasks_overlay.visible || self.help_overlay.visible - || self.show_help || self.history_search_overlay.visible - || self.history_search.is_some() || self.settings_screen.visible || self.theme_screen.visible || self.stats_dialog.visible @@ -4497,11 +4437,6 @@ impl App { return self.handle_global_search_key(key); } - // Legacy history-search mode intercepts most keys - if self.history_search.is_some() { - return self.handle_history_search_key(key); - } - // Notification dismiss if key.code == KeyCode::Esc && !self.notifications.is_empty() { self.notifications.dismiss_current(); @@ -4825,13 +4760,8 @@ impl App { // ---- History search ---------------------------------------- KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Open the new overlay-based history search - let overlay = HistorySearchOverlay::open(&self.prompt_input.history); - self.history_search_overlay = overlay; - // Also open legacy for backwards compat - let mut hs = HistorySearch::new(); - hs.update_matches(&self.prompt_input.history); - self.history_search = Some(hs); + self.history_search_overlay = + HistorySearchOverlay::open(&self.prompt_input.history); } KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.global_search.open(); @@ -4845,7 +4775,6 @@ impl App { // ---- Help overlay ------------------------------------------ KeyCode::F(1) => { - self.show_help = !self.show_help; self.help_overlay.toggle(); } KeyCode::F(2) => { @@ -4875,7 +4804,6 @@ impl App { && !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SUPER) => { - self.show_help = !self.show_help; self.help_overlay.toggle(); } // With the kitty keyboard protocol, Shift+/ is reported as Char('/') with @@ -4888,7 +4816,6 @@ impl App { && !key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::SUPER) => { - self.show_help = !self.show_help; self.help_overlay.toggle(); } @@ -5165,12 +5092,10 @@ impl App { KeyContext::Confirmation } else if self.help_overlay.visible { KeyContext::Help - } else if self.history_search_overlay.visible || self.history_search.is_some() { + } else if self.history_search_overlay.visible { KeyContext::HistorySearch } else if self.permission_request.is_some() { KeyContext::Confirmation - } else if self.show_help { - KeyContext::Help } else { KeyContext::Chat } @@ -5362,7 +5287,6 @@ impl App { match key.code { KeyCode::Esc | KeyCode::F(1) => { self.help_overlay.close(); - self.show_help = false; } KeyCode::Char('?') if !key.modifiers.contains(KeyModifiers::CONTROL) @@ -5370,7 +5294,6 @@ impl App { && !key.modifiers.contains(KeyModifiers::SUPER) => { self.help_overlay.close(); - self.show_help = false; } KeyCode::Up => { self.help_overlay.scroll_up(); @@ -5394,7 +5317,6 @@ impl App { match key.code { KeyCode::Esc => { self.history_search_overlay.close(); - self.history_search = None; } KeyCode::Enter => { if let Some(entry) = self @@ -5404,37 +5326,16 @@ impl App { self.set_prompt_text(entry.to_string()); } self.history_search_overlay.close(); - self.history_search = None; } KeyCode::Up => { self.history_search_overlay.select_prev(); - if let Some(hs) = self.history_search.as_mut() { - let count = hs.matches.len(); - if count > 0 { - if hs.selected == 0 { - hs.selected = count - 1; - } else { - hs.selected -= 1; - } - } - } } KeyCode::Down => { self.history_search_overlay.select_next(); - if let Some(hs) = self.history_search.as_mut() { - let count = hs.matches.len(); - if count > 0 { - hs.selected = (hs.selected + 1) % count; - } - } } KeyCode::Backspace => { let history = self.prompt_input.history.clone(); self.history_search_overlay.pop_char(&history); - if let Some(hs) = self.history_search.as_mut() { - hs.query.pop(); - hs.update_matches(&history); - } } // 'p' with no modifiers and an empty query = pin/unpin the selected entry. // When the query is non-empty 'p' is treated as a filter character so @@ -5449,10 +5350,6 @@ impl App { let c = normalize_char_with_shift(c, key.modifiers); let history = self.prompt_input.history.clone(); self.history_search_overlay.push_char(c, &history); - if let Some(hs) = self.history_search.as_mut() { - hs.query.push(c); - hs.update_matches(&history); - } } _ => {} } @@ -5593,11 +5490,8 @@ impl App { } "redraw" => false, "historySearch" => { - let overlay = HistorySearchOverlay::open(&self.prompt_input.history); - self.history_search_overlay = overlay; - let mut hs = HistorySearch::new(); - hs.update_matches(&self.prompt_input.history); - self.history_search = Some(hs); + self.history_search_overlay = + HistorySearchOverlay::open(&self.prompt_input.history); false } "openSearch" => { @@ -5722,47 +5616,30 @@ impl App { false } "close" => { - self.show_help = false; self.help_overlay.close(); false } "select" => { - // Legacy history search select - if let Some(hs) = self.history_search.as_ref() { - if let Some(entry) = hs.current_entry(&self.prompt_input.history) { + if self.history_search_overlay.visible { + if let Some(entry) = self + .history_search_overlay + .current_entry(&self.prompt_input.history) + { self.set_prompt_text(entry.to_string()); } } - self.history_search = None; self.history_search_overlay.close(); false } "cancel" => { - self.history_search = None; self.history_search_overlay.close(); false } "prevResult" => { - if let Some(hs) = self.history_search.as_mut() { - let count = hs.matches.len(); - if count > 0 { - if hs.selected == 0 { - hs.selected = count - 1; - } else { - hs.selected -= 1; - } - } - } self.history_search_overlay.select_prev(); false } "nextResult" => { - if let Some(hs) = self.history_search.as_mut() { - let count = hs.matches.len(); - if count > 0 { - hs.selected = (hs.selected + 1) % count; - } - } self.history_search_overlay.select_next(); false } @@ -5829,7 +5706,6 @@ impl App { } "openHelp" => { // Alt+H: Open help (alternative to F1) - self.show_help = !self.show_help; self.help_overlay.toggle(); false } @@ -5882,59 +5758,6 @@ impl App { } } - /// Handle a key event while in legacy history-search mode. - fn handle_history_search_key(&mut self, key: KeyEvent) -> bool { - let hs = match self.history_search.as_mut() { - Some(h) => h, - None => return false, - }; - match key.code { - KeyCode::Esc => { - self.history_search = None; - self.history_search_overlay.close(); - } - KeyCode::Enter => { - if let Some(entry) = hs.current_entry(&self.prompt_input.history) { - self.set_prompt_text(entry.to_string()); - } - self.history_search = None; - self.history_search_overlay.close(); - } - KeyCode::Up => { - let count = hs.matches.len(); - if count > 0 { - if hs.selected == 0 { - hs.selected = count - 1; - } else { - hs.selected -= 1; - } - } - } - KeyCode::Down => { - let count = hs.matches.len(); - if count > 0 { - hs.selected = (hs.selected + 1) % count; - } - } - KeyCode::Backspace => { - hs.query.pop(); - let history = self.prompt_input.history.clone(); - if let Some(hs) = self.history_search.as_mut() { - hs.update_matches(&history); - } - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - hs.query.push(c); - let history = self.prompt_input.history.clone(); - if let Some(hs) = self.history_search.as_mut() { - hs.update_matches(&history); - } - } - _ => {} - } - false - } - /// Handle a key event while a permission dialog is active. fn handle_permission_key(&mut self, key: KeyEvent) { let pr = match self.permission_request.as_mut() { @@ -6335,7 +6158,6 @@ impl App { !self.is_streaming && self.permission_request.is_none() && !self.history_search_overlay.visible - && self.history_search.is_none() && !matches!( self.prompt_input.vim_mode, crate::prompt_input::VimMode::Normal @@ -6418,7 +6240,6 @@ impl App { && self.permission_request.is_none() && !self.ask_user_dialog.visible && !self.history_search_overlay.visible - && self.history_search.is_none() && !self.settings_screen.visible && !self.theme_screen.visible && self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert @@ -7315,8 +7136,7 @@ impl App { Event::Paste(data) if !self.is_streaming && self.permission_request.is_none() - && !self.history_search_overlay.visible - && self.history_search.is_none() => + && !self.history_search_overlay.visible => { self.handle_paste_data(data); self.refresh_prompt_input(); @@ -8016,11 +7836,9 @@ role = "Research" fn test_help_slash_command_opens_overlay() { let mut app = make_app(); assert!(!app.help_overlay.visible); - assert!(!app.show_help); assert!(!app.help_overlay.commands.is_empty()); assert!(app.intercept_slash_command("help")); assert!(app.help_overlay.visible); - assert!(app.show_help); } #[test] @@ -8041,19 +7859,16 @@ role = "Research" app.handle_key_event(press_key(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert!(app.help_overlay.visible); - assert!(app.show_help); } #[test] fn test_question_mark_shortcut_closes_help_with_shift_modifier() { let mut app = make_app(); app.help_overlay.toggle(); - app.show_help = true; app.handle_key_event(press_key(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert!(!app.help_overlay.visible); - assert!(!app.show_help); } #[test] diff --git a/src-rust/crates/tui/src/familiar_card.rs b/src-rust/crates/tui/src/familiar_card.rs index 4ecc9291..6ef8fd12 100644 --- a/src-rust/crates/tui/src/familiar_card.rs +++ b/src-rust/crates/tui/src/familiar_card.rs @@ -2,9 +2,10 @@ //! //! Given a [`crate::familiar_theme::FamiliarTheme`] and a [`CardSize`], produces //! the lines that render the active familiar in the welcome panel, the F2 -//! switcher, and the `/agents` detail view. The glyph itself never animates; -//! only the eye row spins while the assistant is in the `Loading` state, so -//! the user still has a signal that work is in progress. +//! switcher, and the `/agents` detail view. The welcome panel passes the live +//! [`CompanionPose`] so the glyph blinks and sways while idle and spins its +//! eye row while the assistant is in the `Loading` state; other surfaces pass +//! `CompanionPose::Static` and stay still. //! //! Built-in archetypes dispatch to the pixel-art builders in //! [`crate::mascot`]. Procedural archetypes ([`Archetype::SigilCrystal`] etc.) @@ -43,18 +44,15 @@ pub fn pick_size(width: u16) -> CardSize { } } -/// Render the full card. `loading` is `Some(frame_count)` to spin the eyes, -/// `None` for the resting state. +/// Render the full card with the given pose: `Static` for surfaces that +/// never animate, `Idle { frame }` for the blink/sway loop, or +/// `Loading { frame }` to spin the eyes. pub fn render_card( theme: &FamiliarTheme, size: CardSize, - loading: Option, + pose: &CompanionPose, ) -> Vec> { - let pose = match loading { - Some(frame) => CompanionPose::Loading { frame }, - None => CompanionPose::Static, - }; - let glyph = glyph_lines(theme, &pose); + let glyph = glyph_lines(theme, pose); match size { CardSize::Compact => glyph_only(glyph), @@ -424,7 +422,7 @@ mod tests { #[test] fn render_card_compact_is_glyph_only() { let t = familiar_theme::resolve("kitty", &[]); - let lines = render_card(&t, CardSize::Compact, None); + let lines = render_card(&t, CardSize::Compact, &CompanionPose::Static); // Built-in archetypes return 5 rows (4 content + 1 blank); compact passes through. assert!(lines.len() >= 4); } @@ -432,7 +430,7 @@ mod tests { #[test] fn render_card_standard_has_border() { let t = familiar_theme::resolve("nova", &[]); - let lines = render_card(&t, CardSize::Standard, None); + let lines = render_card(&t, CardSize::Standard, &CompanionPose::Static); // Top border + 5 glyph rows + access row + bottom border = at least 8. assert!(lines.len() >= 7); } @@ -440,15 +438,15 @@ mod tests { #[test] fn render_card_large_includes_rule_row() { let t = familiar_theme::resolve("sage", &[]); - let lines = render_card(&t, CardSize::Large, None); + let lines = render_card(&t, CardSize::Large, &CompanionPose::Static); // Large has at least one more row than Standard (the rule + role region). - let standard = render_card(&t, CardSize::Standard, None).len(); + let standard = render_card(&t, CardSize::Standard, &CompanionPose::Static).len(); assert!(lines.len() > standard); } #[test] fn unknown_familiar_falls_back_without_panic() { let t = familiar_theme::resolve("definitely-not-real", &[]); - let _ = render_card(&t, CardSize::Large, None); + let _ = render_card(&t, CardSize::Large, &CompanionPose::Static); } } diff --git a/src-rust/crates/tui/src/familiar_theme.rs b/src-rust/crates/tui/src/familiar_theme.rs index 14b14a82..523d2539 100644 --- a/src-rust/crates/tui/src/familiar_theme.rs +++ b/src-rust/crates/tui/src/familiar_theme.rs @@ -18,7 +18,7 @@ //! //! ```ignore //! let theme = familiar_theme::resolve(id, daemon_familiars); -//! let card = familiar_card::render_card(&theme, size, loading); +//! let card = familiar_card::render_card(&theme, size, pose); //! ``` use claurst_core::coven_shared::CovenFamiliar; @@ -27,9 +27,10 @@ use ratatui::style::Color; // ── Palette ────────────────────────────────────────────────────────────────── /// Four-color palette covering body fill, accent details, eye sockets, and -/// the deep background behind the eyes. Eye + bg are intentionally shared -/// across all themes so the eye rendering helpers in [`crate::mascot`] can -/// stay archetype-agnostic. +/// the deep background behind the eyes. The eye socket follows each +/// familiar's hue (a light tint for legibility against the shared dark +/// `eye_bg`), so the eye rendering helpers in [`crate::mascot`] can stay +/// archetype-agnostic while the eyes still read as "this familiar". #[derive(Debug, Clone, Copy)] pub struct FamiliarPalette { pub primary: Color, @@ -39,11 +40,11 @@ pub struct FamiliarPalette { } impl FamiliarPalette { - const fn from_rgb(primary: (u8, u8, u8), accent: (u8, u8, u8)) -> Self { + const fn from_rgb(primary: (u8, u8, u8), accent: (u8, u8, u8), eye: (u8, u8, u8)) -> Self { Self { primary: Color::Rgb(primary.0, primary.1, primary.2), accent: Color::Rgb(accent.0, accent.1, accent.2), - eye_socket: Color::Rgb(196, 181, 253), // violet-300, retained for legibility + eye_socket: Color::Rgb(eye.0, eye.1, eye.2), eye_bg: Color::Rgb(15, 5, 40), } } @@ -108,49 +109,49 @@ impl FamiliarTheme { const BUILTIN_PALETTES: &[(&str, FamiliarPalette, Archetype, &str, &str)] = &[ ( "kitty", - FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250)), + FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250), (196, 181, 253)), Archetype::Cat, "Kitty", "\u{1f431}", ), ( "nova", - FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138)), + FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138), (254, 240, 138)), Archetype::SorceressCrown, "Nova", "\u{1f451}", ), ( "cody", - FamiliarPalette::from_rgb((34, 211, 238), (165, 243, 252)), + FamiliarPalette::from_rgb((34, 211, 238), (165, 243, 252), (165, 243, 252)), Archetype::Robot, "Cody", "\u{1f4bb}", ), ( "charm", - FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232)), + FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232), (251, 207, 232)), Archetype::Heart, "Charm", "\u{2728}", ), ( "sage", - FamiliarPalette::from_rgb((16, 185, 129), (167, 243, 208)), + FamiliarPalette::from_rgb((16, 185, 129), (167, 243, 208), (167, 243, 208)), Archetype::WizardBook, "Sage", "\u{1f33f}", ), ( "astra", - FamiliarPalette::from_rgb((99, 102, 241), (199, 210, 254)), + FamiliarPalette::from_rgb((99, 102, 241), (199, 210, 254), (199, 210, 254)), Archetype::Moon, "Astra", "\u{1f319}", ), ( "echo", - FamiliarPalette::from_rgb((20, 184, 166), (153, 246, 228)), + FamiliarPalette::from_rgb((20, 184, 166), (153, 246, 228), (153, 246, 228)), Archetype::Ghost, "Echo", "\u{1f47b}", @@ -160,14 +161,14 @@ const BUILTIN_PALETTES: &[(&str, FamiliarPalette, Archetype, &str, &str)] = &[ /// Eight-color palette table used to pick a deterministic accent for any /// user-defined familiar by hashing its id. const PROCEDURAL_PALETTES: &[FamiliarPalette] = &[ - FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250)), // violet - FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138)), // gold - FamiliarPalette::from_rgb((34, 211, 238), (165, 243, 252)), // cyan - FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232)), // pink - FamiliarPalette::from_rgb((16, 185, 129), (167, 243, 208)), // emerald - FamiliarPalette::from_rgb((99, 102, 241), (199, 210, 254)), // indigo - FamiliarPalette::from_rgb((251, 113, 133), (254, 205, 211)), // rose - FamiliarPalette::from_rgb((250, 204, 21), (253, 224, 71)), // amber + FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250), (196, 181, 253)), // violet + FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138), (254, 240, 138)), // gold + FamiliarPalette::from_rgb((34, 211, 238), (165, 243, 252), (165, 243, 252)), // cyan + FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232), (251, 207, 232)), // pink + FamiliarPalette::from_rgb((16, 185, 129), (167, 243, 208), (167, 243, 208)), // emerald + FamiliarPalette::from_rgb((99, 102, 241), (199, 210, 254), (199, 210, 254)), // indigo + FamiliarPalette::from_rgb((251, 113, 133), (254, 205, 211), (254, 205, 211)), // rose + FamiliarPalette::from_rgb((250, 204, 21), (253, 224, 71), (254, 240, 138)), // amber ]; const PROCEDURAL_ARCHETYPES: &[Archetype] = &[ @@ -293,6 +294,17 @@ mod tests { ); } + #[test] + fn eye_socket_palette_varies_by_familiar() { + let kitty = resolve("kitty", &[]); + let cody = resolve("cody", &[]); + + assert_ne!( + kitty.palette.eye_socket, cody.palette.eye_socket, + "eye sockets should use each familiar palette instead of one hardcoded violet" + ); + } + #[test] fn different_user_familiars_differ() { // Two distinct ids should map to either different palette or diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index 69d46289..a97f5edb 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -339,7 +339,7 @@ pub fn update_terminal_title(topic: Option<&str>) { #[cfg(test)] mod tests { use super::*; - use app::{App, HistorySearch, ToolStatus, ToolUseBlock}; + use app::{App, ToolStatus, ToolUseBlock}; use claurst_core::config::Config; use claurst_core::cost::CostTracker; use claurst_core::file_history::FileHistory; @@ -1193,11 +1193,11 @@ mod tests { #[test] fn test_f1_toggles_help() { let mut app = make_app(); - assert!(!app.show_help); + assert!(!app.help_overlay.visible); app.handle_key_event(key(KeyCode::F(1))); - assert!(app.show_help); + assert!(app.help_overlay.visible); app.handle_key_event(key(KeyCode::F(1))); - assert!(!app.show_help); + assert!(!app.help_overlay.visible); } #[test] @@ -1552,32 +1552,6 @@ mod tests { assert!(rendered.contains("▣")); } - // ---- HistorySearch -------------------------------------------------- - - #[test] - fn test_history_search_matches() { - let history = vec![ - "git commit".to_string(), - "git push".to_string(), - "cargo build".to_string(), - ]; - let mut hs = HistorySearch::new(); - hs.query = "git".to_string(); - hs.update_matches(&history); - assert_eq!(hs.matches.len(), 2); - assert_eq!(hs.matches[0], 0); - assert_eq!(hs.matches[1], 1); - } - - #[test] - fn test_history_search_no_matches() { - let history = vec!["hello".to_string()]; - let mut hs = HistorySearch::new(); - hs.query = "xyz".to_string(); - hs.update_matches(&history); - assert!(hs.matches.is_empty()); - } - // ---- PermissionRequest -------------------------------------------- #[test] diff --git a/src-rust/crates/tui/src/mascot.rs b/src-rust/crates/tui/src/mascot.rs index 90bbbdbf..ea559eee 100644 --- a/src-rust/crates/tui/src/mascot.rs +++ b/src-rust/crates/tui/src/mascot.rs @@ -5,14 +5,12 @@ //! determines which glyph renders in the welcome screen top-left, the F2 //! switcher, and the `/agents` detail view. //! -//! The glyph itself is **static** — no walking, no idle blink, no -//! Tab-triggered look-down. The only motion is the loading spinner that -//! rotates inside the eye row when the assistant is mid-turn and stalled. -//! That motion lives in [`loading_eye_spans`]. Everything else is one -//! frame for one pose. +//! The glyph has subtle idle motion (blink/spark frames) plus a loading +//! spinner that rotates inside the eye row when the assistant is mid-turn +//! and stalled. Loading motion lives in [`loading_eye_spans`]. //! //! Public surface: -//! - [`CompanionPose`] — `Static` for the resting glyph, `Loading { frame }` for the spinner. +//! - [`CompanionPose`] — `Static`, `Idle { frame }`, or `Loading { frame }`. //! - [`archetype_lines`] — palette-aware glyph dispatcher used by [`crate::familiar_card`]. //! - [`mascot_lines_for`] — legacy entry point preserved for callers that still //! pass a familiar slug; it routes through the theme/card path internally. @@ -39,11 +37,14 @@ use ratatui::text::{Line, Span}; /// Pose / expression of the companion mascot. /// -/// Static is the resting frame. Loading carries a monotonically-increasing -/// frame counter that drives the eye-spinner animation. +/// Static is the resting frame, used by surfaces that never animate (F2 +/// switcher rows, `/agents` detail view). Idle and Loading carry a +/// monotonically-increasing frame counter: Idle drives the blink/sway idle +/// loop, Loading drives the eye-spinner animation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CompanionPose { Static, + Idle { frame: u64 }, Loading { frame: u64 }, } @@ -75,6 +76,19 @@ fn eyeball_style(palette: &FamiliarPalette) -> Style { .add_modifier(Modifier::BOLD) } +// The event loop ticks at roughly 10 fps, so the 120-frame idle cycle is +// about 12 seconds: the glyph sways one column to the right for the second +// half of each cycle, and blinks (or sparks, for familiars without eyes) +// briefly inside that window. + +fn idle_blink(frame: u64) -> bool { + matches!(frame % 120, 90..=95) +} + +fn idle_sway(frame: u64) -> bool { + matches!(frame % 120, 60..=119) +} + // ── Eye helpers ─────────────────────────────────────────────────────────────── fn eye_spans(palette: &FamiliarPalette, s: &'static str) -> Vec> { @@ -162,7 +176,11 @@ fn kitty_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static body_style(palette), )); let row2 = match pose { - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( + " \u{2590}\u{2500} \u{2500}\u{2590}\u{258c} ".to_string(), + body_style(palette), + )), + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2590}\u{25c8} \u{25c8}\u{2590}\u{258c} ".to_string(), body_style(palette), )), @@ -202,7 +220,11 @@ fn nova_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static> body_style(palette), )) } - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( + " \u{2597}\u{2584}\u{2726}\u{2584}\u{2597}\u{2596} ".to_string(), + body_style(palette), + )), + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2597}\u{2584}\u{265b}\u{2584}\u{2597}\u{2596} ".to_string(), body_style(palette), )), @@ -233,7 +255,11 @@ fn cody_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static> body_style(palette), )) } - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( + " \u{2584}\u{2584}[\u{2500} \u{2500}]\u{2584} ".to_string(), + body_style(palette), + )), + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2584}\u{2584}[\u{25c8} \u{25c8}]\u{2584} ".to_string(), body_style(palette), )), @@ -264,7 +290,12 @@ fn charm_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static body_style(palette), )) } - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( + " \u{2726}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2726} " + .to_string(), + body_style(palette), + )), + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} " .to_string(), body_style(palette), @@ -304,7 +335,11 @@ fn sage_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static> body_style(palette), )) } - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( + " \u{2590}~~\u{253c}~~\u{258c} ".to_string(), + body_style(palette), + )), + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2590}\u{2500}\u{2500}\u{253c}\u{2500}\u{2500}\u{258c} ".to_string(), body_style(palette), )), @@ -335,7 +370,11 @@ fn astra_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static body_style(palette), )) } - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Idle { frame } if idle_blink(*frame) => Line::from(Span::styled( + " \u{2588} \u{00b7} ".to_string(), + body_style(palette), + )), + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2588} \u{2726} ".to_string(), body_style(palette), )), @@ -363,7 +402,16 @@ fn echo_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static> )); Line::from(spans) } - CompanionPose::Static => { + CompanionPose::Idle { frame } if idle_blink(*frame) => { + let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; + spans.extend(eye_spans(palette, "\u{2500}\u{00b7}\u{2500}")); + spans.push(Span::styled( + "]\u{2588} ".to_string(), + body_style(palette), + )); + Line::from(spans) + } + CompanionPose::Static | CompanionPose::Idle { .. } => { let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; spans.extend(eye_spans(palette, "\u{2580}\u{00b7}\u{2580}")); spans.push(Span::styled( @@ -390,7 +438,7 @@ fn echo_lines(palette: &FamiliarPalette, pose: &CompanionPose) -> [Line<'static> accent_style(palette), )) } - CompanionPose::Static => Line::from(Span::styled( + CompanionPose::Static | CompanionPose::Idle { .. } => Line::from(Span::styled( " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7}\u{00b7}".to_string(), accent_style(palette), )), @@ -410,7 +458,7 @@ pub fn archetype_lines( palette: &FamiliarPalette, pose: &CompanionPose, ) -> [Line<'static>; 5] { - match arch { + let mut lines = match arch { Archetype::Cat => kitty_lines(palette, pose), Archetype::SorceressCrown => nova_lines(palette, pose), Archetype::Robot => cody_lines(palette, pose), @@ -422,7 +470,18 @@ pub fn archetype_lines( | Archetype::SigilHex | Archetype::SigilRune | Archetype::SigilSeal => kitty_lines(palette, pose), + }; + // Idle sway: drift the whole glyph one column right for half of each + // idle cycle. Card layouts pad rows to a fixed width, so the shift only + // moves the glyph inside its slot. + if let CompanionPose::Idle { frame } = pose { + if idle_sway(*frame) { + for line in lines.iter_mut() { + line.spans.insert(0, Span::raw(" ")); + } + } } + lines } /// Resolve a familiar slug to its theme and render the glyph block. @@ -474,6 +533,27 @@ mod tests { assert_ne!(txt_a, txt_b, "loading row should differ between frames"); } + #[test] + fn idle_pose_blinks_between_frames() { + let awake = mascot_lines_for(Some("kitty"), &CompanionPose::Idle { frame: 0 }); + let blink = mascot_lines_for(Some("kitty"), &CompanionPose::Idle { frame: 90 }); + + assert_ne!( + line_text(&awake[1]), + line_text(&blink[1]), + "idle row should blink instead of staying completely static" + ); + } + + #[test] + fn idle_pose_sways_between_frames() { + // Frame 0 sits in the resting half of the idle cycle, frame 60 in the + // swayed half: every row gains a one-column shift. + let rest = mascot_lines_for(Some("sage"), &CompanionPose::Idle { frame: 0 }); + let sway = mascot_lines_for(Some("sage"), &CompanionPose::Idle { frame: 60 }); + assert_eq!(format!(" {}", line_text(&rest[0])), line_text(&sway[0])); + } + #[test] fn unknown_familiar_falls_back_to_kitty() { let a = mascot_lines_for(Some("unknown_xxx"), &CompanionPose::Static); diff --git a/src-rust/crates/tui/src/prompt_input.rs b/src-rust/crates/tui/src/prompt_input.rs index e4bfee5d..e40b8edb 100644 --- a/src-rust/crates/tui/src/prompt_input.rs +++ b/src-rust/crates/tui/src/prompt_input.rs @@ -3170,7 +3170,8 @@ impl PromptInputState { }) } - /// Update typeahead suggestions for slash commands and file references in the current text. + /// Update typeahead suggestions for slash commands, file references, and + /// (for plain text) matching prompt-history entries. pub fn update_suggestions( &mut self, slash_commands: &[(&str, &str)], @@ -3187,7 +3188,21 @@ impl PromptInputState { file_autocomplete_show_hidden, ); + // Plain text with no command/file suggestions: offer recent history + // entries that share the typed prefix. if self.suggestions.is_empty() { + self.suggestions = self.history_suggestions(); + } + + if self.suggestions.is_empty() { + self.suggestion_index = None; + } else if self + .suggestions + .iter() + .all(|s| s.source == TypeaheadSource::History) + { + // History hints are passive: never auto-select them, so Enter + // still submits the typed prompt. Tab accepts the top hint. self.suggestion_index = None; } else { let idx = self @@ -3198,6 +3213,34 @@ impl PromptInputState { } } + /// Recent prompt-history entries (newest first, deduplicated) that start + /// with the text before the cursor. Skips slash commands — those have + /// their own suggestion source — and inputs shorter than 2 characters, + /// which match too much to be useful. + fn history_suggestions(&self) -> Vec { + const MAX_HISTORY_SUGGESTIONS: usize = 3; + let query = self.text[..self.cursor].to_lowercase(); + if query.trim().len() < 2 || query.starts_with('/') { + return Vec::new(); + } + let mut seen = std::collections::HashSet::new(); + self.history + .iter() + .rev() + .filter(|entry| { + let lower = entry.to_lowercase(); + lower.starts_with(&query) && lower != query + }) + .filter(|entry| seen.insert(entry.to_lowercase())) + .take(MAX_HISTORY_SUGGESTIONS) + .map(|entry| TypeaheadSuggestion { + text: entry.clone(), + description: String::new(), + source: TypeaheadSource::History, + }) + .collect() + } + /// Select the next suggestion. pub fn suggestion_next(&mut self) { if self.suggestions.is_empty() { @@ -4294,6 +4337,80 @@ mod tests { assert!(s.suggestions.is_empty()); } + // ---- history suggestions ------------------------------------------- + + #[test] + fn history_suggestions_match_prefix_newest_first() { + let mut s = PromptInputState::new(); + s.history = vec![ + "fix the parser".to_string(), + "cargo build".to_string(), + "fix the lexer".to_string(), + ]; + s.text = "fix".to_string(); + s.cursor = s.text.len(); + s.update_suggestions(&[], 15, false); + let texts: Vec<&str> = s.suggestions.iter().map(|x| x.text.as_str()).collect(); + assert_eq!(texts, vec!["fix the lexer", "fix the parser"]); + assert!(s + .suggestions + .iter() + .all(|x| x.source == TypeaheadSource::History)); + } + + #[test] + fn history_suggestions_never_auto_select() { + // Enter must keep submitting the typed prompt, so passive history + // hints leave suggestion_index unset; Tab accepts the top hint. + let mut s = PromptInputState::new(); + s.history = vec!["fix the parser".to_string()]; + s.text = "fix".to_string(); + s.cursor = s.text.len(); + s.update_suggestions(&[], 15, false); + assert!(!s.suggestions.is_empty()); + assert_eq!(s.suggestion_index, None); + } + + #[test] + fn history_suggestions_skip_short_and_exact_input() { + let mut s = PromptInputState::new(); + s.history = vec!["fix the parser".to_string()]; + // One character matches too much to be useful. + s.text = "f".to_string(); + s.cursor = 1; + s.update_suggestions(&[], 15, false); + assert!(s.suggestions.is_empty()); + // Input identical to the history entry suggests nothing new. + s.text = "fix the parser".to_string(); + s.cursor = s.text.len(); + s.update_suggestions(&[], 15, false); + assert!(s.suggestions.is_empty()); + } + + #[test] + fn history_suggestion_accepted_via_tab_flow() { + let mut s = PromptInputState::new(); + s.history = vec!["fix the parser".to_string()]; + s.text = "fix".to_string(); + s.cursor = s.text.len(); + s.update_suggestions(&[], 15, false); + // Tab handler sets the index before accepting (see app.rs "indent"). + s.suggestion_index = Some(0); + s.accept_suggestion(); + assert_eq!(s.text, "fix the parser"); + assert_eq!(s.cursor, "fix the parser".len()); + } + + #[test] + fn slash_input_does_not_surface_history() { + let mut s = PromptInputState::new(); + s.history = vec!["/help me understand".to_string()]; + s.text = "/zz".to_string(); + s.cursor = s.text.len(); + s.update_suggestions(&[], 15, false); + assert!(s.suggestions.is_empty()); + } + // ---- token estimate ------------------------------------------------- #[test] diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 116fa0e6..86d851e4 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -24,7 +24,6 @@ use crate::hooks_config_menu::render_hooks_config_menu; use crate::import_config_dialog::render_import_config_dialog; use crate::invalid_config_dialog::render_invalid_config_dialog; use crate::key_input_dialog::render_key_input_dialog; -use crate::mascot::CompanionPose; use crate::mcp_view::render_mcp_view; use crate::memory_file_selector::render_memory_file_selector; use crate::memory_update_notification::render_memory_update_notification; @@ -83,6 +82,9 @@ const SPINNER_FRAME_DIVISOR: u64 = 2; // 1 (bottom border) ≈ 11 rows. The right column packs Tips above Status; // the box fits all of it cleanly. const WELCOME_BOX_HEIGHT: u16 = 11; +// Cap the welcome box width on large terminals so it doesn't stretch +// edge-to-edge; everything inside fits comfortably within 120 columns. +const WELCOME_BOX_MAX_WIDTH: u16 = 120; const STATUS_THINKING: &str = "thinking"; const STATUS_THINKING_ELLIPSIS: &str = "thinking\u{2026}"; const STREAM_STALL_THRESHOLD: std::time::Duration = std::time::Duration::from_secs(3); @@ -534,12 +536,9 @@ pub fn render_app(frame: &mut Frame, app: &App) { render_tasks_overlay(frame, &app.tasks_overlay, size); } - // New help overlay + // Help overlay if app.help_overlay.visible { render_help_overlay(frame, &app.help_overlay, size); - } else if app.show_help { - // Legacy fallback — render the simple help overlay - render_simple_help_overlay(frame, size); } // History search overlay @@ -550,9 +549,6 @@ pub fn render_app(frame: &mut Frame, app: &App) { &app.prompt_input.history, size, ); - } else if let Some(ref hs) = app.history_search { - // Legacy history search rendering - render_legacy_history_search(frame, hs, app, size); } // Settings screen (highest-priority full-screen overlay) @@ -1634,8 +1630,9 @@ fn visible_familiar(app: &App) -> Option Some(frame), - CompanionPose::Static => None, - }; let mut left_lines: Vec = Vec::new(); left_lines.push(Line::from(Span::styled( @@ -1753,7 +1746,11 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) { if let Some(seq) = familiar_image::render_familiar_image(familiar_name, 11, 5) { left_lines.push(Line::from(Span::raw(seq))); } else { - left_lines.extend(familiar_card::render_card(&theme, card_size, loading_frame)); + left_lines.extend(familiar_card::render_card( + &theme, + card_size, + &app.companion_current_pose, + )); } } frame.render_widget( @@ -2910,11 +2907,11 @@ fn render_prompt_suggestions(frame: &mut Frame, app: &App, area: Rect) { truncate_middle(&suggestion.text, label_width), label_style, )); + spans.push(Span::styled( + " [context] ", + Style::default().fg(Color::DarkGray), + )); if !suggestion.description.is_empty() { - spans.push(Span::styled( - " \u{2014} ", - Style::default().fg(Color::DarkGray), - )); spans.push(Span::styled( truncate_text(&suggestion.description, area.width as usize / 2), detail_style, @@ -2952,166 +2949,6 @@ fn render_prompt_suggestions(frame: &mut Frame, app: &App, area: Rect) { } } -// ----------------------------------------------------------------------- -// Legacy simple help overlay (fallback when help_overlay is not open) -// ----------------------------------------------------------------------- - -fn render_simple_help_overlay(frame: &mut Frame, area: Rect) { - let help_width = 50u16.min(area.width.saturating_sub(4)); - let help_height = 20u16.min(area.height.saturating_sub(4)); - let help_area = crate::overlays::centered_rect(help_width, help_height, area); - - frame.render_widget(Clear, help_area); - - let lines = vec![ - Line::from(vec![Span::styled( - " Key Bindings", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - )]), - Line::from(""), - kb_line("Enter", "Submit message"), - kb_line("Ctrl+C", "Cancel streaming / Quit"), - kb_line("Ctrl+D", "Quit (empty input)"), - kb_line("Up / Down", "Navigate input history"), - kb_line("Ctrl+R", "Search input history"), - kb_line("PageUp / PageDown", "Scroll messages"), - kb_line("F1 / ?", "Toggle this help"), - kb_line("Alt+H", "Toggle this help"), - kb_line("F2", "Switch familiar"), - kb_line("Ctrl+B", "Create / switch branch"), - kb_line("Tab", "Cycle mode (build/plan/explore)"), - Line::from(""), - Line::from(vec![Span::styled( - " Permission Dialog", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - )]), - Line::from(""), - kb_line("1 / 2 / 3", "Select option"), - kb_line("y / a / n", "Allow / Always / Deny"), - kb_line("Enter", "Confirm selection"), - kb_line("Esc", "Deny (close dialog)"), - Line::from(""), - Line::from(vec![Span::styled( - " press F1 or ? to close ", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )]), - ]; - - let block = Block::default() - .borders(Borders::ALL) - .title(" Help ") - .border_style(Style::default().fg(Color::Cyan)); - - let para = Paragraph::new(lines) - .block(block) - .alignment(Alignment::Left); - frame.render_widget(para, help_area); -} - -fn kb_line<'a>(key: &str, desc: &str) -> Line<'a> { - Line::from(vec![ - Span::raw(" "), - Span::styled( - format!("{:<20}", key), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::raw(desc.to_string()), - ]) -} - -// ----------------------------------------------------------------------- -// Legacy history search overlay (used when history_search_overlay is not open) -// ----------------------------------------------------------------------- - -fn render_legacy_history_search( - frame: &mut Frame, - hs: &crate::app::HistorySearch, - app: &App, - area: Rect, -) { - let dialog_width = 60u16.min(area.width.saturating_sub(4)); - let visible_matches = 8usize; - let dialog_height = (4 + visible_matches.min(hs.matches.len().max(1)) as u16) - .min(area.height.saturating_sub(4)); - let dialog_area = crate::overlays::centered_rect(dialog_width, dialog_height, area); - - frame.render_widget(Clear, dialog_area); - - let mut lines: Vec = Vec::new(); - lines.push(Line::from(vec![ - Span::raw(" Search: "), - Span::styled( - hs.query.clone(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - Span::styled("\u{2588}", Style::default().fg(Color::White)), - ])); - lines.push(Line::from("")); - - if hs.matches.is_empty() { - lines.push(Line::from(vec![Span::styled( - " (no matches)", - Style::default().fg(Color::DarkGray), - )])); - } else { - let start = hs.selected.saturating_sub(visible_matches / 2); - let end = (start + visible_matches).min(hs.matches.len()); - let start = end.saturating_sub(visible_matches).min(start); - - for (display_idx, &hist_idx) in hs.matches[start..end].iter().enumerate() { - let real_idx = start + display_idx; - let is_selected = real_idx == hs.selected; - let entry = app - .prompt_input - .history - .get(hist_idx) - .map(String::as_str) - .unwrap_or(""); - - let truncated = if UnicodeWidthStr::width(entry) > (dialog_width as usize - 6) { - let mut s = entry.to_string(); - s.truncate(dialog_width as usize - 9); - format!("{}\u{2026}", s) - } else { - entry.to_string() - }; - - let (prefix, style) = if is_selected { - ( - " \u{25BA} ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - } else { - (" ", Style::default().fg(Color::White)) - }; - lines.push(Line::from(vec![ - Span::raw(prefix), - Span::styled(truncated, style), - ])); - } - } - - let block = Block::default() - .borders(Borders::ALL) - .title(" History Search (Esc to cancel) ") - .border_style(Style::default().fg(Color::Cyan)); - - let para = Paragraph::new(lines).block(block); - frame.render_widget(para, dialog_area); -} - // ----------------------------------------------------------------------- // Complete status line (T2-8) // ----------------------------------------------------------------------- @@ -3679,4 +3516,67 @@ mod welcome_tests { "unexpected daemon label: {label}" ); } + + #[test] + fn welcome_box_width_is_capped_on_large_terminals() { + let mut terminal = Terminal::new(TestBackend::new(180, 14)).expect("terminal"); + let app = make_test_app_with_model_and_familiar(None, None, None, None); + + terminal + .draw(|frame| render_welcome_box(frame, &app, frame.area())) + .expect("draw welcome"); + + let buffer = terminal.backend().buffer(); + for y in 0..WELCOME_BOX_HEIGHT { + for x in WELCOME_BOX_MAX_WIDTH..180 { + assert_eq!( + buffer[(x, y)].symbol(), + " ", + "welcome box should not paint past {WELCOME_BOX_MAX_WIDTH} columns at ({x}, {y})" + ); + } + } + } + + #[test] + fn typeahead_suggestions_show_source_badges() { + let mut terminal = Terminal::new(TestBackend::new(100, 3)).expect("terminal"); + let mut app = make_test_app_with_model_and_familiar(None, None, None, None); + app.prompt_input.suggestions = vec![ + crate::prompt_input::TypeaheadSuggestion { + text: "/help".to_string(), + description: "Show help".to_string(), + source: TypeaheadSource::SlashCommand, + }, + crate::prompt_input::TypeaheadSuggestion { + text: "@src/main.rs".to_string(), + description: "file".to_string(), + source: TypeaheadSource::FileRef, + }, + crate::prompt_input::TypeaheadSuggestion { + text: "previous prompt".to_string(), + description: "recent".to_string(), + source: TypeaheadSource::History, + }, + ]; + + terminal + .draw(|frame| render_prompt_suggestions(frame, &app, frame.area())) + .expect("draw suggestions"); + + let content: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + for expected in ["[cmd]", "[context]", "[history]"] { + assert!( + content.contains(expected), + "suggestion list should include {expected} badge, got {content:?}" + ); + } + } }