From 78fb5ea41d328397d51d125800464816145f6808 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 15 May 2026 22:44:52 +0200 Subject: [PATCH 1/2] Add fleet-ui tab strip snapshot --- ...ip_snapshot__tab_strip_default_render.snap | 7 ++ rust/fleet-ui/tests/tab_strip_snapshot.rs | 83 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 rust/fleet-ui/tests/snapshots/tab_strip_snapshot__tab_strip_default_render.snap create mode 100644 rust/fleet-ui/tests/tab_strip_snapshot.rs diff --git a/rust/fleet-ui/tests/snapshots/tab_strip_snapshot__tab_strip_default_render.snap b/rust/fleet-ui/tests/snapshots/tab_strip_snapshot__tab_strip_default_render.snap new file mode 100644 index 0000000..f74a26a --- /dev/null +++ b/rust/fleet-ui/tests/snapshots/tab_strip_snapshot__tab_strip_default_render.snap @@ -0,0 +1,7 @@ +--- +source: fleet-ui/tests/tab_strip_snapshot.rs +expression: "normalize_clock(format!(\"{}\", terminal.backend()))" +--- +" " +" ◆ codex-fleet HH:MM:SS ◖ 0 ⊞ Overview 7 ◗ ◖ 1 ⌬ Fleet 12 ◗ ◖ 2 ▤ Plan 3 ◗ ◖ 3 ≋ Waves 1 ◗ ◖ 4 ✓ Review 0 ◗ ● live · 42 " +" " diff --git a/rust/fleet-ui/tests/tab_strip_snapshot.rs b/rust/fleet-ui/tests/tab_strip_snapshot.rs new file mode 100644 index 0000000..aba7e34 --- /dev/null +++ b/rust/fleet-ui/tests/tab_strip_snapshot.rs @@ -0,0 +1,83 @@ +use fleet_ui::tab_strip::{Tab, TabStrip, COUNTERS_PATH}; +use ratatui::{backend::TestBackend, layout::Rect, Terminal}; +use std::{ + fs, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[test] +fn tab_strip_default_render() { + let _fixture = CounterFixture::install(); + let mut terminal = Terminal::new(TestBackend::new(128, 3)).unwrap(); + let mut hits = Vec::new(); + + terminal + .draw(|frame| { + hits = TabStrip::new(Tab::Plan, 128) + .with_tick(42) + .render(frame, Rect::new(0, 1, 128, 1)); + }) + .unwrap(); + + assert_eq!(hits.len(), Tab::ALL.len()); + assert_eq!(hits[2].tab, Tab::Plan); + + insta::assert_snapshot!(normalize_clock(format!("{}", terminal.backend()))); +} + +struct CounterFixture { + previous: Option>, +} + +impl CounterFixture { + fn install() -> Self { + let path = Path::new(COUNTERS_PATH); + let previous = fs::read(path).ok(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + fs::write( + path, + format!( + r#"{{"overview":7,"fleet":12,"plan":3,"waves":1,"review":0,"updated_at":{updated_at}}}"# + ), + ) + .unwrap(); + + Self { previous } + } +} + +impl Drop for CounterFixture { + fn drop(&mut self) { + match &self.previous { + Some(previous) => { + let _ = fs::write(COUNTERS_PATH, previous); + } + None => { + let _ = fs::remove_file(COUNTERS_PATH); + } + } + } +} + +fn normalize_clock(rendered: String) -> String { + rendered + .lines() + .map(|line| { + let Some(start) = line.find("codex-fleet ") else { + return line.to_string(); + }; + let mut line = line.to_string(); + let clock_start = start + "codex-fleet ".len(); + line.replace_range(clock_start..clock_start + "HH:MM:SS".len(), "HH:MM:SS"); + line + }) + .collect::>() + .join("\n") +} From 2d2358395a45145c8f47c06dc4f6e87a87e3a1f4 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 15 May 2026 22:45:27 +0200 Subject: [PATCH 2/2] Add fleet-ui spotlight overlay widget --- rust/fleet-ui/src/overlay.rs | 469 +++++++++++++++++- rust/fleet-ui/tests/overlay_spotlight.rs | 97 ++++ ...y_spotlight__spotlight_default_render.snap | 44 ++ 3 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 rust/fleet-ui/tests/overlay_spotlight.rs create mode 100644 rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap diff --git a/rust/fleet-ui/src/overlay.rs b/rust/fleet-ui/src/overlay.rs index cdae869..fbe6e4c 100644 --- a/rust/fleet-ui/src/overlay.rs +++ b/rust/fleet-ui/src/overlay.rs @@ -11,16 +11,180 @@ //! caller renders their own content inside `Block::inner(rect)`. use crate::card::card; +use crate::palette::*; use ratatui::Frame; use ratatui::{ layout::Rect, - style::{Color, Style}, - widgets::{Block, Clear}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, }; pub mod context_menu; pub use context_menu::{ContextMenu, MenuItem, Section}; +/// One command row in the reusable Spotlight palette. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpotlightItem { + pub group: &'static str, + pub icon: &'static str, + pub title: &'static str, + pub sub: &'static str, + pub kbd: &'static str, +} + +impl SpotlightItem { + pub const fn new( + group: &'static str, + icon: &'static str, + title: &'static str, + sub: &'static str, + kbd: &'static str, + ) -> Self { + Self { + group, + icon, + title, + sub, + kbd, + } + } +} + +/// Interactive Spotlight palette state owned by the caller. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SpotlightState { + pub query: String, + pub selected: usize, + pub tick: u64, +} + +/// Return Spotlight items whose title or sub-line contains `query`. +pub fn filter<'a>(items: &'a [SpotlightItem], query: &str) -> Vec<&'a SpotlightItem> { + if query.is_empty() { + return items.iter().collect(); + } + + let query = query.to_lowercase(); + items + .iter() + .filter(|item| { + item.title.to_lowercase().contains(&query) || item.sub.to_lowercase().contains(&query) + }) + .collect() +} + +/// Reusable iOS-style Spotlight command palette overlay. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Spotlight; + +impl Spotlight { + pub const WIDTH: u16 = 78; + pub const HEIGHT: u16 = 42; + + pub fn new() -> Self { + Self + } + + pub fn render( + &self, + frame: &mut Frame, + area: Rect, + state: &SpotlightState, + items: &[SpotlightItem], + ) { + let filtered = filter(items, &state.query); + let total = filtered.len(); + let selected = if total == 0 { + 0 + } else { + state.selected.min(total - 1) + }; + + let rect = centered_overlay(area, Self::WIDTH, Self::HEIGHT); + card_shadow(frame, rect, area); + frame.render_widget(Clear, rect); + frame.render_widget(spotlight_glass_block(), rect); + + let inner = Rect { + x: rect.x + 2, + y: rect.y + 1, + width: rect.width.saturating_sub(4), + height: rect.height.saturating_sub(2), + }; + if inner.width == 0 || inner.height == 0 { + return; + } + + let mut y = inner.y + 1; + y = render_spotlight_search_row(frame, inner, y, state); + render_spotlight_hairline(frame, inner, y); + y += 2; + + if total == 0 { + render_spotlight_empty(frame, inner, y); + render_spotlight_footer(frame, inner, total); + return; + } + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "TOP HIT", + Style::default() + .fg(IOS_FG_MUTED) + .add_modifier(Modifier::BOLD), + ))), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); + y += 1; + + y = render_spotlight_top_hit(frame, inner, y, filtered[0], selected == 0); + y += 1; + + let bottom_guard = inner.y + inner.height.saturating_sub(2); + let mut last_group: Option<&str> = None; + for (rank_index, item) in filtered.iter().enumerate().skip(1) { + if y + 3 > bottom_guard { + break; + } + if last_group != Some(item.group) { + if last_group.is_some() { + y += 1; + if y + 3 > bottom_guard { + break; + } + } + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {}", item.group), + Style::default() + .fg(IOS_FG_MUTED) + .add_modifier(Modifier::BOLD), + ))), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); + y += 1; + last_group = Some(item.group); + } + + render_spotlight_result(frame, inner, y, item, rank_index == selected); + y += 2; + } + + render_spotlight_footer(frame, inner, total); + } +} + /// Return a `Rect` of `width × height` centred inside `area`. If the /// requested size exceeds `area`, the result is clamped to `area` (top-left /// aligned in that degenerate case so nothing overflows the frame). @@ -90,6 +254,307 @@ pub fn card_shadow(frame: &mut Frame, card_rect: Rect, area: Rect) { } } +fn spotlight_glass_block() -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(IOS_HAIRLINE_STRONG)) + .style(Style::default().bg(IOS_BG_SOLID).fg(IOS_FG)) +} + +fn render_spotlight_search_row( + frame: &mut Frame, + inner: Rect, + y: u16, + state: &SpotlightState, +) -> u16 { + let caret_on = (state.tick / 4) % 2 == 0; + let caret_char = if caret_on { "▏" } else { " " }; + let query_display = if state.query.is_empty() { + "type to filter…" + } else { + state.query.as_str() + }; + let query_style = if state.query.is_empty() { + Style::default().fg(IOS_FG_FAINT) + } else { + Style::default().fg(IOS_FG).add_modifier(Modifier::BOLD) + }; + let query_spans = vec![ + Span::styled("⌕ ", Style::default().fg(IOS_FG_MUTED)), + Span::styled(query_display.to_string(), query_style), + Span::styled( + caret_char, + Style::default().fg(IOS_TINT).add_modifier(Modifier::BOLD), + ), + ]; + let cmdk = " Ctrl K "; + let cmdk_w = text_width(cmdk); + frame.render_widget( + Paragraph::new(Line::from(query_spans)), + Rect { + x: inner.x, + y, + width: inner.width.saturating_sub(cmdk_w + 1), + height: 1, + }, + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + cmdk, + Style::default().fg(IOS_FG_FAINT).bg(IOS_CHIP_BG), + ))), + Rect { + x: inner.x + inner.width - cmdk_w, + y, + width: cmdk_w, + height: 1, + }, + ); + y + 1 +} + +fn render_spotlight_empty(frame: &mut Frame, inner: Rect, y: u16) { + let msg = "no matches"; + let msg_w = text_width(msg); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + msg, + Style::default() + .fg(IOS_FG_MUTED) + .add_modifier(Modifier::BOLD), + ))), + Rect { + x: inner.x + (inner.width.saturating_sub(msg_w)) / 2, + y: y + 3, + width: msg_w, + height: 1, + }, + ); +} + +fn render_spotlight_top_hit( + frame: &mut Frame, + inner: Rect, + y: u16, + item: &SpotlightItem, + active: bool, +) -> u16 { + let hit_bg = if active { + IOS_TINT + } else { + Color::Rgb(8, 80, 180) + }; + let hit_rect = Rect { + x: inner.x, + y, + width: inner.width, + height: 3, + }; + frame.render_widget( + Block::default().style(Style::default().bg(hit_bg)), + hit_rect, + ); + + let badge = format!(" tmux · {} ", item.kbd); + let badge_w = text_width(&badge); + let chevron = " › "; + let chevron_w = text_width(chevron); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default().bg(hit_bg)), + Span::styled( + format!(" {} ", item.icon), + Style::default() + .fg(Color::Rgb(255, 255, 255)) + .bg(IOS_TINT_DARK) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", item.title), + Style::default() + .fg(Color::Rgb(255, 255, 255)) + .bg(hit_bg) + .add_modifier(Modifier::BOLD), + ), + ])), + Rect { + x: hit_rect.x, + y: hit_rect.y + 1, + width: hit_rect.width.saturating_sub(badge_w + chevron_w + 1), + height: 1, + }, + ); + if hit_rect.width > badge_w + chevron_w + 1 { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + badge, + Style::default() + .fg(Color::Rgb(255, 255, 255)) + .bg(IOS_TINT_DARK) + .add_modifier(Modifier::BOLD), + ))), + Rect { + x: hit_rect.x + hit_rect.width - badge_w - chevron_w, + y: hit_rect.y + 1, + width: badge_w, + height: 1, + }, + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + chevron, + Style::default() + .fg(IOS_TINT_SUB) + .bg(hit_bg) + .add_modifier(Modifier::BOLD), + ))), + Rect { + x: hit_rect.x + hit_rect.width - chevron_w, + y: hit_rect.y + 1, + width: chevron_w, + height: 1, + }, + ); + } + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {}", item.sub), + Style::default().fg(IOS_TINT_SUB).bg(hit_bg), + ))), + Rect { + x: hit_rect.x, + y: hit_rect.y + 2, + width: hit_rect.width, + height: 1, + }, + ); + y + 3 +} + +fn render_spotlight_result( + frame: &mut Frame, + inner: Rect, + y: u16, + item: &SpotlightItem, + selected: bool, +) { + let row_bg = if selected { IOS_TINT_DARK } else { IOS_CARD_BG }; + let title_fg = if selected { + Color::Rgb(255, 255, 255) + } else { + IOS_FG + }; + let sub_fg = if selected { IOS_TINT_SUB } else { IOS_FG_MUTED }; + let item_rect = Rect { + x: inner.x, + y, + width: inner.width, + height: 2, + }; + frame.render_widget( + Block::default().style(Style::default().bg(row_bg)), + item_rect, + ); + + let kbd = format!(" {} ", item.kbd); + let kbd_w = text_width(&kbd); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default().bg(row_bg)), + Span::styled( + format!(" {} ", item.icon), + Style::default() + .fg(title_fg) + .bg(IOS_ICON_CHIP) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", item.title), + Style::default() + .fg(title_fg) + .bg(row_bg) + .add_modifier(Modifier::BOLD), + ), + ])), + Rect { + x: inner.x, + y, + width: inner.width.saturating_sub(kbd_w + 2), + height: 1, + }, + ); + if inner.width > kbd_w + 1 { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + kbd, + Style::default() + .fg(title_fg) + .bg(IOS_ICON_CHIP) + .add_modifier(Modifier::BOLD), + ))), + Rect { + x: inner.x + inner.width - kbd_w - 1, + y, + width: kbd_w, + height: 1, + }, + ); + } + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {}", item.sub), + Style::default().fg(sub_fg).bg(row_bg), + ))), + Rect { + x: inner.x, + y: y + 1, + width: inner.width, + height: 1, + }, + ); +} + +fn render_spotlight_footer(frame: &mut Frame, inner: Rect, total: usize) { + let y = inner.y + inner.height - 1; + let footer = Line::from(vec![ + Span::styled("↵", Style::default().fg(IOS_FG)), + Span::styled(" open · ", Style::default().fg(IOS_FG_MUTED)), + Span::styled("⌥↵", Style::default().fg(IOS_FG)), + Span::styled(" all panes · ", Style::default().fg(IOS_FG_MUTED)), + Span::styled("esc", Style::default().fg(IOS_FG)), + Span::styled(" cancel · ", Style::default().fg(IOS_FG_MUTED)), + Span::styled("✦", Style::default().fg(IOS_PURPLE)), + Span::styled(format!(" {total} items"), Style::default().fg(IOS_FG_MUTED)), + ]); + frame.render_widget( + Paragraph::new(footer), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); +} + +fn render_spotlight_hairline(frame: &mut Frame, inner: Rect, y: u16) { + let hairline = "─".repeat(inner.width as usize); + frame.render_widget( + Paragraph::new(Span::styled(hairline, Style::default().fg(IOS_HAIRLINE))), + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + ); +} + +fn text_width(s: &str) -> u16 { + s.chars().count() as u16 +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/fleet-ui/tests/overlay_spotlight.rs b/rust/fleet-ui/tests/overlay_spotlight.rs new file mode 100644 index 0000000..84807ca --- /dev/null +++ b/rust/fleet-ui/tests/overlay_spotlight.rs @@ -0,0 +1,97 @@ +use fleet_ui::overlay::{filter, Spotlight, SpotlightItem, SpotlightState}; +use ratatui::{backend::TestBackend, Terminal}; + +fn poc_items() -> Vec { + vec![ + SpotlightItem::new( + "PANE", + "⊟", + "Horizontal split", + "Split active pane top/bottom", + "h", + ), + SpotlightItem::new( + "PANE", + "⊞", + "Vertical split", + "Split active pane left/right", + "v", + ), + SpotlightItem::new( + "PANE", + "⤢", + "Zoom pane", + "Toggle full-screen for this pane", + "z", + ), + SpotlightItem::new( + "PANE", + "⇄", + "Swap with marked pane", + "codex-ricsi-zazrifka ⇄ marked", + "s", + ), + SpotlightItem::new( + "SESSION · codex-admin-kollarrobert", + "⧉", + "Copy whole session", + "180 lines · transcript", + "⇧C", + ), + SpotlightItem::new( + "SESSION · codex-admin-kollarrobert", + "☰", + "Queue message", + "Send to agent on next idle", + "↹", + ), + SpotlightItem::new( + "SESSION · codex-admin-kollarrobert", + "⌚", + "Search history…", + "Across all 7 panes", + "/", + ), + SpotlightItem::new( + "FLEET", + "+", + "Spawn new codex worker", + "codex-fleet · new agent", + "Ctrl N", + ), + SpotlightItem::new( + "FLEET", + "⎇", + "Switch worktree…", + "codex-fleet-extract-p1…", + "Ctrl B", + ), + ] +} + +#[test] +fn spotlight_filter_is_case_insensitive_substring() { + let items = poc_items(); + let filtered = filter(&items, "SPLIT"); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].title, "Horizontal split"); + assert_eq!(filtered[1].title, "Vertical split"); +} + +#[test] +fn spotlight_default_render() { + let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); + let spotlight = Spotlight::new(); + let state = SpotlightState { + query: "split".to_string(), + selected: 0, + tick: 0, + }; + let items = poc_items(); + + terminal + .draw(|frame| spotlight.render(frame, frame.area(), &state, &items)) + .unwrap(); + + insta::assert_snapshot!(format!("{}", terminal.backend())); +} diff --git a/rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap b/rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap new file mode 100644 index 0000000..2adcad4 --- /dev/null +++ b/rust/fleet-ui/tests/snapshots/overlay_spotlight__spotlight_default_render.snap @@ -0,0 +1,44 @@ +--- +source: fleet-ui/tests/overlay_spotlight.rs +expression: "format!(\"{}\", terminal.backend())" +--- +" ╭────────────────────────────────────────────────────────────────────────────╮ " +" │ │ " +" │ ⌕ split▏ Ctrl K │ " +" │ ────────────────────────────────────────────────────────────────────────── │ " +" │ │ " +" │ TOP HIT │ " +" │ │ " +" │ ⊟ Horizontal split tmux · h › │ " +" │ Split active pane top/bottom │ " +" │ │ " +" │ PANE │ " +" │ ⊞ Vertical split v │ " +" │ Split active pane left/right │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ ↵ open · ⌥↵ all panes · esc cancel · ✦ 2 items │ " +" ╰────────────────────────────────────────────────────────────────────────────╯ "