//! Queue pane — renders queued prompts in a `ListPane`. //! //! Similar to [`super::todo_pane::TodoPane`] but for queued prompts. //! Shows `#1`, `#2`, … prefixes (positional, 1-based) with the first //! line of each prompt's text. Multiline prompts show a `(+N lines)` indicator. use ratatui::style::Style; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::app::agent::{QueueEntryKind, QueuedPrompt}; use crate::app::prompt_queue::QueueEntryWire; use crate::render::line_utils::truncate_str; use crate::theme::{Theme, ThemeKind}; use super::list_pane::ListItem; /// Server row is visible in the held pane / "N queued" count: not running, /// not the armed send-now echo, and not painted-pending (the painted block /// represents the message even after the arm drops; showing the row beside /// it would duplicate the message). #[inline] pub(crate) fn visible_held_server_row( id: &str, running_id: Option<&str>, send_now_id: Option<&str>, painted_pending: &std::collections::HashMap, ) -> bool { let key = id; let id = Some(id); id != running_id && id != send_now_id && !painted_pending.contains_key(key) } // --------------------------------------------------------------------------- // QueuedPromptEntry — ListItem wrapper around QueuedPrompt // --------------------------------------------------------------------------- /// Where a rendered queue row originates, which determines how an edit /// (delete / reorder) is routed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QueueRowOrigin { /// A client-local `pending_prompts` entry (skill/image/bash/cron/etc., or /// any idle-drained prompt). Edits mutate the local queue directly. Local, /// A server-authoritative `shared_prompt_queues` entry (plain prompt queued /// while running). Edits route to the agent as `kigi/queue/*`. Server, } /// A resolved reference to a queue row, used by the edit handlers to route by /// origin. Returned by [`QueuePane::row_ref`]. #[derive(Debug, Clone)] pub struct QueueRowRef { pub origin: QueueRowOrigin, /// The server `prompt_id` for `Server` rows; `None` for `Local` rows. pub server_id: Option, /// The server queue-entry version (for versioned removes); 0 for local. pub version: u64, } /// A `QueuedPrompt` wrapped for display in a `ListPane`. #[derive(Debug, Clone)] pub struct QueuedPromptEntry { /// Stable selection ID. For local rows this is the `QueuedPrompt.id` /// monotonic counter; for server rows it is a stable hash of the server /// `prompt_id` (so `ListPane` selection works uniformly across origins). pub id: u64, /// 1-based positional index for display (`#1`, `#2`, …). pub position: usize, /// The full prompt text (for search/copy). pub text: String, /// First non-empty line (trimmed) for display. first_line: String, /// Total line count in the prompt. line_count: usize, /// Entry kind (Prompt or Command). kind: QueueEntryKind, /// Row origin — Local (client queue) or Server (shared queue). origin: QueueRowOrigin, /// Server `prompt_id` for `Server` rows; `None` for local rows. server_id: Option, /// Server queue-entry version (for versioned removes); 0 for local. version: u64, /// Cached styled content (rebuilt with width in `rebuild_styled_for_width`). styled: Line<'static>, } /// Stable 64-bit id derived from a server `prompt_id` string, so server rows /// have a `u64` selection id like local rows. Collision with local monotonic /// counters (small integers) is astronomically unlikely. fn synth_server_id(prompt_id: &str) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); prompt_id.hash(&mut h); // Set the top bit so a synthesized id can never equal a small local // monotonic counter (which start at 0 and increment by 1). h.finish() | (1 << 63) } /// Map a shared-queue wire `kind` string to the display [`QueueEntryKind`]. /// Unknown kinds fall back to a plain prompt. pub fn kind_from_wire(kind: &str) -> QueueEntryKind { match kind { "bash" => QueueEntryKind::BashCommand, "cron" => QueueEntryKind::Cron, "command" => QueueEntryKind::Command, _ => QueueEntryKind::Prompt, } } impl QueuedPromptEntry { /// Create a new entry from a `QueuedPrompt` and its current position. pub fn new(prompt: &QueuedPrompt, position: usize) -> Self { // Show first non-empty line, trimmed. let first_line = prompt .text .lines() .map(|l| l.trim()) .find(|l| !l.is_empty()) .unwrap_or("") .to_string(); let line_count = prompt.text.lines().count(); // Build initial styled line (will be rebuilt with proper width later). let styled = Self::build_styled(&first_line, line_count, prompt.kind, None); Self { id: prompt.id, position, text: prompt.text.clone(), first_line, line_count, kind: prompt.kind, origin: QueueRowOrigin::Local, server_id: None, version: 0, styled, } } /// Create an entry from a server-authoritative shared-queue wire entry. /// The wire `kind` selects the display style: plain prompts, bash /// (`! ` prefix), and the agent-driven cron kind. pub fn from_server(wire: &QueueEntryWire, position: usize) -> Self { let first_line = wire .text .lines() .map(|l| l.trim()) .find(|l| !l.is_empty()) .unwrap_or("") .to_string(); let line_count = wire.text.lines().count(); let kind = kind_from_wire(&wire.kind); let styled = Self::build_styled(&first_line, line_count, kind, None); Self { id: synth_server_id(&wire.id), position, text: wire.text.clone(), first_line, line_count, kind, origin: QueueRowOrigin::Server, server_id: Some(wire.id.clone()), version: wire.version, styled, } } /// Rebuild the styled content with proper truncation for the given width. /// /// This ensures the `(+N lines)` suffix is always visible by truncating /// the first line content to make room. pub fn rebuild_styled_for_width(&mut self, available_width: u16) { self.styled = Self::build_styled( &self.first_line, self.line_count, self.kind, Some(available_width as usize), ); } /// Build the styled `Line` for display. /// /// If `max_width` is provided and the entry is multiline, truncates the /// first line content to ensure the `(+N lines)` suffix fits. fn build_styled( first_line: &str, line_count: usize, kind: QueueEntryKind, max_width: Option, ) -> Line<'static> { let theme = Theme::current(); let extra_lines = line_count.saturating_sub(1); // Build the suffix for multiline prompts: " (+N lines)" or " (+1 line)" let suffix = if extra_lines > 0 { if extra_lines == 1 { " (+1 line)".to_string() } else { format!(" (+{extra_lines} lines)") } } else { String::new() }; let suffix_width = suffix.width(); // Determine how much space we have for the first line content. // Reserve space for the suffix if multiline. let content_max_width = max_width.map(|w| { if extra_lines > 0 { w.saturating_sub(suffix_width) } else { w } }); match kind { QueueEntryKind::Prompt => { let display_text = if let Some(max_w) = content_max_width { truncate_str(first_line, max_w) } else { first_line.to_string() }; let mut spans = vec![Span::styled( display_text, Style::default().fg(theme.accent_user), )]; if extra_lines > 0 { spans.push(Span::styled(suffix, Style::default().fg(theme.gray))); } Line::from(spans) } QueueEntryKind::Command => { // Slash commands: `/command` in magenta, args (if any) in bright gray. let trimmed = first_line.trim(); // For commands, we need to be smarter about truncation. // Truncate the whole thing first, then split. let truncated = if let Some(max_w) = content_max_width { truncate_str(trimmed, max_w) } else { trimmed.to_string() }; let mut spans = if let Some(space_idx) = truncated.find(' ') { let (cmd, args) = truncated.split_at(space_idx); vec![ Span::styled(cmd.to_string(), Style::default().fg(theme.accent_assistant)), Span::styled(args.to_string(), Style::default().fg(theme.gray_bright)), ] } else { vec![Span::styled( truncated, Style::default().fg(theme.accent_assistant), )] }; if extra_lines > 0 { spans.push(Span::styled(suffix, Style::default().fg(theme.gray))); } Line::from(spans) } QueueEntryKind::BashCommand => { // Bash commands: `! ` prefix in yellow (theme.command), command text in yellow. let display_text = if let Some(max_w) = content_max_width { // Reserve 2 chars for "! " prefix. truncate_str(first_line, max_w.saturating_sub(2)) } else { first_line.to_string() }; let mut spans = vec![ Span::styled("! ", Style::default().fg(theme.command)), Span::styled(display_text, Style::default().fg(theme.command)), ]; if extra_lines > 0 { spans.push(Span::styled(suffix, Style::default().fg(theme.gray))); } Line::from(spans) } QueueEntryKind::Cron => { let display_text = if let Some(max_w) = content_max_width { truncate_str(first_line, max_w.saturating_sub(2)) } else { first_line.to_string() }; let mut spans = vec![ Span::styled("\u{21BB} ", Style::default().fg(theme.gray_dim)), Span::styled(display_text, Style::default().fg(theme.accent_user)), ]; if extra_lines > 0 { spans.push(Span::styled(suffix, Style::default().fg(theme.gray))); } Line::from(spans) } } } } impl ListItem for QueuedPromptEntry { fn content(&self) -> &Line<'_> { &self.styled } fn prefix(&self) -> Option> { let theme = Theme::current(); Some(Line::from(vec![ Span::styled( format!("#{}", self.position), Style::default().fg(theme.gray), ), Span::raw(" "), ])) } fn stable_id(&self) -> u64 { self.id } fn search_text(&self) -> &str { &self.text } } // --------------------------------------------------------------------------- // QueuePane — self-contained pane owning entries, state, and rendering // --------------------------------------------------------------------------- use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::StatefulWidget; use crate::appearance::LayoutConfig; use crate::render::{PreviewConfig, PreviewStyle, render_preview_overlay}; use super::list_pane::{ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode}; use super::overlay::OverlayState; /// Event returned by [`QueuePane::handle_key`] signaling intent to the caller. /// /// The queue pane never mutates business state (the prompt queue) directly. /// It signals intent via these events, and `AgentView::handle_queue_key` /// performs the actual mutation on `AgentSession::pending_prompts`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum QueueEvent { /// Delete the selected prompt from the queue. DeleteSelected { id: u64 }, /// Edit the selected prompt (load into prompt widget). EditSelected { id: u64 }, /// Swap the selected prompt with its neighbor above. SwapUp { id: u64 }, /// Swap the selected prompt with its neighbor below. SwapDown { id: u64 }, /// Force-send the selected prompt as a mid-turn interjection. ForceInterject { id: u64 }, } /// Maximum height (in lines) the queue pane will request. const MAX_QUEUE_HEIGHT: u16 = 3; /// Self-contained queue pane component. /// /// Does NOT own the queue data — entries are rebuilt each frame from /// `AgentSession::pending_prompts`. Owns `ListPaneState` for scroll, /// selection, and rendering state. pub struct QueuePane { /// Styled entries for `ListPane` rendering. /// Rebuilt from the queue at the start of each `render()` call. entries: Vec, /// List pane state (scroll, selection, search, layout cache). pub list_state: ListPaneState, /// Visual style for the list pane framework. list_style: ListPaneStyle, /// Theme kind at the last render. Used to detect a theme switch and /// refresh `list_style`, whose `selection_bg` is captured from the theme /// (otherwise the focused-row highlight keeps the previous theme's /// `bg_highlight` — e.g. KigiNight's dark band leaking into KigiDay). last_theme: ThemeKind, /// Shared visibility/focus state. pub overlay: OverlayState, /// Previous queue length — used for auto-show detection. prev_len: usize, send_now_rect: Option, send_now_entry_id: Option, /// Entry id whose `[Interject]` button is under the mouse, if any. Drives /// the button's hover fg color (brightens like the `[Dashboard]` button). hovered_send_now_id: Option, delete_button_rect: Option, delete_button_entry_id: Option, pub(crate) hovered_delete_id: Option, /// Entry id of the row currently under the mouse cursor, if any. Drives /// the hover affordance: the hovered row reveals its action buttons just /// like the selected row does when the pane is focused. hovered_row_id: Option, /// Content area from the last `render`, used to hit-test which row the /// mouse is over (for `hovered_row_id`). last_inner: Option, } impl Default for QueuePane { fn default() -> Self { Self::new() } } impl QueuePane { /// Create a new empty queue pane. pub fn new() -> Self { let config = ListPaneConfig { follow_enabled: false, wrap_toggle_enabled: false, search_enabled: false, // Queue is small, no search needed copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: false, filter_enabled: false, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::NoWrap, false, config); list_state.set_clipboard_provider(Box::new(crate::clipboard::SystemClipboard)); Self { entries: Vec::new(), list_state, list_style: ListPaneStyle::default(), last_theme: Theme::current_kind(), overlay: OverlayState::hidden(), prev_len: 0, send_now_rect: None, send_now_entry_id: None, hovered_send_now_id: None, delete_button_rect: None, delete_button_entry_id: None, hovered_delete_id: None, hovered_row_id: None, last_inner: None, } } // -- Data management ----------------------------------------------------- /// Rebuild the queue rows from the **union** of the local drip-feed queue /// (`local`) and the server-authoritative shared queue (`server`), tagging /// each row with its origin so edits route correctly. /// /// Merge ordering rule: **server rows first** (in their broadcast /// `position` order), then **local rows** (in `pending_prompts` order). /// Rationale: server (plain) prompts were already accepted into the agent's /// authoritative FIFO; local rows (skill/image/bash/cron, or plain prompts /// queued before the session/turn was ready) wait behind them. This is only /// correct because every server-queued prompt is older than every local /// one, an invariant enforced by `immediate_server_send_eligible`: a prompt /// may take the immediate-send (server) path only while the local queue is /// empty, so a newer prompt can never sit on the server queue ahead of an /// older local prompt. The two queues are disjoint by construction /// (plain-while-running routes to the server queue and everything else /// local), so no row appears in both. /// /// `running_id` / `send_now_id` / `painted_pending` use /// [`visible_held_server_row`]. pub fn sync_from_merged( &mut self, local: &std::collections::VecDeque, server: &[QueueEntryWire], running_id: Option<&str>, send_now_id: Option<&str>, painted_pending: &std::collections::HashMap, ) { // Rebuild entries; positions are recomputed 1-based across the union. self.entries.clear(); let mut pos = 1; for wire in server { if !visible_held_server_row(&wire.id, running_id, send_now_id, painted_pending) { continue; } self.entries.push(QueuedPromptEntry::from_server(wire, pos)); pos += 1; } for prompt in local { self.entries.push(QueuedPromptEntry::new(prompt, pos)); pos += 1; } let new_len = self.entries.len(); // Auto-show when hidden and queue grew, or empty→non-empty (after external hide reset). if new_len > 0 && !self.overlay.visible && (self.prev_len == 0 || new_len > self.prev_len) { self.overlay.visible = true; } if new_len == 0 && self.overlay.visible { self.overlay.visible = false; self.overlay.focused = false; } // Hidden+empty: pin prev_len so the next enqueue always recovers. if new_len == 0 && !self.overlay.visible { self.prev_len = 0; } else { self.prev_len = new_len; } } /// Selection ids of the current rows, in display order. pub fn entry_ids(&self) -> Vec { self.entries.iter().map(|e| e.id).collect() } /// Resolve a row's origin metadata by its selection id (for edit routing). pub fn row_ref(&self, id: u64) -> Option { self.entries .iter() .find(|e| e.id == id) .map(|e| QueueRowRef { origin: e.origin, server_id: e.server_id.clone(), version: e.version, }) } /// Whether the pane should be visible in the layout. pub fn is_visible(&self) -> bool { self.overlay.visible && !self.entries.is_empty() } /// Called after overlay state changes that hide the pane. pub fn on_state_change(&mut self) { if !self.overlay.visible { self.list_state.close_input_bar(); } } /// Reset auto-show edge after external hide on an empty merged queue. pub fn reset_auto_show_edge(&mut self) { self.prev_len = 0; } /// Desired height in lines for layout computation. /// /// Returns 0 when hidden or empty. /// Otherwise: `min(queue.len(), MAX_QUEUE_HEIGHT)`. pub fn desired_height(&self) -> u16 { if !self.is_visible() { return 0; } (self.entries.len() as u16).clamp(1, MAX_QUEUE_HEIGHT) } // -- Input handling ------------------------------------------------------ /// Handle a key event when the queue pane is focused. /// /// Returns `Some(QueueEvent)` for queue-specific actions (delete, edit, /// reorder). Returns `None` if the key wasn't a queue action — the caller /// should then try `handle_navigation_key` for j/k/y etc. pub fn handle_key( &self, key: &KeyEvent, registry: &crate::actions::ActionRegistry, ) -> Option { let id = self.selected_id()?; if registry.matches_id(crate::actions::ActionId::InterjectPrompt, key) { return Some(QueueEvent::ForceInterject { id }); } match key.code { KeyCode::Char('x') | KeyCode::Delete | KeyCode::Backspace => { Some(QueueEvent::DeleteSelected { id }) } KeyCode::Char('e') | KeyCode::Enter => Some(QueueEvent::EditSelected { id }), KeyCode::Char('J') if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::NONE) => { // Shift+J or uppercase J (same thing in practice) if key.code == KeyCode::Char('J') { Some(QueueEvent::SwapDown { id }) } else { None } } KeyCode::Char('K') if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::NONE) => { if key.code == KeyCode::Char('K') { Some(QueueEvent::SwapUp { id }) } else { None } } _ => None, } } /// Handle navigation keys (j/k, y to copy, scrolling, etc.). /// /// Delegates to ListPane. Returns `true` if consumed. pub fn handle_navigation_key(&mut self, key: &KeyEvent) -> bool { if self.entries.is_empty() { return false; } self.list_state.handle_key_event(key, &self.entries) } /// Get the stable ID of the currently selected entry, if any. pub fn selected_id(&self) -> Option { self.list_state.selected_id() } /// Get the full text of the currently selected entry, if any. /// /// Used to determine if the entry is multiline (for preview overlay). pub fn selected_text(&self) -> Option<&str> { let id = self.list_state.selected_id()?; self.entries .iter() .find(|e| e.id == id) .map(|e| e.text.as_str()) } /// Select the deleted row's neighbor (so the cursor doesn't jump to the /// top), resolved from the merged `entries` to stay correct across the /// server/local boundary. Call before the next `sync_from_merged` rebuild. pub fn select_after_delete(&mut self, deleted_id: u64) { let Some(pos) = self.entries.iter().position(|e| e.id == deleted_id) else { return; }; // `pos` exists, so `entries` is non-empty and `len() - 1` is safe. let survivors = self.entries.len() - 1; if survivors == 0 { return; } let new_pos = pos.min(survivors - 1); if let Some(entry) = self .entries .iter() .filter(|e| e.id != deleted_id) .nth(new_pos) { self.list_state.select_by_id(entry.id); } } /// Handle a mouse scroll event over the queue pane area. pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) { let max = match self.list_state.viewport_height() { 0..=5 => 1, _ => lines.unsigned_abs() as i32, }; let capped = lines.signum() * lines.abs().min(max); self.list_state .handle_scroll_event(capped, col, row, &self.entries); // Rows move under a stationary cursor while wheel-scrolling, but hover // is otherwise only refreshed on `Moved` events. Re-resolve the hovered // row from the pointer's current position against the just-updated // scroll offset, so the hover bg and action buttons track the row now // under the pointer instead of the pre-scroll entry. self.update_row_hover(col, row); } /// Handle a mouse click/drag event over the queue pane area. /// /// Returns `true` if the event was consumed. pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16, area: Rect) -> bool { self.list_state .handle_mouse_event(kind, col, row, area, &self.entries) } /// Hit-test the action-row `[Interject]` button. Returns the id of the row /// the button belongs to (the hovered row, else the selected row). pub fn send_now_click(&self, col: u16, row: u16) -> Option { let rect = self.send_now_rect?; if rect.contains((col, row).into()) { self.send_now_entry_id.or_else(|| self.selected_id()) } else { None } } /// Hit-test the action-row `[cancel]` button (removes the row). pub fn delete_click(&self, col: u16, row: u16) -> Option { let rect = self.delete_button_rect?; if rect.contains((col, row).into()) { self.delete_button_entry_id.or_else(|| self.selected_id()) } else { None } } pub fn update_delete_hover(&mut self, col: u16, row: u16) -> bool { let over = self .delete_button_rect .is_some_and(|r| r.contains((col, row).into())); let new_id = if over { self.delete_button_entry_id.or_else(|| self.selected_id()) } else { None }; if new_id != self.hovered_delete_id { self.hovered_delete_id = new_id; true } else { false } } pub fn clear_delete_hover(&mut self) { self.hovered_delete_id = None; } /// Update whether the mouse is over the `[Interject]` button. Drives the /// button's hover fg color. Returns `true` if it changed (caller redraws). pub fn update_send_now_hover(&mut self, col: u16, row: u16) -> bool { let over = self .send_now_rect .is_some_and(|r| r.contains((col, row).into())); let new_id = if over { self.send_now_entry_id.or_else(|| self.selected_id()) } else { None }; if new_id != self.hovered_send_now_id { self.hovered_send_now_id = new_id; true } else { false } } /// Clear the `[Interject]` hover (mouse left the queue pane). Returns /// `true` if it was previously hovered (caller should redraw). pub fn clear_send_now_hover(&mut self) -> bool { if self.hovered_send_now_id.is_some() { self.hovered_send_now_id = None; true } else { false } } /// Update which row the mouse is hovering over (the row, not just its /// delete button). Drives the action-button reveal on hover. Returns /// `true` if the hovered row changed (caller should redraw). pub fn update_row_hover(&mut self, col: u16, row: u16) -> bool { let new_id = self.row_id_at(col, row); if new_id != self.hovered_row_id { self.hovered_row_id = new_id; true } else { false } } /// Clear the hovered row (mouse left the queue pane). Returns `true` if a /// row was previously hovered (caller should redraw). pub fn clear_row_hover(&mut self) -> bool { if self.hovered_row_id.is_some() { self.hovered_row_id = None; true } else { false } } /// Resolve the entry id at screen position `(col, row)` using the last /// rendered content area and the list layout. `None` when the position is /// outside the rows. fn row_id_at(&self, col: u16, row: u16) -> Option { let inner = self.last_inner?; if !inner.contains((col, row).into()) { return None; } let ry = row.saturating_sub(inner.y) as usize; let vy = self.list_state.scroll_offset() + ry; let idx = self.list_state.layout().item_at_y(vy)?; self.entries.get(idx).map(|e| e.id) } // -- Rendering ----------------------------------------------------------- /// Compute the inner content area for the queue rows. /// /// Indents one column less than scrollback content (which uses /// `ACCENT + block_pad_left`) so the `#N` prefix vertically aligns with the /// turn-status loading indicator rendered just below — it shares this exact /// `ACCENT + block_pad_left - 1` left indent (see `agent_view.rs`). /// /// The right edge is NOT inset: it extends to the full area width so the /// row's right-aligned action buttons (`[cancel]`) and hover bg line up with /// the turn-status line's `[stop]` button just below, which is likewise /// right-aligned to the full width (it insets only on the left). fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect { use crate::scrollback::layout::HorizontalLayout; let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left.saturating_sub(1); Rect { x: area.x + pad_left, y: area.y, width: area.width.saturating_sub(pad_left), height: area.height, } } /// Render the queue pane into the given area. /// /// If `overlay_area` is provided and the selected entry is multiline, /// renders a preview overlay showing the full content. /// pub fn render( &mut self, area: Rect, buf: &mut Buffer, focused: bool, layout_cfg: &LayoutConfig, overlay_area: Option, is_turn_running: bool, ) { // Detect a theme switch and refresh the list chrome style. Its // `selection_bg` (the focused-row highlight) is captured from the // theme's `bg_highlight`; without this it would keep the theme active // at construction (default KigiNight, dark) after the user switches — // painting a dark band on a light KigiDay canvas. let current_theme = Theme::current_kind(); if current_theme != self.last_theme { self.last_theme = current_theme; self.list_style = ListPaneStyle::default(); } let inner = Self::content_area(area, layout_cfg); self.last_inner = Some(inner); if self.entries.is_empty() { return; } // Rebuild styled content with proper width for truncation. // Account for prefix width: "#N " where N is the position (1-based). // Max position determines prefix width: #1-#9 = 3 chars, #10-#99 = 4 chars, etc. let max_pos = self.entries.len(); let prefix_width = 2 + digit_count(max_pos); // "#" + digits + " " let content_width = (inner.width as usize).saturating_sub(prefix_width); for entry in &mut self.entries { entry.rebuild_styled_for_width(content_width as u16); } // When the queue overflows, the ListPane reserves its scrollbar in the // last column of its render area. That column is where the right-aligned // action buttons ([cancel]) sit, so render the list one column wider — // borrowing a column of the right outer padding — to push the scrollbar // just past the buttons instead of underneath them. let list_area = if self.entries.len() as u16 > inner.height { Rect { width: inner.width + 1, ..inner } } else { inner }; self.list_state .prepare_layout(&self.entries, list_area.width, list_area.height); ListPane::new(&self.entries) .focused(focused) .style(self.list_style) .render(list_area, buf, &mut self.list_state); // Hover affordance: paint a dim hover bg on the row under the mouse, // matching the scrollback tool-call hover (`blend(bg_base, bg_dark, 0.5)`). // Selection wins — skip when the hovered row is the focused selection, // which already carries the (stronger) selection bg. let hovered_idx = self .hovered_row_id .and_then(|id| self.entries.iter().position(|e| e.id == id)); let selected_idx = if focused { self.list_state.selected_index() } else { None }; if let Some(idx) = hovered_idx && hovered_idx != selected_idx { // Skip when the hovered row is scrolled outside the viewport (e.g. // a stale hover after a wheel-scroll). A plain saturating_sub would // collapse an above-viewport row to row 0 and mis-paint it. let item_y = self.list_state.layout().virtual_y(idx); if let Some(rel) = item_y.checked_sub(self.list_state.scroll_offset()) && rel < inner.height as usize { let screen_y = inner.y + rel as u16; let theme = Theme::current(); let hover_bg = crate::render::color::blend_color(theme.bg_base, theme.bg_dark, 0.5) .unwrap_or(theme.bg_dark); let row = Rect::new(inner.x, screen_y, inner.width, 1); buf.set_style(row, Style::default().bg(hover_bg)); } } // Action buttons (optional [Send now] then [cancel], right-aligned). They // render for the row under the mouse (hover affordance) or, when the // pane is focused, the selected row. Hover takes precedence so mouse // users can act on any row without focusing/selecting it first. self.send_now_rect = None; self.send_now_entry_id = None; self.delete_button_rect = None; self.delete_button_entry_id = None; let action_idx = self .hovered_row_id .and_then(|id| self.entries.iter().position(|e| e.id == id)) .or_else(|| { if focused { self.list_state.selected_index() } else { None } }); if let Some(idx) = action_idx && let Some(entry) = self.entries.get(idx) { use crate::render::SafeBuf; let theme = Theme::current(); // Skip when the action row is scrolled outside the viewport; a plain // saturating_sub would otherwise bind the buttons to row 0 and // mis-route clicks to an off-screen entry. let item_y = self.list_state.layout().virtual_y(idx); if let Some(rel) = item_y.checked_sub(self.list_state.scroll_offset()) && rel < inner.height as usize { let screen_y = inner.y + rel as u16; let cancel_label = "[cancel]"; let cancel_w = cancel_label.len() as u16; // Compact action wording; same mouse hit-test as before (force-interject). let interject_label = "[Send now]"; let interject_w = interject_label.len() as u16; let btn_style = Style::default().fg(theme.gray); let mut right = inner.x + inner.width; right = right.saturating_sub(cancel_w); let cancel_x = right; let hovered = self.hovered_delete_id == Some(entry.id); let cancel_style = if hovered { Style::default().fg(theme.accent_error) } else { btn_style }; buf.set_string_safe(cancel_x, screen_y, cancel_label, cancel_style); self.delete_button_rect = Some(Rect::new(cancel_x, screen_y, cancel_w, 1)); self.delete_button_entry_id = Some(entry.id); if is_turn_running { // Place [Send now] flush against [cancel] (no gap) — a gap // would let the queued message behind the row leak through // the seam between the two buttons. right = right.saturating_sub(interject_w); let interject_x = right; // Brighten the fg on hover (same hover color as the // [Dashboard] button) so it reads as clickable. let interject_style = if self.hovered_send_now_id == Some(entry.id) { Style::default().fg(theme.text_primary) } else { btn_style }; buf.set_string_safe(interject_x, screen_y, interject_label, interject_style); self.send_now_rect = Some(Rect::new(interject_x, screen_y, interject_w, 1)); self.send_now_entry_id = Some(entry.id); } } } // Multiline preview overlay: show when focused and selected entry has 2+ lines. if focused && let Some(overlay) = overlay_area && let Some(text) = self.selected_text() && text.lines().count() > 1 { let theme = Theme::current(); // Use accent_user for text (matches prompt color in queue list). let style = PreviewStyle::new(theme.bg_dark, theme.accent_user, theme.gray_dim); render_preview_overlay(buf, overlay, text, style, PreviewConfig::default()); } } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Count the number of decimal digits in a number. fn digit_count(n: usize) -> usize { if n == 0 { 1 } else { (n as f64).log10().floor() as usize + 1 } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn wire(id: &str, text: &str, pos: usize) -> QueueEntryWire { QueueEntryWire { id: id.into(), version: 0, owner: None, last_editor: None, kind: "prompt".into(), text: text.into(), position: pos, } } fn local_prompt(id: u64, text: &str) -> QueuedPrompt { QueuedPrompt::plain(id, text, QueueEntryKind::Prompt) } #[test] fn reset_auto_show_edge_allows_requeue_after_external_hide() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "first")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!(pane.overlay.visible); assert_eq!(pane.prev_len, 1); pane.overlay.visible = false; pane.reset_auto_show_edge(); assert_eq!(pane.prev_len, 0); local.clear(); local.push_back(local_prompt(2, "second")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!( pane.overlay.visible, "re-queued prompt must auto-show after external hide" ); assert_eq!(pane.entries.len(), 1); assert_eq!(pane.entries[0].text, "second"); } #[test] fn delete_last_then_requeue_shows_after_empty_sync_without_explicit_reset() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "hi")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!(pane.overlay.visible); local.clear(); pane.overlay.visible = false; pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!(!pane.overlay.visible); assert_eq!(pane.prev_len, 0, "hidden+empty must pin prev_len to 0"); local.push_back(local_prompt(2, "hi2")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!( pane.overlay.visible, "hi2 must auto-show after x-delete of last queued prompt" ); assert_eq!(pane.entries[0].text, "hi2"); } #[test] fn stale_prev_len_same_count_needs_reset_or_empty_frame() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "hi")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); local.clear(); pane.overlay.visible = false; // BUG state without fix: prev_len left at 1, no empty sync, swap hi→hi2. pane.prev_len = 1; local.push_back(local_prompt(2, "hi2")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!( !pane.overlay.visible, "without reset/empty frame, same-count requeue stays hidden (documents bug)" ); pane.reset_auto_show_edge(); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert!(pane.overlay.visible, "reset then sync must show hi2"); } /// Server-authoritative rows render as interim queue rows, and the /// in-flight (running) prompt is excluded from the list. #[test] fn sync_from_merged_renders_server_rows_and_excludes_running() { let mut pane = QueuePane::new(); let server = vec![wire("p1", "first", 0), wire("p2", "second", 1)]; pane.sync_from_merged( &std::collections::VecDeque::new(), &server, None, None, &Default::default(), ); assert_eq!(pane.entries.len(), 2); assert_eq!(pane.entries[0].text, "first"); assert_eq!(pane.entries[0].position, 1); let r0 = pane.row_ref(pane.entries[0].id).unwrap(); assert_eq!(r0.origin, QueueRowOrigin::Server); assert_eq!(r0.server_id.as_deref(), Some("p1")); // Once p1 is the running turn it must NOT appear as a queued row. pane.sync_from_merged( &std::collections::VecDeque::new(), &server, Some("p1"), None, &Default::default(), ); assert_eq!(pane.entries.len(), 1); assert_eq!(pane.entries[0].text, "second"); assert_eq!(pane.entries[0].position, 1); assert_eq!( pane.row_ref(pane.entries[0].id) .unwrap() .server_id .as_deref(), Some("p2") ); } #[test] fn sync_from_merged_excludes_send_now_echo() { let mut pane = QueuePane::new(); let server = vec![ wire("running", "active turn", 0), wire("send-now", "just fired", 1), wire("held", "still waiting", 2), ]; pane.sync_from_merged( &std::collections::VecDeque::new(), &server, Some("running"), Some("send-now"), &Default::default(), ); assert_eq!(pane.entries.len(), 1); assert_eq!(pane.entries[0].text, "still waiting"); assert_eq!( pane.row_ref(pane.entries[0].id) .unwrap() .server_id .as_deref(), Some("held") ); assert!(visible_held_server_row( "held", Some("running"), Some("send-now"), &Default::default() )); assert!(!visible_held_server_row( "send-now", Some("running"), Some("send-now"), &Default::default() )); let mut painted = std::collections::HashMap::new(); painted.insert( "painted".to_string(), (crate::scrollback::EntryId::new(1), false), ); assert!(!visible_held_server_row("painted", None, None, &painted)); } /// The union is rendered server-first then local, each row tagged /// with its origin so edits route correctly. #[test] fn sync_from_merged_tags_origin_and_orders_server_first() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(7, "local one")); let server = vec![wire("p1", "server one", 0)]; pane.sync_from_merged(&local, &server, None, None, &Default::default()); assert_eq!(pane.entries.len(), 2); // Server row first. assert_eq!( pane.row_ref(pane.entries[0].id).unwrap().origin, QueueRowOrigin::Server ); assert_eq!(pane.entries[0].text, "server one"); // Local row second, keeping its u64 id and Local origin. assert_eq!(pane.entries[1].id, 7); let r1 = pane.row_ref(7).unwrap(); assert_eq!(r1.origin, QueueRowOrigin::Local); assert_eq!(r1.server_id, None); } /// `select_after_delete` picks the neighbor from the *merged* rows, so a /// trailing local row's neighbor can be a server row across the boundary /// (regression: indexing the local-only queue with a merged position /// re-selected the wrong — often the just-deleted — row). Each step mirrors /// the production cycle: mutate the backing queue, then re-sync. #[test] fn select_after_delete_picks_neighbor_across_merge_boundary() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(7, "local one")); let mut server = vec![wire("p1", "server one", 0), wire("p2", "server two", 1)]; pane.sync_from_merged(&local, &server, None, None, &Default::default()); // entries: [server p1, server p2, local 7] let ids = pane.entry_ids(); assert_eq!(ids.len(), 3); let (p1_id, p2_id, local_id) = (ids[0], ids[1], ids[2]); // Delete the trailing local row → clamps to the new last row, which is // the *server* row p2 (across the boundary), not a local row. pane.list_state.select_by_id(local_id); pane.select_after_delete(local_id); assert_eq!(pane.selected_id(), Some(p2_id)); // Apply the deletion to the backing queue and re-sync before the next. local.clear(); pane.sync_from_merged(&local, &server, None, None, &Default::default()); assert_eq!(pane.entry_ids(), vec![p1_id, p2_id]); assert_eq!(pane.selected_id(), Some(p2_id)); // Delete the leading server row → the next row slides up into the slot. pane.list_state.select_by_id(p1_id); pane.select_after_delete(p1_id); assert_eq!(pane.selected_id(), Some(p2_id)); // Re-sync: only p2 remains, still selected. server.remove(0); pane.sync_from_merged(&local, &server, None, None, &Default::default()); assert_eq!(pane.entry_ids(), vec![p2_id]); assert_eq!(pane.selected_id(), Some(p2_id)); } /// Purely-local queue (no server rows): deleting the middle row selects the /// following row; deleting the last clamps to the new last; deleting the /// only row is a no-op (selection unchanged). #[test] fn select_after_delete_local_only_neighbor_selection() { let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "one")); local.push_back(local_prompt(2, "two")); local.push_back(local_prompt(3, "three")); let mut pane = QueuePane::new(); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert_eq!(pane.entry_ids(), vec![1, 2, 3]); // Delete the middle row → the following row (3) slides into the slot. pane.list_state.select_by_id(2); pane.select_after_delete(2); assert_eq!(pane.selected_id(), Some(3)); // Apply + re-sync, then delete the last row → clamp to the new last (1). local.retain(|p| p.id != 2); pane.sync_from_merged(&local, &[], None, None, &Default::default()); assert_eq!(pane.entry_ids(), vec![1, 3]); pane.list_state.select_by_id(3); pane.select_after_delete(3); assert_eq!(pane.selected_id(), Some(1)); // Deleting the sole remaining row leaves the selection unchanged. let mut single = QueuePane::new(); let mut one = std::collections::VecDeque::new(); one.push_back(local_prompt(9, "only")); single.sync_from_merged(&one, &[], None, None, &Default::default()); single.list_state.select_by_id(9); single.select_after_delete(9); assert_eq!(single.selected_id(), Some(9)); } #[test] fn test_digit_count() { assert_eq!(digit_count(0), 1); assert_eq!(digit_count(1), 1); assert_eq!(digit_count(9), 1); assert_eq!(digit_count(10), 2); assert_eq!(digit_count(99), 2); assert_eq!(digit_count(100), 3); assert_eq!(digit_count(999), 3); } #[test] fn test_single_line_no_suffix() { let styled = QueuedPromptEntry::build_styled("hello world", 1, QueueEntryKind::Prompt, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(text, "hello world"); assert!(!text.contains("line")); } #[test] fn test_multiline_suffix() { let styled = QueuedPromptEntry::build_styled("first line", 5, QueueEntryKind::Prompt, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("first line")); assert!(text.contains("(+4 lines)")); } #[test] fn test_multiline_singular() { let styled = QueuedPromptEntry::build_styled("first line", 2, QueueEntryKind::Prompt, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("(+1 line)")); assert!(!text.contains("lines)")); // Should be singular } #[test] fn test_truncation_preserves_suffix() { // Width of 25: "first line" (10) + " (+4 lines)" (11) = 21, fits let styled = QueuedPromptEntry::build_styled("first line", 5, QueueEntryKind::Prompt, Some(25)); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("first line")); assert!(text.contains("(+4 lines)")); // Width of 20: Need to truncate "first line" to fit suffix let styled = QueuedPromptEntry::build_styled("first line here", 5, QueueEntryKind::Prompt, Some(20)); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); // Should have truncated first line but kept suffix assert!(text.contains("(+4 lines)")); // First line should be truncated (with ellipsis) assert!(text.contains("…") || text.len() <= 20); } #[test] fn test_very_narrow_width() { // Extremely narrow - suffix takes most of the space let styled = QueuedPromptEntry::build_styled( "hello world", 10, QueueEntryKind::Prompt, Some(15), // " (+9 lines)" is 11 chars, leaving 4 for content ); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("(+9 lines)")); } #[test] fn test_command_with_suffix() { let styled = QueuedPromptEntry::build_styled("/help me", 3, QueueEntryKind::Command, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("/help")); assert!(text.contains("(+2 lines)")); } // -- Bash command queue pane tests -- #[test] fn test_bash_command_has_bang_prefix() { let styled = QueuedPromptEntry::build_styled("ls -la", 1, QueueEntryKind::BashCommand, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( text.starts_with("! "), "bash entry should start with '! ', got: {text}" ); assert!(text.contains("ls -la")); } #[test] fn test_bash_command_multiline_suffix() { let styled = QueuedPromptEntry::build_styled("echo hello", 3, QueueEntryKind::BashCommand, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.starts_with("! ")); assert!(text.contains("(+2 lines)")); } // -- Cron queue pane tests -- #[test] fn test_cron_has_recycle_prefix() { let styled = QueuedPromptEntry::build_styled("check status", 1, QueueEntryKind::Cron, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!( text.starts_with("\u{21BB} "), "cron entry should start with \u{21BB}, got: {text}" ); assert!(text.contains("check status")); } #[test] fn test_cron_multiline_suffix() { let styled = QueuedPromptEntry::build_styled("/pr-babysit check", 4, QueueEntryKind::Cron, None); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.starts_with("\u{21BB} ")); assert!(text.contains("(+3 lines)")); } #[test] fn test_cron_truncation() { let styled = QueuedPromptEntry::build_styled( "very long scheduled prompt", 1, QueueEntryKind::Cron, Some(15), ); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.starts_with("\u{21BB} ")); } #[test] fn test_bash_command_truncation() { let styled = QueuedPromptEntry::build_styled( "very long command here", 1, QueueEntryKind::BashCommand, Some(15), ); let text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.starts_with("! ")); // Total should fit within width (prefix "! " is 2 chars, content truncated to 13) } // -- Action-button rendering (hover + layout) ---------------------------- /// The `[Interject]` and `[cancel]` buttons render flush against each other /// so the queued message behind the row can't leak through a seam between /// them (no gap). #[test] fn action_buttons_render_flush_with_no_gap() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt( 1, "a queued message long enough to fill the entire row width", )); pane.sync_from_merged(&local, &[], None, None, &Default::default()); let ids = pane.entry_ids(); pane.list_state.select_by_id(ids[0]); let area = Rect::new(0, 0, 80, 1); let mut buf = Buffer::empty(area); let layout_cfg = crate::appearance::LayoutConfig::default(); // Focused + turn running → both buttons render for the selected row. pane.render(area, &mut buf, true, &layout_cfg, None, true); let interject = pane.send_now_rect.expect("interject button renders"); let delete = pane.delete_button_rect.expect("delete button renders"); assert_eq!( interject.x + interject.width, delete.x, "[Interject] must sit flush against [cancel] (no gap to leak through)" ); } /// Hovering a row reveals that row's action buttons even when the pane is /// unfocused, and the buttons are bound to the hovered row. #[test] fn hover_reveals_action_buttons_for_hovered_row_when_unfocused() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "first")); local.push_back(local_prompt(2, "second")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); let area = Rect::new(0, 0, 80, 2); let mut buf = Buffer::empty(area); let layout_cfg = crate::appearance::LayoutConfig::default(); // Unfocused with no hover → no action buttons at all. pane.render(area, &mut buf, false, &layout_cfg, None, true); assert!(pane.delete_button_rect.is_none()); assert!(pane.send_now_rect.is_none()); // Hover the second row → its buttons appear (still unfocused). let inner = pane.last_inner.expect("inner area recorded during render"); assert!( pane.update_row_hover(inner.x, inner.y + 1), "hovering a new row should report a change" ); pane.render(area, &mut buf, false, &layout_cfg, None, true); let ids = pane.entry_ids(); assert_eq!(pane.delete_button_entry_id, Some(ids[1])); assert_eq!(pane.send_now_entry_id, Some(ids[1])); assert!(pane.delete_button_rect.is_some()); assert!(pane.send_now_rect.is_some()); // Moving off the rows clears the hover and hides the buttons again. assert!(pane.clear_row_hover()); pane.render(area, &mut buf, false, &layout_cfg, None, true); assert!(pane.delete_button_rect.is_none()); assert!(pane.send_now_rect.is_none()); } /// Hovering a row paints the dim hover bg across that row (matching the /// scrollback tool-call hover), and only that row. #[test] fn hover_paints_dim_hover_bg_on_hovered_row() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "first")); local.push_back(local_prompt(2, "second")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); let area = Rect::new(0, 0, 80, 2); let mut buf = Buffer::empty(area); let layout_cfg = crate::appearance::LayoutConfig::default(); // First render records `last_inner`; then hover the second row. pane.render(area, &mut buf, false, &layout_cfg, None, true); let inner = pane.last_inner.expect("inner area recorded during render"); assert!(pane.update_row_hover(inner.x, inner.y + 1)); pane.render(area, &mut buf, false, &layout_cfg, None, true); let theme = Theme::current(); let hover_bg = crate::render::color::blend_color(theme.bg_base, theme.bg_dark, 0.5) .unwrap_or(theme.bg_dark); // Hovered (second) row carries the hover bg across its full width. assert_eq!(buf[(inner.x, inner.y + 1)].bg, hover_bg); assert_eq!( buf[(inner.x + inner.width - 1, inner.y + 1)].bg, hover_bg, "hover bg spans the full row width" ); // When the terminal has real colors the non-hovered row is visibly // distinct. In headless tests every bg quantizes to `Reset`, so the // difference is only asserted when the hover bg is a real color. if hover_bg != ratatui::style::Color::Reset { assert_ne!(buf[(inner.x, inner.y)].bg, hover_bg); } } /// The `[Interject]` button brightens its fg to `text_primary` on hover /// (the same hover color as the `[Dashboard]` button) and is plain `gray` /// otherwise. #[test] fn interject_button_brightens_fg_on_hover() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "first")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); let ids = pane.entry_ids(); pane.list_state.select_by_id(ids[0]); let area = Rect::new(0, 0, 80, 1); let mut buf = Buffer::empty(area); let layout_cfg = crate::appearance::LayoutConfig::default(); let theme = Theme::current(); // Focused + turn running → [Interject] renders for the selected row. pane.render(area, &mut buf, true, &layout_cfg, None, true); let rect = pane.send_now_rect.expect("interject button renders"); let non_hover_fg = buf[(rect.x, rect.y)].fg; assert_eq!(non_hover_fg, theme.gray, "plain button uses gray fg"); // Hover the [Interject] button → fg becomes text_primary. assert!(pane.update_send_now_hover(rect.x, rect.y)); pane.render(area, &mut buf, true, &layout_cfg, None, true); let rect = pane.send_now_rect.expect("interject button still renders"); for x in rect.x..rect.x + rect.width { assert_eq!(buf[(x, rect.y)].fg, theme.text_primary, "col {x}"); } // With real colors the hover fg is visibly distinct from the plain fg // (in headless tests every color quantizes to `Reset`). if theme.text_primary != theme.gray { assert_ne!(buf[(rect.x, rect.y)].fg, non_hover_fg); } } /// Regression: a stale hover on a row that has scrolled *above* the /// viewport must not bind the action buttons to row 0 (which would /// mis-route a click to an off-screen entry). The bounds check skips it. #[test] fn hovered_row_scrolled_above_viewport_binds_no_buttons() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); for i in 1..=5 { local.push_back(local_prompt(i, &format!("row {i}"))); } pane.sync_from_merged(&local, &[], None, None, &Default::default()); let ids = pane.entry_ids(); // Viewport height 3 (MAX_QUEUE_HEIGHT) with 5 rows → scrollable. The // buffer is wider than the queue area (mirroring the outer padding) so // the overflow scrollbar's column, one past the area, stays in-bounds. let area = Rect::new(0, 0, 80, 3); let mut buf = Buffer::empty(Rect::new(0, 0, 82, 3)); let layout_cfg = crate::appearance::LayoutConfig::default(); // Establish layout, then scroll so rows 0 and 1 sit above the top and // leave a stale hover on the now-off-screen first row. pane.render(area, &mut buf, true, &layout_cfg, None, true); pane.list_state.set_scroll_offset(2); pane.hovered_row_id = Some(ids[0]); pane.render(area, &mut buf, true, &layout_cfg, None, true); assert!( pane.delete_button_rect.is_none(), "off-screen hovered row must not bind the cancel button to row 0" ); assert!( pane.send_now_rect.is_none(), "off-screen hovered row must not bind the interject button to row 0" ); assert_eq!(pane.delete_button_entry_id, None); assert_eq!(pane.send_now_entry_id, None); } /// The `[cancel]` button's right edge reaches the full content-area width /// (no right inset) so it lines up with the turn-status `[stop]` button on /// the line below, which is right-aligned to the same full width. #[test] fn cancel_button_right_edge_reaches_full_width() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); local.push_back(local_prompt(1, "msg")); pane.sync_from_merged(&local, &[], None, None, &Default::default()); let ids = pane.entry_ids(); pane.list_state.select_by_id(ids[0]); let area = Rect::new(0, 0, 80, 1); let mut buf = Buffer::empty(area); let layout_cfg = crate::appearance::LayoutConfig::default(); pane.render(area, &mut buf, true, &layout_cfg, None, true); let cancel = pane.delete_button_rect.expect("cancel button renders"); assert_eq!( cancel.x + cancel.width, area.x + area.width, "[cancel] right edge must reach the full width to align with [stop]" ); } /// When the queue overflows, the scrollbar is pushed one column past the /// queue area (into the right outer padding) so it sits clear of the /// right-aligned `[cancel]` button instead of overlapping it. #[test] fn scrollbar_sits_clear_of_action_buttons() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); for i in 1..=5 { local.push_back(local_prompt(i, &format!("row {i}"))); } pane.sync_from_merged(&local, &[], None, None, &Default::default()); let ids = pane.entry_ids(); pane.list_state.select_by_id(ids[0]); // Queue area is 80 wide; the buffer is wider (outer padding) so the // scrollbar column just past the area is in-bounds. let area = Rect::new(0, 0, 80, 3); let mut buf = Buffer::empty(Rect::new(0, 0, 82, 3)); let layout_cfg = crate::appearance::LayoutConfig::default(); pane.render(area, &mut buf, true, &layout_cfg, None, true); let sb = pane .list_state .scrollbar_area() .expect("scrollbar shown for 5 rows in a height-3 pane"); let cancel = pane.delete_button_rect.expect("cancel button renders"); assert!( sb.x >= cancel.x + cancel.width, "scrollbar (x={}) must sit clear of [cancel] (right edge {})", sb.x, cancel.x + cancel.width ); } /// Wheel-scrolling refreshes the hovered row from the cursor's current /// position (rows move under a stationary pointer), so the hover bg and /// action buttons don't keep tracking the pre-scroll entry until the next /// mouse move. #[test] fn wheel_scroll_refreshes_hovered_row_without_mouse_move() { let mut pane = QueuePane::new(); let mut local = std::collections::VecDeque::new(); for i in 1..=5 { local.push_back(local_prompt(i, &format!("row {i}"))); } pane.sync_from_merged(&local, &[], None, None, &Default::default()); let ids = pane.entry_ids(); // 5 rows in a height-3 pane → scrollable. Buffer is wider than the area // so the overflow scrollbar column stays in-bounds. let area = Rect::new(0, 0, 80, 3); let mut buf = Buffer::empty(Rect::new(0, 0, 82, 3)); let layout_cfg = crate::appearance::LayoutConfig::default(); pane.render(area, &mut buf, false, &layout_cfg, None, true); let inner = pane.last_inner.expect("inner area recorded during render"); // Hover the top visible row → the first entry. assert!(pane.update_row_hover(inner.x, inner.y)); assert_eq!(pane.hovered_row_id, Some(ids[0])); // Wheel-scroll down one line with the cursor held over the top row. // The entry now under the pointer is the second one, and hover must // refresh to it WITHOUT a separate mouse-move event. pane.handle_scroll(1, inner.x, inner.y); assert_eq!( pane.hovered_row_id, Some(ids[1]), "scroll must refresh the hovered row to the entry now under the cursor" ); } }