Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/queue_pane.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1715 lines
67 KiB
Rust

//! 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<String, (crate::scrollback::EntryId, bool)>,
) -> 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<String>,
/// 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<String>,
/// 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<usize>,
) -> 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<Line<'_>> {
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<QueuedPromptEntry>,
/// 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<Rect>,
send_now_entry_id: Option<u64>,
/// 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<u64>,
delete_button_rect: Option<Rect>,
delete_button_entry_id: Option<u64>,
pub(crate) hovered_delete_id: Option<u64>,
/// 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<u64>,
/// Content area from the last `render`, used to hit-test which row the
/// mouse is over (for `hovered_row_id`).
last_inner: Option<Rect>,
}
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<QueuedPrompt>,
server: &[QueueEntryWire],
running_id: Option<&str>,
send_now_id: Option<&str>,
painted_pending: &std::collections::HashMap<String, (crate::scrollback::EntryId, bool)>,
) {
// 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<u64> {
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<QueueRowRef> {
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<QueueEvent> {
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<u64> {
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<u64> {
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<u64> {
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<u64> {
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<Rect>,
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"
);
}
}