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.
1715 lines
67 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|