Changes: - Stop hooks for session lifecycle - Add x.ai/session/state and x.ai/session/import ACP methods - Deny-and-continue for auto-mode classifier blocks with denial limits - Drop codebase-upload from dhat soak test - scheduler_create upsert via task_id; retire one-shot tasks - Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal - Polarity-safe syntax colors in minimal mode - Auto mode classifies unvetted env prefixes instead of hard-prompting - Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
3155 lines
112 KiB
Rust
3155 lines
112 KiB
Rust
//! Combined tasks pane — unified overlay panel showing both background tasks
|
|
//! and subagents in a single interleaved list.
|
|
//!
|
|
//! Replaces the separate `BgTaskPane` and `SubagentPane`. Items are sorted
|
|
//! running-first, then by start time (newest first). Each entry dispatches
|
|
//! to the correct action type (kill task vs kill agent, view output vs view
|
|
//! session) based on its variant.
|
|
|
|
use crossterm::event::{KeyEvent, MouseEventKind};
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::StatefulWidget;
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::time::{Instant, SystemTime};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::app::agent::{BgTaskState, BgTaskStatus, ScheduledTaskInfo};
|
|
use crate::app::subagent::{SubagentInfo, format_context_badge, format_subagent_label};
|
|
use crate::appearance::LayoutConfig;
|
|
use crate::scrollback::layout::HorizontalLayout;
|
|
use crate::syntax::get_syntect;
|
|
use crate::theme::{Theme, ThemeKind};
|
|
use crate::util::format_duration;
|
|
use chrono::{DateTime, Utc};
|
|
|
|
use super::list_pane::{
|
|
ListItem, ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode,
|
|
};
|
|
use super::overlay::OverlayState;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spinner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SPINNER_DIVISOR: u64 = 4;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shell command syntax highlighting (used by other modules too)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Highlight a shell command string into styled spans.
|
|
///
|
|
/// Uses syntect with the best available grammar for the platform: tries
|
|
/// "powershell" first on Windows, falls back to "bash". Returns plain
|
|
/// `theme.command` color if no grammar matches. Results should be cached.
|
|
pub fn highlight_bash_command(command: &str) -> Vec<Span<'static>> {
|
|
let syntect = get_syntect();
|
|
let grammar = if cfg!(windows) { "powershell" } else { "bash" };
|
|
let Some(mut hl) = syntect
|
|
.highlight_lines_for_token(grammar)
|
|
.or_else(|| syntect.highlight_lines_for_token("bash"))
|
|
else {
|
|
let theme = Theme::current();
|
|
return vec![Span::styled(
|
|
command.to_string(),
|
|
Style::default().fg(theme.command),
|
|
)];
|
|
};
|
|
|
|
let line = format!("{command}\n");
|
|
match hl.highlight_line(&line, &syntect.syntax_set) {
|
|
Ok(ranges) => {
|
|
let mut spans = Vec::new();
|
|
for (style, segment) in ranges {
|
|
let mut text = segment.to_owned();
|
|
while text.ends_with('\n') || text.ends_with('\r') {
|
|
text.pop();
|
|
}
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
// Raw syntect RGB here used to bypass quantization and leak
|
|
// polarity-tuned tmTheme colors into minimal.
|
|
spans.push(Span::styled(
|
|
text,
|
|
crate::syntax::syntect_to_ratatui_fg(style),
|
|
));
|
|
}
|
|
if spans.is_empty() {
|
|
let theme = Theme::current();
|
|
vec![Span::styled(
|
|
command.to_string(),
|
|
Style::default().fg(theme.command),
|
|
)]
|
|
} else {
|
|
spans
|
|
}
|
|
}
|
|
Err(_) => {
|
|
let theme = Theme::current();
|
|
vec![Span::styled(
|
|
command.to_string(),
|
|
Style::default().fg(theme.command),
|
|
)]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dim highlighted spans by blending each color toward background.
|
|
fn dim_spans(spans: &[Span<'static>], blend_factor: f32) -> Vec<Span<'static>> {
|
|
let theme = Theme::current();
|
|
spans
|
|
.iter()
|
|
.map(|span| {
|
|
let fg = span
|
|
.style
|
|
.fg
|
|
.and_then(|fg| crate::render::color::blend_color(theme.bg_base, fg, blend_factor))
|
|
.or(span.style.fg);
|
|
let style = Style::default().fg(fg.unwrap_or(theme.gray));
|
|
Span::styled(span.content.clone(), style)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Line count badge formatting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Format an stdout line count as a compact `(N)` badge with SI scaling.
|
|
///
|
|
/// Returns an empty string for `0` so callers can treat that as "no badge".
|
|
/// Truncation (not rounding) is used throughout so the badge never
|
|
/// overstates the count — e.g. `1999` renders as `(1.9k)` (not `(2.0k)`),
|
|
/// `999_999` renders as `(999k)` (not `(1.0M)`), and `9_999_999` renders as
|
|
/// `(9.9M)` (not `(10M)`). Each branch boundary is exact: `1_000_000` is the
|
|
/// first count to render with an `M` suffix.
|
|
///
|
|
/// When `truncated` is `true`, a `+` is inserted before the closing paren
|
|
/// (`(2.0k+)`) to signal "at least this many" — the rolling buffer has
|
|
/// dropped data so the real total is larger than `count`.
|
|
///
|
|
/// - `<1000`: `(42)`, `(999)`
|
|
/// - `<10_000`: `(1.0k)`, `(9.9k)` — one decimal
|
|
/// - `<1M`: `(10k)`, `(999k)` — whole thousands
|
|
/// - `<10M`: `(1.0M)`, `(9.9M)` — one decimal
|
|
/// - `≥10M`: `(10M)`, `(999M)` — whole millions
|
|
fn format_line_count_badge(count: usize, truncated: bool) -> String {
|
|
if count == 0 {
|
|
return String::new();
|
|
}
|
|
let suffix = if truncated { "+" } else { "" };
|
|
if count < 1_000 {
|
|
return format!("({count}{suffix})");
|
|
}
|
|
if count < 10_000 {
|
|
let tenths = count / 100;
|
|
return format!("({}.{}k{suffix})", tenths / 10, tenths % 10);
|
|
}
|
|
if count < 1_000_000 {
|
|
return format!("({}k{suffix})", count / 1_000);
|
|
}
|
|
if count < 10_000_000 {
|
|
let tenths = count / 100_000;
|
|
return format!("({}.{}M{suffix})", tenths / 10, tenths % 10);
|
|
}
|
|
format!("({}M{suffix})", count / 1_000_000)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TaskEntryId — identifies which entry a button belongs to
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum TaskEntryId {
|
|
BgTask(String),
|
|
Agent(String),
|
|
Scheduled(String),
|
|
}
|
|
|
|
/// Logical group a [`TaskEntry`] belongs to. Drives both the sort order (so
|
|
/// each kind is contiguous) and the collapsible group headers.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum GroupKind {
|
|
Subagents,
|
|
Tasks,
|
|
/// Recurring background processes: `monitor` tasks and `/loop` scheduled
|
|
/// tasks share one section. They stay contiguous (monitors first, then
|
|
/// loops) via [`TaskEntry::type_order`].
|
|
Watchers,
|
|
}
|
|
|
|
impl GroupKind {
|
|
/// Display label shown in the group header.
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
GroupKind::Subagents => "Subagents",
|
|
GroupKind::Tasks => "Tasks",
|
|
GroupKind::Watchers => "Watchers",
|
|
}
|
|
}
|
|
|
|
/// Sort/render order: subagents → tasks → watchers (monitors + loops).
|
|
fn order(self) -> u8 {
|
|
match self {
|
|
GroupKind::Subagents => 0,
|
|
GroupKind::Tasks => 1,
|
|
GroupKind::Watchers => 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TaskEntry — unified entry for the combined list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum TaskEntry {
|
|
BgTask {
|
|
id: u64,
|
|
task_id: String,
|
|
label: String,
|
|
styled: Line<'static>,
|
|
running: bool,
|
|
start_time: SystemTime,
|
|
/// True for `monitor` tool tasks. Used to sort monitors into their
|
|
/// own contiguous group (separate from one-shot bg commands).
|
|
is_monitor: bool,
|
|
},
|
|
Agent {
|
|
id: u64,
|
|
subagent_id: String,
|
|
child_session_id: String,
|
|
label: String,
|
|
styled: Line<'static>,
|
|
running: bool,
|
|
started_at: Instant,
|
|
/// Capitalized agent-type / persona label (e.g. `Explore`, `Plan`,
|
|
/// `General`). Used to order subagents by type within their group.
|
|
type_label: String,
|
|
},
|
|
Scheduled {
|
|
id: u64,
|
|
task_id: String,
|
|
label: String,
|
|
styled: Line<'static>,
|
|
started_at: Instant,
|
|
linked_subagent: Option<String>,
|
|
},
|
|
/// Collapsible group header row (e.g. `▾ Subagents 2`). Not a task —
|
|
/// selecting it and pressing Enter (or clicking it) toggles the group's
|
|
/// collapse state.
|
|
Header {
|
|
group: GroupKind,
|
|
styled: Line<'static>,
|
|
},
|
|
}
|
|
|
|
impl TaskEntry {
|
|
fn from_bg_task(
|
|
task: &BgTaskState,
|
|
highlight_cache: &mut HashMap<String, Vec<Span<'static>>>,
|
|
) -> Self {
|
|
// Prefer the tool call's description over the raw command for the
|
|
// pane label. The full command is always available via the block
|
|
// viewer (preamble of the BgTaskBlock).
|
|
let description = task
|
|
.description
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty());
|
|
|
|
let running = task.status == BgTaskStatus::Running;
|
|
let (label, styled) = if task.is_monitor {
|
|
// Monitor: blue "Monitor" tag + neutral description, mirroring
|
|
// scheduled `/loop` rows. Falls back to the command if the
|
|
// description is somehow empty. The description (not the raw
|
|
// command) is what we show, so it never gets bash-highlighted.
|
|
let theme = Theme::current();
|
|
let text = description
|
|
.map(|d| d.replace('\n', " "))
|
|
.unwrap_or_else(|| task.command.trim().replace('\n', " "));
|
|
const TAG: &str = "Monitor";
|
|
let desc_style = if running {
|
|
Style::default().fg(theme.text_secondary)
|
|
} else {
|
|
Style::default().fg(theme.gray_bright)
|
|
};
|
|
let label = format!("{TAG} {text}");
|
|
let styled = Line::from(vec![
|
|
Span::styled(format!("{TAG} "), Style::default().fg(theme.accent_system)),
|
|
Span::styled(text, desc_style),
|
|
]);
|
|
(label, styled)
|
|
} else if let Some(desc) = description {
|
|
// Collapse newlines so multi-line descriptions render on one row.
|
|
let one_line = desc.replace('\n', " ");
|
|
let theme = Theme::current();
|
|
// Prefix the description with a constant `Task` tag in the
|
|
// theme's secondary text color so the entry type is identifiable
|
|
// at a glance, the same way subagent rows lead with their
|
|
// persona/role label. The prefix is included in `label` so it
|
|
// is searchable (the tasks-pane filter matches against `label`).
|
|
const PREFIX: &str = "Task ";
|
|
let desc_style = if running {
|
|
Style::default().fg(theme.text_primary)
|
|
} else {
|
|
Style::default().fg(theme.gray_bright)
|
|
};
|
|
let label = format!("{PREFIX}{one_line}");
|
|
let styled = Line::from(vec![
|
|
Span::styled(PREFIX, Style::default().fg(theme.text_secondary)),
|
|
Span::styled(one_line, desc_style),
|
|
]);
|
|
(label, styled)
|
|
} else {
|
|
let trimmed = task.command.trim();
|
|
let label = if let Some(nl) = trimmed.find('\n') {
|
|
let first_line = trimmed[..nl].trim_end();
|
|
format!("{first_line}\u{2026}")
|
|
} else {
|
|
trimmed.to_string()
|
|
};
|
|
|
|
let base_spans = highlight_cache
|
|
.entry(label.clone())
|
|
.or_insert_with(|| highlight_bash_command(&label))
|
|
.clone();
|
|
|
|
let spans = if running {
|
|
base_spans
|
|
} else {
|
|
dim_spans(&base_spans, 0.45)
|
|
};
|
|
(label, Line::from(spans))
|
|
};
|
|
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
task.task_id.hash(&mut hasher);
|
|
let id = hasher.finish();
|
|
|
|
TaskEntry::BgTask {
|
|
id,
|
|
task_id: task.task_id.clone(),
|
|
label,
|
|
styled,
|
|
running,
|
|
start_time: task.start_time,
|
|
is_monitor: task.is_monitor,
|
|
}
|
|
}
|
|
|
|
fn from_subagent(info: &SubagentInfo) -> Self {
|
|
let theme = Theme::current();
|
|
|
|
// Single consolidated label (persona > role > subagent_type > tag >
|
|
// "general") plus description with any `[tag]` prefix stripped.
|
|
let (type_label, description) = format_subagent_label(info);
|
|
let model_suffix = info
|
|
.model
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or("");
|
|
|
|
// Label color is state-driven: pending_kill / running stay vivid;
|
|
// completed / failed keep their hue (green / red) but blend toward
|
|
// the background so finished entries recede without losing their
|
|
// success-vs-failure signal.
|
|
let raw_type_color = if info.pending_kill {
|
|
theme.accent_error
|
|
} else if info.is_running() {
|
|
theme.accent_running
|
|
} else if info.status.as_deref() == Some("completed") {
|
|
theme.accent_success
|
|
} else {
|
|
theme.accent_error
|
|
};
|
|
let type_color = if info.is_running() || info.pending_kill {
|
|
raw_type_color
|
|
} else {
|
|
crate::render::color::blend_color(theme.bg_base, raw_type_color, 0.45)
|
|
.unwrap_or(raw_type_color)
|
|
};
|
|
let type_style = Style::default().fg(type_color);
|
|
let desc_style = if info.is_running() {
|
|
Style::default().fg(theme.text_primary)
|
|
} else {
|
|
Style::default().fg(theme.gray_bright)
|
|
};
|
|
|
|
// Live activity suffix, running rows only. The description is capped
|
|
// so the live part survives typical pane widths (the right overlay's
|
|
// end-truncation remains the final safety net); the suffix stays out
|
|
// of `label` so filter matches don't flicker as activity changes.
|
|
const ACTIVITY_DESC_MAX_WIDTH: usize = 40;
|
|
let activity = info
|
|
.is_running()
|
|
.then_some(info.activity_label.as_deref())
|
|
.flatten()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty());
|
|
let shown_desc = match activity {
|
|
Some(_) => {
|
|
crate::render::line_utils::truncate_str(&description, ACTIVITY_DESC_MAX_WIDTH)
|
|
}
|
|
None => description.clone(),
|
|
};
|
|
|
|
// Skip the trailing-space separator when the cleaned description is
|
|
// empty (reachable when `info.description == "[tag]"`); otherwise we
|
|
// render `"Tag "` with a stray trailing space.
|
|
//
|
|
// The model is NOT rendered inline here — it's drawn right-aligned in
|
|
// the overlay (just to the left of the elapsed/duration). The label
|
|
// string below still includes the model so it remains searchable.
|
|
let type_sep = if description.is_empty() { "" } else { " " };
|
|
let mut spans = vec![
|
|
Span::styled(format!("{type_label}{type_sep}"), type_style),
|
|
Span::styled(shown_desc, desc_style),
|
|
];
|
|
if let Some(activity) = activity {
|
|
spans.push(Span::styled(
|
|
format!(" \u{2014} {activity}"),
|
|
Style::default().fg(theme.gray),
|
|
));
|
|
}
|
|
|
|
let label = match (description.is_empty(), model_suffix.is_empty()) {
|
|
(true, true) => type_label.clone(),
|
|
(true, false) => format!("{type_label} {model_suffix}"),
|
|
(false, true) => format!("{type_label} {description}"),
|
|
(false, false) => format!("{type_label} {description} {model_suffix}"),
|
|
};
|
|
let styled = Line::from(spans);
|
|
|
|
// Use a different hash namespace to avoid collisions with bg tasks
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
"agent:".hash(&mut hasher);
|
|
info.child_session_id.hash(&mut hasher);
|
|
let id = hasher.finish();
|
|
|
|
TaskEntry::Agent {
|
|
id,
|
|
subagent_id: info.subagent_id.to_string(),
|
|
child_session_id: info.child_session_id.to_string(),
|
|
label,
|
|
styled,
|
|
running: info.is_running(),
|
|
started_at: info.started_at,
|
|
type_label,
|
|
}
|
|
}
|
|
|
|
fn from_scheduled(
|
|
info: &ScheduledTaskInfo,
|
|
current_cron: Option<&str>,
|
|
is_queued: bool,
|
|
linked: Option<(String, bool)>,
|
|
) -> Self {
|
|
let linked_running = linked.as_ref().is_some_and(|(_, running)| *running);
|
|
let theme = Theme::current();
|
|
let prompt_preview = if info.prompt.chars().count() > 60 {
|
|
info.prompt.chars().take(57).collect::<String>() + "..."
|
|
} else {
|
|
info.prompt.clone()
|
|
};
|
|
let countdown = |schedule: &str, created: std::time::Instant| -> String {
|
|
if let Some(secs) = crate::util::parse_schedule_interval_secs(schedule) {
|
|
let approx = created + std::time::Duration::from_secs(secs);
|
|
let now = std::time::Instant::now();
|
|
if approx > now {
|
|
format!(" (next in {})", format_duration(approx.duration_since(now)))
|
|
} else {
|
|
" (due now)".to_string()
|
|
}
|
|
} else {
|
|
String::new()
|
|
}
|
|
};
|
|
let is_provisional = info.task_id.starts_with("provisional-");
|
|
let suffix = if current_cron == Some(&info.task_id) || linked_running {
|
|
" (running)".to_string()
|
|
} else if is_queued {
|
|
" (queued)".to_string()
|
|
} else if is_provisional {
|
|
" (starting)".to_string()
|
|
} else if let Some(n) = &info.next_fire_at {
|
|
if let Ok(dt) = DateTime::<chrono::FixedOffset>::parse_from_rfc3339(n) {
|
|
let dt = dt.with_timezone(&Utc);
|
|
let now = Utc::now();
|
|
if dt > now {
|
|
let dur = (dt - now).to_std().unwrap_or_default();
|
|
format!(" (next in {})", format_duration(dur))
|
|
} else {
|
|
" (due now)".to_string()
|
|
}
|
|
} else {
|
|
countdown(&info.human_schedule, info.created_at)
|
|
}
|
|
} else {
|
|
countdown(&info.human_schedule, info.created_at)
|
|
};
|
|
// Capitalize the tag for display (`loop` → `Loop`) so it reads as a
|
|
// proper label, matching the monitor row's `Monitor` tag.
|
|
let tag_display = {
|
|
let mut chars = info.tag.chars();
|
|
match chars.next() {
|
|
Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
|
|
None => String::new(),
|
|
}
|
|
};
|
|
let label = format!(
|
|
"{} {} \u{b7} {}{}",
|
|
tag_display, info.human_schedule, &prompt_preview, &suffix
|
|
);
|
|
|
|
// Only the tag (e.g. `Loop`) carries color — the blue system accent.
|
|
// The schedule, prompt preview, and status suffix all render in the
|
|
// neutral secondary text color so the row reads calmly with a single
|
|
// point of color. No surrounding `[ ]` brackets: the color alone
|
|
// sets the tag apart from the schedule that follows it.
|
|
let schedule_style = format!("{} \u{b7} ", info.human_schedule);
|
|
let neutral = Style::default().fg(theme.text_secondary);
|
|
let styled = Line::from(vec![
|
|
Span::styled(
|
|
format!("{} ", tag_display),
|
|
Style::default().fg(theme.accent_system),
|
|
),
|
|
Span::styled(schedule_style, neutral),
|
|
Span::styled(prompt_preview, neutral),
|
|
if !suffix.is_empty() {
|
|
Span::styled(suffix, neutral)
|
|
} else {
|
|
Span::raw("")
|
|
},
|
|
]);
|
|
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
"sched:".hash(&mut hasher);
|
|
info.task_id.hash(&mut hasher);
|
|
let id = hasher.finish();
|
|
|
|
TaskEntry::Scheduled {
|
|
id,
|
|
task_id: info.task_id.clone(),
|
|
label,
|
|
styled,
|
|
started_at: info.created_at,
|
|
linked_subagent: linked.map(|(sid, _)| sid),
|
|
}
|
|
}
|
|
|
|
/// Build a collapsible group header row, e.g. `▾ Subagents 2` (expanded)
|
|
/// or `▸ Subagents 2` (collapsed). The chevron + count are baked into the
|
|
/// styled line; the label aligns with item labels (the `chevron + space`
|
|
/// prefix is the same width as an item's 2-space indent).
|
|
fn header(group: GroupKind, count: usize, collapsed: bool) -> Self {
|
|
let theme = Theme::current();
|
|
let chevron = if collapsed { "\u{25B8} " } else { "\u{25BE} " };
|
|
let styled = Line::from(vec![
|
|
Span::styled(chevron, Style::default().fg(theme.gray)),
|
|
Span::styled(
|
|
group.label(),
|
|
Style::default()
|
|
.fg(theme.gray_bright)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::styled(format!(" {count}"), Style::default().fg(theme.gray)),
|
|
]);
|
|
TaskEntry::Header { group, styled }
|
|
}
|
|
|
|
/// Which collapsible group this entry belongs to.
|
|
fn group_kind(&self) -> GroupKind {
|
|
match self {
|
|
TaskEntry::Agent { .. } => GroupKind::Subagents,
|
|
TaskEntry::BgTask {
|
|
is_monitor: false, ..
|
|
} => GroupKind::Tasks,
|
|
TaskEntry::BgTask {
|
|
is_monitor: true, ..
|
|
} => GroupKind::Watchers,
|
|
TaskEntry::Scheduled { .. } => GroupKind::Watchers,
|
|
TaskEntry::Header { group, .. } => *group,
|
|
}
|
|
}
|
|
|
|
fn is_running(&self) -> bool {
|
|
match self {
|
|
TaskEntry::BgTask { running, .. } | TaskEntry::Agent { running, .. } => *running,
|
|
TaskEntry::Scheduled { .. } => true,
|
|
TaskEntry::Header { .. } => false,
|
|
}
|
|
}
|
|
|
|
/// Fine-grained sort rank, distinct per task kind so each renders as a
|
|
/// contiguous block: subagents (0) → one-shot bg tasks (1) → monitors
|
|
/// (2) → scheduled/loops (3). Monitors and loops share the `Watchers`
|
|
/// group/header but keep distinct ranks so monitors always sort before
|
|
/// loops within that section.
|
|
fn type_order(&self) -> u8 {
|
|
match self {
|
|
TaskEntry::Agent { .. } => 0,
|
|
TaskEntry::BgTask {
|
|
is_monitor: false, ..
|
|
} => 1,
|
|
TaskEntry::BgTask {
|
|
is_monitor: true, ..
|
|
} => 2,
|
|
TaskEntry::Scheduled { .. } => 3,
|
|
// Headers never appear in the sorted `items` list; fall back to
|
|
// the group's coarse order for completeness.
|
|
TaskEntry::Header { group, .. } => group.order(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ListItem for TaskEntry {
|
|
fn content(&self) -> &Line<'_> {
|
|
match self {
|
|
TaskEntry::BgTask { styled, .. }
|
|
| TaskEntry::Agent { styled, .. }
|
|
| TaskEntry::Scheduled { styled, .. }
|
|
| TaskEntry::Header { styled, .. } => styled,
|
|
}
|
|
}
|
|
|
|
fn prefix(&self) -> Option<Line<'_>> {
|
|
match self {
|
|
// Headers sit flush-left; their chevron occupies the same two
|
|
// columns as an item's indent, so labels still line up.
|
|
TaskEntry::Header { .. } => None,
|
|
_ => Some(Line::from(Span::raw(" "))),
|
|
}
|
|
}
|
|
|
|
fn stable_id(&self) -> u64 {
|
|
match self {
|
|
TaskEntry::BgTask { id, .. }
|
|
| TaskEntry::Agent { id, .. }
|
|
| TaskEntry::Scheduled { id, .. } => *id,
|
|
TaskEntry::Header { group, .. } => {
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
"header:".hash(&mut hasher);
|
|
group.order().hash(&mut hasher);
|
|
hasher.finish()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_selectable(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn search_text(&self) -> &str {
|
|
match self {
|
|
TaskEntry::BgTask { label, .. }
|
|
| TaskEntry::Agent { label, .. }
|
|
| TaskEntry::Scheduled { label, .. } => label,
|
|
TaskEntry::Header { group, .. } => group.label(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TasksPane
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Temporary data for the overlay pass (avoids borrowing entries during mutation).
|
|
enum OverlayEntryData {
|
|
BgTask(String),
|
|
Agent(String, String),
|
|
Scheduled(String, Option<String>),
|
|
}
|
|
|
|
const MAX_TASKS_HEIGHT: u16 = 8;
|
|
const MAX_TASKS_FRACTION: f32 = 0.15;
|
|
|
|
pub struct TasksPane {
|
|
/// Display list: sorted `items` with group headers inserted and
|
|
/// collapsed groups' items removed. This is what the `ListPane` renders.
|
|
entries: Vec<TaskEntry>,
|
|
/// Sorted task items only (no headers). `entries` is derived from this by
|
|
/// [`Self::rebuild_entries`]; kept so collapse toggles can rebuild the
|
|
/// display list without re-reading the live task data.
|
|
items: Vec<TaskEntry>,
|
|
/// Groups the user has collapsed (header shown, items hidden).
|
|
collapsed_groups: std::collections::HashSet<GroupKind>,
|
|
pub list_state: ListPaneState,
|
|
list_style: ListPaneStyle,
|
|
show_done: bool,
|
|
pub overlay: OverlayState,
|
|
tick: u64,
|
|
pub kill_button_rects: Vec<(TaskEntryId, Rect)>,
|
|
pub view_button_rects: Vec<(TaskEntryId, Rect)>,
|
|
pub hovered_kill: Option<TaskEntryId>,
|
|
pub hovered_view: Option<TaskEntryId>,
|
|
prev_running_count: usize,
|
|
opened_by_auto: bool,
|
|
highlight_cache: HashMap<String, Vec<Span<'static>>>,
|
|
last_theme: ThemeKind,
|
|
}
|
|
|
|
impl Default for TasksPane {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Fill overlay cells with spaces so label text doesn't bleed through.
|
|
///
|
|
/// When the label extends into the area we're about to clear, place an
|
|
/// ellipsis `…` at the cell immediately to the left of the clear boundary so
|
|
/// the user sees that the row was truncated rather than just clipped silently.
|
|
/// The ellipsis inherits the style of the cell it lands on so it blends with
|
|
/// the surrounding label text.
|
|
fn clear_overlay_area(buf: &mut Buffer, area: Rect, y: u16, overlay_w: u16) {
|
|
let clamped = overlay_w.min(area.width);
|
|
if clamped == 0 {
|
|
return;
|
|
}
|
|
let clear_x = area.x + area.width - clamped;
|
|
|
|
// Detect truncation BEFORE clearing: if the cell at `clear_x` contains
|
|
// non-blank label content, the label is wider than the row minus the
|
|
// overlay reservation. Capture the style at `clear_x - 1` (the cell that
|
|
// will host the ellipsis) so the inserted `…` matches the label color.
|
|
let needs_ellipsis = clear_x > area.x
|
|
&& buf
|
|
.cell((clear_x, y))
|
|
.map(|c| !c.symbol().trim().is_empty())
|
|
.unwrap_or(false);
|
|
let ellipsis_style = if needs_ellipsis {
|
|
buf.cell((clear_x - 1, y))
|
|
.map(|c| c.style())
|
|
.unwrap_or_default()
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
let blanks = " ".repeat(clamped as usize);
|
|
buf.set_span(clear_x, y, &Span::raw(blanks), clamped);
|
|
|
|
if needs_ellipsis {
|
|
buf.set_span(clear_x - 1, y, &Span::styled("\u{2026}", ellipsis_style), 1);
|
|
}
|
|
}
|
|
|
|
/// Draw a single scroll indicator glyph centered on row `y`, blanking the rest
|
|
/// of the row so it reads as a dedicated, easy-to-see indicator row — the same
|
|
/// ▲/▼ as the corner indicators, just centered for visibility.
|
|
fn draw_centered_arrow(buf: &mut Buffer, area: Rect, y: u16, arrow: &str, color: Color) {
|
|
if area.width == 0 {
|
|
return;
|
|
}
|
|
let blanks = " ".repeat(area.width as usize);
|
|
buf.set_span(area.x, y, &Span::raw(blanks), area.width);
|
|
let cx = area.x + area.width / 2;
|
|
buf.set_span(
|
|
cx,
|
|
y,
|
|
&Span::styled(arrow.to_string(), Style::default().fg(color)),
|
|
1,
|
|
);
|
|
}
|
|
impl TasksPane {
|
|
pub fn new() -> Self {
|
|
let config = ListPaneConfig {
|
|
follow_enabled: false,
|
|
wrap_toggle_enabled: false,
|
|
search_enabled: true,
|
|
copy_enabled: true,
|
|
show_selection_when_unfocused: false,
|
|
visual_select_enabled: false,
|
|
filter_enabled: true,
|
|
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));
|
|
// This pane draws the scroll indicators (▲/▼) centered on dedicated
|
|
// rows, so the generic right-corner indicators are suppressed.
|
|
let list_style = ListPaneStyle {
|
|
show_corner_indicators: false,
|
|
..ListPaneStyle::default()
|
|
};
|
|
Self {
|
|
entries: Vec::new(),
|
|
items: Vec::new(),
|
|
collapsed_groups: std::collections::HashSet::new(),
|
|
list_state,
|
|
list_style,
|
|
show_done: false,
|
|
overlay: OverlayState::hidden(),
|
|
tick: 0,
|
|
kill_button_rects: Vec::new(),
|
|
view_button_rects: Vec::new(),
|
|
hovered_kill: None,
|
|
hovered_view: None,
|
|
prev_running_count: 0,
|
|
opened_by_auto: false,
|
|
highlight_cache: HashMap::new(),
|
|
last_theme: Theme::current_kind(),
|
|
}
|
|
}
|
|
|
|
// -- Data sync -----------------------------------------------------------
|
|
|
|
/// Sync entries from bg tasks, subagent sessions, and scheduled tasks.
|
|
pub fn sync(
|
|
&mut self,
|
|
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
|
|
subagents: &HashMap<String, SubagentInfo>,
|
|
scheduled: &HashMap<String, ScheduledTaskInfo>,
|
|
current_cron_task_id: Option<&str>,
|
|
queued_cron_ids: &std::collections::HashSet<&str>,
|
|
) {
|
|
// Detect theme switch and refresh caches.
|
|
let current_theme = Theme::current_kind();
|
|
if current_theme != self.last_theme {
|
|
self.last_theme = current_theme;
|
|
self.list_style = ListPaneStyle {
|
|
show_corner_indicators: false,
|
|
..ListPaneStyle::default()
|
|
};
|
|
self.highlight_cache.clear();
|
|
}
|
|
|
|
self.items.clear();
|
|
|
|
// Add bg task items
|
|
for task in bg_tasks.values() {
|
|
if self.show_done || task.status == BgTaskStatus::Running {
|
|
self.items
|
|
.push(TaskEntry::from_bg_task(task, &mut self.highlight_cache));
|
|
}
|
|
}
|
|
|
|
// Add subagent items
|
|
for info in subagents.values() {
|
|
if self.show_done || info.is_running() {
|
|
self.items.push(TaskEntry::from_subagent(info));
|
|
}
|
|
}
|
|
|
|
// Add scheduled task items (always "running")
|
|
for info in scheduled.values() {
|
|
let linked = info.last_subagent_id.as_deref().and_then(|sid| {
|
|
subagents
|
|
.values()
|
|
.find(|s| s.subagent_id.as_ref() == sid)
|
|
.map(|s| (sid.to_string(), s.is_running()))
|
|
});
|
|
self.items.push(TaskEntry::from_scheduled(
|
|
info,
|
|
current_cron_task_id,
|
|
queued_cron_ids.contains(info.task_id.as_str()),
|
|
linked,
|
|
));
|
|
}
|
|
|
|
// Sort: group by type first (subagents → tasks → monitors →
|
|
// scheduled) so each kind is one contiguous block, then running
|
|
// before done within each group, then newest-first, then a stable
|
|
// id tiebreak. Monitors and scheduled/loops render under one shared
|
|
// "Watchers" header but keep distinct ranks (monitors first).
|
|
self.items.sort_by(|a, b| {
|
|
// 1. Group by type so each kind is one contiguous block:
|
|
// subagents → tasks → monitors → scheduled.
|
|
a.type_order()
|
|
.cmp(&b.type_order())
|
|
// 2. Running before done *within* each group.
|
|
.then_with(|| b.is_running().cmp(&a.is_running()))
|
|
// 3. Within a (group, run-state): subagents order by agent
|
|
// type (alphabetical) then newest-first; tasks/monitors/
|
|
// loops order newest-first. Avoids mixing SystemTime and
|
|
// Instant across types.
|
|
.then_with(|| match (a, b) {
|
|
(
|
|
TaskEntry::Agent {
|
|
type_label: ta,
|
|
started_at: sa,
|
|
..
|
|
},
|
|
TaskEntry::Agent {
|
|
type_label: tb,
|
|
started_at: sb,
|
|
..
|
|
},
|
|
) => ta.cmp(tb).then_with(|| sb.cmp(sa)),
|
|
(
|
|
TaskEntry::Scheduled { started_at: a, .. },
|
|
TaskEntry::Scheduled { started_at: b, .. },
|
|
) => b.cmp(a),
|
|
(
|
|
TaskEntry::BgTask { start_time: a, .. },
|
|
TaskEntry::BgTask { start_time: b, .. },
|
|
) => b.cmp(a),
|
|
_ => std::cmp::Ordering::Equal,
|
|
})
|
|
// 4. Stable tiebreak so equal-timestamp rows don't reshuffle
|
|
// frame-to-frame.
|
|
.then_with(|| a.stable_id().cmp(&b.stable_id()))
|
|
});
|
|
|
|
// Auto-expand: forget the collapse state of any group that no longer
|
|
// has items, so when items later return (e.g. a new subagent spawns
|
|
// after the group emptied) the group reappears expanded instead of
|
|
// hidden under a stale collapsed header.
|
|
if !self.collapsed_groups.is_empty() {
|
|
let mut present = [false; 3];
|
|
for it in &self.items {
|
|
present[it.group_kind().order() as usize] = true;
|
|
}
|
|
self.collapsed_groups
|
|
.retain(|g| present[g.order() as usize]);
|
|
}
|
|
|
|
// Build the display list: group headers + (non-collapsed) items.
|
|
self.rebuild_entries();
|
|
|
|
// Count running for edge detection. Replay-restored bg tasks are
|
|
// excluded: on resume they are historical context (cold loads
|
|
// reconcile them away within the same `session/load`; on warm
|
|
// reconnects they are ambient, not new activity), so they must not
|
|
// flash the overlay open on every resume.
|
|
let running_count = bg_tasks
|
|
.values()
|
|
.filter(|t| t.status == BgTaskStatus::Running && !t.restored_from_replay)
|
|
.count()
|
|
+ subagents.values().filter(|s| s.is_running()).count()
|
|
+ scheduled.len();
|
|
|
|
// Auto-show: running went from 0 to N
|
|
if running_count > 0 && self.prev_running_count == 0 {
|
|
self.overlay.show();
|
|
self.opened_by_auto = true;
|
|
}
|
|
|
|
// Auto-close: running went from N to 0, auto-shown, not focused
|
|
if running_count == 0
|
|
&& self.prev_running_count > 0
|
|
&& self.overlay.visible
|
|
&& !self.overlay.focused
|
|
&& self.opened_by_auto
|
|
&& !self.show_done
|
|
{
|
|
self.overlay.visible = false;
|
|
self.opened_by_auto = false;
|
|
}
|
|
|
|
self.prev_running_count = running_count;
|
|
}
|
|
|
|
/// Rebuild the display `entries` from the sorted `items`: insert a header
|
|
/// at each group boundary (with the group's item count), and include a
|
|
/// group's items only when the group is not collapsed. Relies on `items`
|
|
/// already being sorted so each group is contiguous.
|
|
fn rebuild_entries(&mut self) {
|
|
self.entries.clear();
|
|
// Per-group item counts (indexed by `GroupKind::order`).
|
|
let mut counts: [usize; 3] = [0; 3];
|
|
for it in &self.items {
|
|
counts[it.group_kind().order() as usize] += 1;
|
|
}
|
|
let mut last: Option<GroupKind> = None;
|
|
for it in &self.items {
|
|
let group = it.group_kind();
|
|
if last != Some(group) {
|
|
let collapsed = self.collapsed_groups.contains(&group);
|
|
self.entries.push(TaskEntry::header(
|
|
group,
|
|
counts[group.order() as usize],
|
|
collapsed,
|
|
));
|
|
last = Some(group);
|
|
}
|
|
if !self.collapsed_groups.contains(&group) {
|
|
self.entries.push(it.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggle a group's collapse state (used by Enter / Ctrl-F / click).
|
|
pub fn toggle_group(&mut self, group: GroupKind) {
|
|
let collapsed = self.collapsed_groups.contains(&group);
|
|
self.set_group_collapsed(group, !collapsed);
|
|
}
|
|
|
|
/// Explicitly set a group's collapse state (used by ← / →, which collapse
|
|
/// and expand respectively rather than toggle). When the state changes,
|
|
/// rebuild the display list and keep the group's header selected so the
|
|
/// cursor doesn't jump. Returns `true` if the state actually changed.
|
|
pub fn set_group_collapsed(&mut self, group: GroupKind, collapsed: bool) -> bool {
|
|
let changed = if collapsed {
|
|
self.collapsed_groups.insert(group)
|
|
} else {
|
|
self.collapsed_groups.remove(&group)
|
|
};
|
|
if changed {
|
|
self.rebuild_entries();
|
|
if let Some(header) = self
|
|
.entries
|
|
.iter()
|
|
.find(|e| matches!(e, TaskEntry::Header { group: g, .. } if *g == group))
|
|
{
|
|
let id = header.stable_id();
|
|
self.list_state.select_by_id(id);
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// If the selected entry is a group header, return its group.
|
|
pub fn selected_header_group(&self) -> Option<GroupKind> {
|
|
match self.selected_entry()? {
|
|
TaskEntry::Header { group, .. } => Some(*group),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Number of running bg tasks + subagents + scheduled tasks.
|
|
pub fn running_count(
|
|
&self,
|
|
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
|
|
subagents: &HashMap<String, SubagentInfo>,
|
|
scheduled: &HashMap<String, ScheduledTaskInfo>,
|
|
) -> usize {
|
|
bg_tasks
|
|
.values()
|
|
.filter(|t| t.status == BgTaskStatus::Running)
|
|
.count()
|
|
+ subagents.values().filter(|s| s.is_running()).count()
|
|
+ scheduled.len()
|
|
}
|
|
|
|
// -- Visibility ----------------------------------------------------------
|
|
|
|
pub fn show_done(&self) -> bool {
|
|
self.show_done
|
|
}
|
|
|
|
pub fn is_visible(&self) -> bool {
|
|
self.overlay.visible
|
|
}
|
|
|
|
pub fn on_state_change(&mut self) {
|
|
if !self.overlay.visible {
|
|
self.list_state.close_input_bar();
|
|
}
|
|
self.opened_by_auto = false;
|
|
}
|
|
|
|
pub fn desired_height(&self, view_height: u16) -> u16 {
|
|
if !self.overlay.visible {
|
|
return 0;
|
|
}
|
|
if view_height < 12 {
|
|
return 0;
|
|
}
|
|
let count = self.entries.len();
|
|
if count == 0 {
|
|
return 1;
|
|
}
|
|
let fraction_cap = (view_height as f32 * MAX_TASKS_FRACTION).floor() as u16;
|
|
let max = MAX_TASKS_HEIGHT.min(fraction_cap).max(1);
|
|
// Reserve one extra row for the search/filter input bar (or an
|
|
// accepted matcher's status line) so it gets its own line instead of
|
|
// displacing the last task/agent entry. `ListPane` carves the bar out
|
|
// of the bottom of the area it's given, so without this the pane would
|
|
// show one fewer entry the moment `/` (or `f`) is pressed.
|
|
let bar = u16::from(
|
|
self.list_state.input_mode().is_some() || self.list_state.matcher().is_some(),
|
|
);
|
|
(count as u16).min(max).max(1) + bar
|
|
}
|
|
|
|
// -- Tick ----------------------------------------------------------------
|
|
|
|
pub fn tick(&mut self) -> bool {
|
|
self.tick += 1;
|
|
self.entries.iter().any(|e| e.is_running())
|
|
}
|
|
|
|
pub fn tick_count(&self) -> u64 {
|
|
self.tick
|
|
}
|
|
|
|
pub fn needs_tick(&self) -> bool {
|
|
self.entries.iter().any(|e| e.is_running())
|
|
}
|
|
|
|
// -- Input handling ------------------------------------------------------
|
|
|
|
pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
|
|
if crate::key!('h').matches(key) && self.list_state.input_mode().is_none() {
|
|
self.show_done = !self.show_done;
|
|
return true;
|
|
}
|
|
if self.entries.is_empty() {
|
|
return false;
|
|
}
|
|
self.list_state.handle_key_event(key, &self.entries)
|
|
}
|
|
|
|
pub fn handle_paste(&mut self, text: &str) -> bool {
|
|
self.list_state.handle_paste(text, &self.entries)
|
|
}
|
|
|
|
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
|
|
let max = match self.list_state.viewport_height() {
|
|
0..=5 => 1,
|
|
6..=10 => 2,
|
|
_ => lines.unsigned_abs() as i32,
|
|
};
|
|
let capped = lines.signum() * lines.abs().min(max);
|
|
self.list_state
|
|
.handle_scroll_event(capped, col, row, &self.entries);
|
|
}
|
|
|
|
pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16, area: Rect) -> bool {
|
|
if self.entries.is_empty() {
|
|
return false;
|
|
}
|
|
self.list_state
|
|
.handle_mouse_event(kind, col, row, area, &self.entries)
|
|
}
|
|
|
|
/// Get the selected entry (if any).
|
|
pub fn selected_entry(&self) -> Option<&TaskEntry> {
|
|
let sel = self.list_state.selected_index()?;
|
|
self.entries.get(sel)
|
|
}
|
|
|
|
/// Get the task_id if the selected entry is a BgTask.
|
|
pub fn selected_task_id(&self) -> Option<&str> {
|
|
match self.selected_entry()? {
|
|
TaskEntry::BgTask { task_id, .. } => Some(task_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Get the subagent_id if the selected entry is an Agent.
|
|
pub fn selected_subagent_id(&self) -> Option<&str> {
|
|
match self.selected_entry()? {
|
|
TaskEntry::Agent { subagent_id, .. } => Some(subagent_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Get the child_session_id if the selected entry is an Agent.
|
|
pub fn selected_child_session_id(&self) -> Option<&str> {
|
|
match self.selected_entry()? {
|
|
TaskEntry::Agent {
|
|
child_session_id, ..
|
|
} => Some(child_session_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// -- Rendering -----------------------------------------------------------
|
|
|
|
fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect {
|
|
let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left;
|
|
let pad_right = layout_cfg.block_pad_right;
|
|
Rect {
|
|
x: area.x + pad_left,
|
|
y: area.y,
|
|
width: area.width.saturating_sub(pad_left + pad_right),
|
|
height: area.height,
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn render(
|
|
&mut self,
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
focused: bool,
|
|
layout_cfg: &LayoutConfig,
|
|
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
|
|
subagents: &HashMap<String, SubagentInfo>,
|
|
scheduled: &HashMap<String, ScheduledTaskInfo>,
|
|
) {
|
|
let inner = Self::content_area(area, layout_cfg);
|
|
if self.entries.is_empty() {
|
|
if inner.height > 0 && inner.width > 0 {
|
|
let theme = Theme::current();
|
|
if self.show_done {
|
|
let span = Span::styled(
|
|
"No tasks or agents.",
|
|
Style::default().fg(theme.gray_bright),
|
|
);
|
|
buf.set_span(inner.x, inner.y, &span, inner.width);
|
|
} else {
|
|
let muted = Style::default().fg(theme.gray_bright);
|
|
let key_style = Style::default()
|
|
.fg(theme.text_primary)
|
|
.add_modifier(Modifier::BOLD);
|
|
let line = Line::from(vec![
|
|
Span::styled("No running tasks. Press ", muted),
|
|
Span::styled("h", key_style),
|
|
Span::styled(" to show all.", muted),
|
|
]);
|
|
buf.set_line(inner.x, inner.y, &line, inner.width);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Decide the reserved indicator rows from the CURRENT scroll offset
|
|
// (NoWrap ⇒ one row per entry, so the total is the entry count), then
|
|
// prepare the layout exactly once with the final viewport. Preparing
|
|
// first with the full `inner` height would clamp the offset to that
|
|
// larger viewport's max, leaving the offset short of the smaller
|
|
// viewport's bottom — so ▼ could never turn off at the end.
|
|
let total = self.entries.len();
|
|
let scrollable = total > inner.height as usize && inner.height >= 3;
|
|
let scroll = self.list_state.scroll_offset();
|
|
|
|
// Reserve a row for a centered ▲ / ▼ indicator ONLY when that indicator
|
|
// is actually shown — no blank reserved rows. The top row appears when
|
|
// scrolled down; the bottom row appears when, after the top
|
|
// reservation, content still extends past the viewport.
|
|
let reserve_top = scrollable && scroll > 0;
|
|
let top = u16::from(reserve_top);
|
|
let rows_without_bottom = inner.height.saturating_sub(top) as usize;
|
|
let reserve_bottom = scrollable && scroll + rows_without_bottom < total;
|
|
let bottom = u16::from(reserve_bottom);
|
|
|
|
let list_area = Rect {
|
|
x: inner.x,
|
|
y: inner.y + top,
|
|
width: inner.width,
|
|
height: inner.height - top - bottom,
|
|
};
|
|
self.list_state
|
|
.prepare_layout(&self.entries, list_area.width, list_area.height);
|
|
|
|
// ListPane draws its scrollbar in the last column of the area it's
|
|
// given. The overlay (right-aligned kill/view buttons) paints over
|
|
// that same column, covering the scrollbar. Hand the list an area
|
|
// extended one column into the right padding so the scrollbar lands
|
|
// just past the overlay's right edge.
|
|
//
|
|
// Only widen when the list will *actually* draw a scrollbar there
|
|
// (content overflows the viewport). When it won't, ListPane gives the
|
|
// full area to content — so the extra column would be filled with
|
|
// label text that bleeds one cell past the overlay's `[✗]` button
|
|
// (the overlay only clears within `list_area`). Keeping `lp_area ==
|
|
// list_area` in that case lets the overlay truncate the label cleanly
|
|
// before the button, with nothing rendered to its right.
|
|
let needs_scrollbar = total > list_area.height as usize;
|
|
let lp_area = if needs_scrollbar && list_area.right() < area.right() {
|
|
Rect {
|
|
width: list_area.width + 1,
|
|
..list_area
|
|
}
|
|
} else {
|
|
list_area
|
|
};
|
|
ListPane::new(&self.entries)
|
|
.focused(focused)
|
|
.style(self.list_style)
|
|
.render(lp_area, buf, &mut self.list_state);
|
|
|
|
// The right-corner indicators (▲/▼) are suppressed for this pane;
|
|
// instead we draw the same glyphs, in the same color, centered on the
|
|
// reserved row(s) so they're easier to see.
|
|
let arrow_color = self.list_style.indicator_fg;
|
|
if reserve_top {
|
|
draw_centered_arrow(buf, inner, inner.y, "\u{25B2}", arrow_color);
|
|
}
|
|
if reserve_bottom {
|
|
draw_centered_arrow(
|
|
buf,
|
|
inner,
|
|
inner.y + inner.height - 1,
|
|
"\u{25BC}",
|
|
arrow_color,
|
|
);
|
|
}
|
|
|
|
// Overlay pass — positioned over the list area so the kill/view button
|
|
// rows line up with the rows the list actually rendered. When the
|
|
// search/filter input bar is open (or an accepted matcher's status is
|
|
// shown), `ListPane` reserves the bottom row(s) of `list_area` for it,
|
|
// so shrink the overlay area to match — otherwise the spinner icons
|
|
// and kill/view buttons paint over the input bar (e.g. the `⸬` spinner
|
|
// corrupting `search:` into `⸬earch:`).
|
|
let bar_height = self.list_state.bottom_bar_height(list_area.height);
|
|
let overlay_area = Rect {
|
|
height: list_area.height.saturating_sub(bar_height),
|
|
..list_area
|
|
};
|
|
self.render_overlay(overlay_area, buf, bg_tasks, subagents, scheduled);
|
|
}
|
|
|
|
fn render_overlay(
|
|
&mut self,
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
|
|
subagents: &HashMap<String, SubagentInfo>,
|
|
_scheduled: &HashMap<String, ScheduledTaskInfo>,
|
|
) {
|
|
let theme = Theme::current();
|
|
let scroll_offset = self.list_state.scroll_offset();
|
|
self.kill_button_rects.clear();
|
|
self.view_button_rects.clear();
|
|
|
|
// Collect entry data first to avoid borrowing self.entries during mutation.
|
|
let visible: Vec<(u16, OverlayEntryData)> = self
|
|
.entries
|
|
.iter()
|
|
.skip(scroll_offset)
|
|
.enumerate()
|
|
.take_while(|(vis_row, _)| area.y + (*vis_row as u16) < area.y + area.height)
|
|
.filter_map(|(vis_row, entry)| {
|
|
let y = area.y + vis_row as u16;
|
|
let data = match entry {
|
|
TaskEntry::BgTask { task_id, .. } => OverlayEntryData::BgTask(task_id.clone()),
|
|
TaskEntry::Agent {
|
|
subagent_id,
|
|
child_session_id,
|
|
..
|
|
} => OverlayEntryData::Agent(subagent_id.clone(), child_session_id.clone()),
|
|
TaskEntry::Scheduled {
|
|
task_id,
|
|
linked_subagent,
|
|
..
|
|
} => OverlayEntryData::Scheduled(task_id.clone(), linked_subagent.clone()),
|
|
// Group headers have no kill/view buttons; they still
|
|
// occupy a row (vis_row is enumerated before this filter),
|
|
// so the y offsets for following items stay correct.
|
|
TaskEntry::Header { .. } => return None,
|
|
};
|
|
Some((y, data))
|
|
})
|
|
.collect();
|
|
|
|
for (y, data) in visible {
|
|
match data {
|
|
OverlayEntryData::BgTask(ref task_id) => {
|
|
let Some(task) = bg_tasks.get(task_id) else {
|
|
continue;
|
|
};
|
|
self.render_bg_task_overlay(area, buf, y, task_id, task, &theme);
|
|
}
|
|
OverlayEntryData::Agent(ref subagent_id, ref child_session_id) => {
|
|
let Some(info) = subagents.get(child_session_id) else {
|
|
continue;
|
|
};
|
|
self.render_agent_overlay(area, buf, y, subagent_id, info, &theme);
|
|
}
|
|
OverlayEntryData::Scheduled(ref task_id, ref linked_subagent) => {
|
|
self.render_scheduled_overlay(
|
|
area,
|
|
buf,
|
|
y,
|
|
task_id,
|
|
linked_subagent.as_deref(),
|
|
&theme,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_bg_task_overlay(
|
|
&mut self,
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
y: u16,
|
|
task_id: &str,
|
|
task: &BgTaskState,
|
|
theme: &Theme,
|
|
) {
|
|
let (icon, icon_style, right_text, right_style) = if task.pending_kill {
|
|
let frames = crate::glyphs::dot_spinner_frames();
|
|
let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len();
|
|
(
|
|
frames[frame_idx],
|
|
Style::default().fg(theme.accent_error),
|
|
"killing\u{2026} ".to_string(),
|
|
Style::default().fg(theme.accent_error),
|
|
)
|
|
} else {
|
|
match task.status {
|
|
BgTaskStatus::Running => {
|
|
let frames = crate::glyphs::dot_spinner_frames();
|
|
let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len();
|
|
let elapsed = format_duration(task.elapsed());
|
|
(
|
|
frames[frame_idx],
|
|
Style::default().fg(theme.accent_running),
|
|
format!("{elapsed} "),
|
|
Style::default().fg(theme.gray),
|
|
)
|
|
}
|
|
BgTaskStatus::Done => {
|
|
let elapsed = format_duration(task.elapsed());
|
|
(
|
|
crate::glyphs::check_mark(),
|
|
Style::default().fg(theme.accent_success),
|
|
format!("{elapsed} "),
|
|
Style::default().fg(theme.gray),
|
|
)
|
|
}
|
|
BgTaskStatus::Failed => {
|
|
let elapsed = format_duration(task.elapsed());
|
|
(
|
|
crate::glyphs::ballot_x(),
|
|
Style::default().fg(theme.accent_error),
|
|
format!("{elapsed} "),
|
|
Style::default().fg(theme.gray),
|
|
)
|
|
}
|
|
}
|
|
};
|
|
|
|
buf.set_span(area.x, y, &Span::styled(icon, icon_style), 2);
|
|
|
|
// Stdout line count, shown just to the left of the duration as
|
|
// a compact `(N)` badge with SI scaling (`(1.2k)`, `(5.4M)`) so
|
|
// huge outputs don't push the label off-screen. Hidden when
|
|
// there's no captured output. Styled dim like the time text.
|
|
// Reads the cached `stdout_line_count` (maintained by
|
|
// `BgTaskState::set_stdout` / `append_stdout`) so the overlay
|
|
// doesn't memchr-scan the full buffer per render frame.
|
|
let badge = format_line_count_badge(task.stdout_line_count, task.truncated);
|
|
let lines_text = if badge.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("{badge} ")
|
|
};
|
|
let lines_w = lines_text.width() as u16;
|
|
|
|
// Clear overlay area to prevent label text bleeding through.
|
|
let right_text_w = right_text.width() as u16;
|
|
let bg_kill_w: u16 = if task.status == BgTaskStatus::Running {
|
|
3
|
|
} else {
|
|
0
|
|
};
|
|
let bg_overlay_w = bg_kill_w + 3 + right_text_w + lines_w + 1;
|
|
clear_overlay_area(buf, area, y, bg_overlay_w);
|
|
|
|
let mut rx = area.x + area.width;
|
|
|
|
// Kill button (visible even during pending_kill so the user can retry)
|
|
if task.status == BgTaskStatus::Running {
|
|
rx = rx.saturating_sub(3);
|
|
let is_hovered = matches!(
|
|
&self.hovered_kill,
|
|
Some(TaskEntryId::BgTask(tid)) if tid == task_id
|
|
);
|
|
let kill_style = if is_hovered {
|
|
Style::default().fg(theme.accent_error)
|
|
} else {
|
|
Style::default().fg(theme.gray)
|
|
};
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(crate::glyphs::ballot_x_button(), kill_style),
|
|
3,
|
|
);
|
|
self.kill_button_rects.push((
|
|
TaskEntryId::BgTask(task_id.to_string()),
|
|
Rect::new(rx, y, 3, 1),
|
|
));
|
|
}
|
|
|
|
// View button
|
|
rx = rx.saturating_sub(3);
|
|
let is_view_hovered = matches!(
|
|
&self.hovered_view,
|
|
Some(TaskEntryId::BgTask(tid)) if tid == task_id
|
|
);
|
|
let view_style = if is_view_hovered {
|
|
Style::default().fg(theme.text_primary)
|
|
} else {
|
|
Style::default().fg(theme.gray)
|
|
};
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(crate::glyphs::enlarge_button(), view_style),
|
|
3,
|
|
);
|
|
self.view_button_rects.push((
|
|
TaskEntryId::BgTask(task_id.to_string()),
|
|
Rect::new(rx, y, 3, 1),
|
|
));
|
|
|
|
// Time/status text
|
|
let right_width = right_text.width() as u16;
|
|
rx = rx.saturating_sub(right_width);
|
|
buf.set_span(rx, y, &Span::styled(right_text, right_style), right_width);
|
|
|
|
// Line count (just to the left of the duration).
|
|
if lines_w > 0 {
|
|
rx = rx.saturating_sub(lines_w);
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(lines_text, Style::default().fg(theme.gray)),
|
|
lines_w,
|
|
);
|
|
}
|
|
|
|
if rx > area.x {
|
|
buf.set_span(rx - 1, y, &Span::raw(" "), 1);
|
|
}
|
|
}
|
|
|
|
fn render_agent_overlay(
|
|
&mut self,
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
y: u16,
|
|
subagent_id: &str,
|
|
info: &SubagentInfo,
|
|
theme: &Theme,
|
|
) {
|
|
let (icon, icon_style, right_text, right_style) = if info.pending_kill {
|
|
let frames = crate::glyphs::dot_spinner_frames();
|
|
let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len();
|
|
(
|
|
frames[frame_idx],
|
|
Style::default().fg(theme.accent_error),
|
|
"killing\u{2026} ".to_string(),
|
|
Style::default().fg(theme.accent_error),
|
|
)
|
|
} else if info.is_running() {
|
|
let frames = crate::glyphs::dot_spinner_frames();
|
|
let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len();
|
|
let elapsed = format_duration(info.display_elapsed());
|
|
(
|
|
frames[frame_idx],
|
|
Style::default().fg(theme.accent_running),
|
|
format!("{elapsed} "),
|
|
Style::default().fg(theme.gray),
|
|
)
|
|
} else if info.status.as_deref() == Some("completed") {
|
|
let elapsed = format_duration(info.display_elapsed());
|
|
(
|
|
crate::glyphs::check_mark(),
|
|
Style::default().fg(theme.accent_success),
|
|
format!("{elapsed} "),
|
|
Style::default().fg(theme.gray),
|
|
)
|
|
} else {
|
|
let elapsed = format_duration(info.display_elapsed());
|
|
(
|
|
crate::glyphs::ballot_x(),
|
|
Style::default().fg(theme.accent_error),
|
|
format!("{elapsed} "),
|
|
Style::default().fg(theme.gray),
|
|
)
|
|
};
|
|
|
|
buf.set_span(area.x, y, &Span::styled(icon, icon_style), 2);
|
|
|
|
// Clear overlay area to prevent label text bleeding through.
|
|
let badge = format_context_badge(info);
|
|
let model_text = info
|
|
.model
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or("");
|
|
let right_text_w = right_text.width() as u16;
|
|
let kill_w: u16 = if info.is_running() { 3 } else { 0 };
|
|
let badge_w: u16 = if badge.is_empty() {
|
|
0
|
|
} else {
|
|
badge.width() as u16 + 1
|
|
};
|
|
let model_w: u16 = if model_text.is_empty() {
|
|
0
|
|
} else {
|
|
model_text.width() as u16 + 1
|
|
};
|
|
let overlay_w = kill_w + 3 + right_text_w + model_w + badge_w + 1;
|
|
clear_overlay_area(buf, area, y, overlay_w);
|
|
|
|
let mut rx = area.x + area.width;
|
|
|
|
// Kill button (visible even during pending_kill so the user can retry)
|
|
if info.is_running() {
|
|
rx = rx.saturating_sub(3);
|
|
let is_hovered = matches!(
|
|
&self.hovered_kill,
|
|
Some(TaskEntryId::Agent(sid)) if sid == subagent_id
|
|
);
|
|
let kill_style = if is_hovered {
|
|
Style::default().fg(theme.accent_error)
|
|
} else {
|
|
Style::default().fg(theme.gray)
|
|
};
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(crate::glyphs::ballot_x_button(), kill_style),
|
|
3,
|
|
);
|
|
self.kill_button_rects.push((
|
|
TaskEntryId::Agent(subagent_id.to_string()),
|
|
Rect::new(rx, y, 3, 1),
|
|
));
|
|
}
|
|
|
|
// View button
|
|
rx = rx.saturating_sub(3);
|
|
let is_view_hovered = matches!(
|
|
&self.hovered_view,
|
|
Some(TaskEntryId::Agent(sid)) if sid == subagent_id
|
|
);
|
|
let view_style = if is_view_hovered {
|
|
Style::default().fg(theme.text_primary)
|
|
} else {
|
|
Style::default().fg(theme.gray)
|
|
};
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(crate::glyphs::enlarge_button(), view_style),
|
|
3,
|
|
);
|
|
self.view_button_rects.push((
|
|
TaskEntryId::Agent(subagent_id.to_string()),
|
|
Rect::new(rx, y, 3, 1),
|
|
));
|
|
|
|
// Time/status text
|
|
let right_width = right_text.width() as u16;
|
|
rx = rx.saturating_sub(right_width);
|
|
buf.set_span(rx, y, &Span::styled(right_text, right_style), right_width);
|
|
|
|
// Model (right-aligned, just to the left of elapsed). Pre-computed
|
|
// above for overlay clearing.
|
|
if !model_text.is_empty() {
|
|
rx = rx.saturating_sub(model_w);
|
|
let mstyle = Style::default().fg(theme.gray);
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(format!("{model_text} "), mstyle),
|
|
model_w,
|
|
);
|
|
}
|
|
|
|
// Context badge (pre-computed above for overlay clearing)
|
|
if !badge.is_empty() {
|
|
rx = rx.saturating_sub(badge_w);
|
|
let bstyle = Style::default().fg(theme.gray).add_modifier(Modifier::DIM);
|
|
buf.set_span(rx, y, &Span::styled(format!("{badge} "), bstyle), badge_w);
|
|
}
|
|
|
|
if rx > area.x {
|
|
buf.set_span(rx - 1, y, &Span::raw(" "), 1);
|
|
}
|
|
}
|
|
|
|
fn render_scheduled_overlay(
|
|
&mut self,
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
y: u16,
|
|
task_id: &str,
|
|
linked_subagent: Option<&str>,
|
|
theme: &Theme,
|
|
) {
|
|
let frames = crate::glyphs::dot_spinner_frames();
|
|
let frame_idx = (self.tick / SPINNER_DIVISOR) as usize % frames.len();
|
|
buf.set_span(
|
|
area.x,
|
|
y,
|
|
&Span::styled(frames[frame_idx], Style::default().fg(theme.accent_running)),
|
|
2,
|
|
);
|
|
|
|
let overlay_cols = if linked_subagent.is_some() { 7 } else { 4 };
|
|
clear_overlay_area(buf, area, y, overlay_cols);
|
|
|
|
let mut rx = area.x + area.width;
|
|
|
|
// Kill button [✗]
|
|
rx = rx.saturating_sub(3);
|
|
let is_hovered = matches!(
|
|
&self.hovered_kill,
|
|
Some(TaskEntryId::Scheduled(tid)) if tid == task_id
|
|
);
|
|
let kill_style = if is_hovered {
|
|
Style::default().fg(theme.accent_error)
|
|
} else {
|
|
Style::default().fg(theme.gray)
|
|
};
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(crate::glyphs::ballot_x_button(), kill_style),
|
|
3,
|
|
);
|
|
self.kill_button_rects.push((
|
|
TaskEntryId::Scheduled(task_id.to_string()),
|
|
Rect::new(rx, y, 3, 1),
|
|
));
|
|
|
|
if linked_subagent.is_some() {
|
|
rx = rx.saturating_sub(3);
|
|
let is_view_hovered = matches!(
|
|
&self.hovered_view,
|
|
Some(TaskEntryId::Scheduled(tid)) if tid == task_id
|
|
);
|
|
let view_style = if is_view_hovered {
|
|
Style::default().fg(theme.text_primary)
|
|
} else {
|
|
Style::default().fg(theme.gray)
|
|
};
|
|
buf.set_span(
|
|
rx,
|
|
y,
|
|
&Span::styled(crate::glyphs::enlarge_button(), view_style),
|
|
3,
|
|
);
|
|
self.view_button_rects.push((
|
|
TaskEntryId::Scheduled(task_id.to_string()),
|
|
Rect::new(rx, y, 3, 1),
|
|
));
|
|
}
|
|
|
|
if rx > area.x {
|
|
buf.set_span(rx - 1, y, &Span::raw(" "), 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
fn make_info() -> SubagentInfo {
|
|
SubagentInfo {
|
|
subagent_id: Arc::from("sa-1"),
|
|
child_session_id: Arc::from("cs-1"),
|
|
description: Arc::from("Find API endpoints"),
|
|
subagent_type: Arc::from("explore"),
|
|
persona: None,
|
|
role: None,
|
|
model: None,
|
|
context_source: None,
|
|
resumed_from: None,
|
|
capability_mode: None,
|
|
context_normalized: false,
|
|
parent_prompt_id: None,
|
|
started_at: Instant::now(),
|
|
last_progress_at: Instant::now(),
|
|
finished: false,
|
|
status: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
tool_calls: None,
|
|
turns: None,
|
|
turn_count: None,
|
|
tool_call_count: None,
|
|
tokens_used: None,
|
|
context_window_tokens: None,
|
|
context_usage_pct: None,
|
|
tools_used: Vec::new(),
|
|
error_count: None,
|
|
activity_label: None,
|
|
is_background: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
prompt: None,
|
|
child_cwd: None,
|
|
worktree_path: None,
|
|
child_updates_replayed: false,
|
|
}
|
|
}
|
|
|
|
fn make_bg_task(task_id: &str, command: &str, status: BgTaskStatus) -> BgTaskState {
|
|
BgTaskState {
|
|
task_id: task_id.into(),
|
|
tool_call_id: String::new(),
|
|
command: command.into(),
|
|
description: None,
|
|
cwd: String::new(),
|
|
output_file: String::new(),
|
|
status,
|
|
start_time: SystemTime::now(),
|
|
end_time: None,
|
|
exit_code: None,
|
|
signal: None,
|
|
stdout: String::new(),
|
|
stdout_line_count: 0,
|
|
truncated: false,
|
|
pending_kill: false,
|
|
kill_requested_at: None,
|
|
scrollback_entry_id: None,
|
|
is_monitor: false,
|
|
restored_from_replay: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_empty_for_zero() {
|
|
assert_eq!(format_line_count_badge(0, false), "");
|
|
// `truncated` doesn't conjure a badge out of an empty buffer.
|
|
assert_eq!(format_line_count_badge(0, true), "");
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_raw_under_thousand() {
|
|
assert_eq!(format_line_count_badge(1, false), "(1)");
|
|
assert_eq!(format_line_count_badge(42, false), "(42)");
|
|
assert_eq!(format_line_count_badge(999, false), "(999)");
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_decimal_thousands() {
|
|
assert_eq!(format_line_count_badge(1_000, false), "(1.0k)");
|
|
assert_eq!(format_line_count_badge(1_234, false), "(1.2k)");
|
|
// Truncation, not rounding: 1999 stays at "1.9k".
|
|
assert_eq!(format_line_count_badge(1_999, false), "(1.9k)");
|
|
assert_eq!(format_line_count_badge(9_999, false), "(9.9k)");
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_whole_thousands() {
|
|
assert_eq!(format_line_count_badge(10_000, false), "(10k)");
|
|
assert_eq!(format_line_count_badge(99_999, false), "(99k)");
|
|
assert_eq!(format_line_count_badge(123_456, false), "(123k)");
|
|
assert_eq!(format_line_count_badge(999_999, false), "(999k)");
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_decimal_millions() {
|
|
assert_eq!(format_line_count_badge(1_000_000, false), "(1.0M)");
|
|
assert_eq!(format_line_count_badge(1_234_567, false), "(1.2M)");
|
|
assert_eq!(format_line_count_badge(9_999_999, false), "(9.9M)");
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_whole_millions() {
|
|
assert_eq!(format_line_count_badge(10_000_000, false), "(10M)");
|
|
assert_eq!(format_line_count_badge(123_456_789, false), "(123M)");
|
|
assert_eq!(format_line_count_badge(999_999_999, false), "(999M)");
|
|
}
|
|
|
|
#[test]
|
|
fn line_badge_truncated_appends_plus_suffix() {
|
|
assert_eq!(format_line_count_badge(42, true), "(42+)");
|
|
assert_eq!(format_line_count_badge(1_234, true), "(1.2k+)");
|
|
assert_eq!(format_line_count_badge(123_456, true), "(123k+)");
|
|
assert_eq!(format_line_count_badge(1_234_567, true), "(1.2M+)");
|
|
assert_eq!(format_line_count_badge(123_456_789, true), "(123M+)");
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_label_single_line() {
|
|
let task = make_bg_task("t1", "cargo test --release", BgTaskStatus::Running);
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let label = match &entry {
|
|
TaskEntry::BgTask { label, .. } => label.as_str(),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
assert_eq!(label, "cargo test --release");
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_label_multiline_truncated() {
|
|
let task = make_bg_task("t2", "echo hello\necho world", BgTaskStatus::Done);
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let label = match &entry {
|
|
TaskEntry::BgTask { label, .. } => label.as_str(),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
assert!(
|
|
label.ends_with('\u{2026}'),
|
|
"multiline command should be truncated with ellipsis: {label}",
|
|
);
|
|
assert!(
|
|
!label.contains('\n'),
|
|
"label should be single line: {label}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_label_prefers_description_over_command() {
|
|
let mut task = make_bg_task("t3", "cargo test --release", BgTaskStatus::Running);
|
|
task.description = Some("Run release tests".into());
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let label = match &entry {
|
|
TaskEntry::BgTask { label, .. } => label.as_str(),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
// `Task ` prefix is included in the searchable label.
|
|
assert_eq!(label, "Task Run release tests");
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_styled_prefix_uses_secondary_color() {
|
|
let mut task = make_bg_task("t3a", "cargo test --release", BgTaskStatus::Running);
|
|
task.description = Some("Run release tests".into());
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let styled = match &entry {
|
|
TaskEntry::BgTask { styled, .. } => styled,
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
let theme = Theme::current();
|
|
assert_eq!(styled.spans.len(), 2);
|
|
assert_eq!(styled.spans[0].content.as_ref(), "Task ");
|
|
assert_eq!(styled.spans[0].style.fg, Some(theme.text_secondary));
|
|
assert_eq!(styled.spans[1].content.as_ref(), "Run release tests");
|
|
assert_eq!(styled.spans[1].style.fg, Some(theme.text_primary));
|
|
}
|
|
|
|
#[test]
|
|
fn monitor_task_styled_with_monitor_tag() {
|
|
// Monitors render a blue "Monitor" tag + neutral description,
|
|
// mirroring scheduled /loop rows — NOT the bash-highlighted command.
|
|
let mut task = make_bg_task("mon1", "python -u counter.py", BgTaskStatus::Running);
|
|
task.is_monitor = true;
|
|
task.description = Some("incrementing event counter every 3s".into());
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let (label, styled) = match &entry {
|
|
TaskEntry::BgTask { label, styled, .. } => (label.as_str(), styled),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
let theme = Theme::current();
|
|
assert_eq!(label, "Monitor incrementing event counter every 3s");
|
|
assert_eq!(styled.spans.len(), 2);
|
|
assert_eq!(styled.spans[0].content.as_ref(), "Monitor ");
|
|
assert_eq!(styled.spans[0].style.fg, Some(theme.accent_system));
|
|
assert_eq!(
|
|
styled.spans[1].content.as_ref(),
|
|
"incrementing event counter every 3s"
|
|
);
|
|
assert_eq!(styled.spans[1].style.fg, Some(theme.text_secondary));
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_no_prefix_when_no_description() {
|
|
// Bare-command branch (no description) stays prefix-free — the
|
|
// bash-highlighted command stands alone.
|
|
let task = make_bg_task("t3b", "ls -la", BgTaskStatus::Running);
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let (label, styled) = match &entry {
|
|
TaskEntry::BgTask { label, styled, .. } => (label.as_str(), styled),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
assert_eq!(label, "ls -la");
|
|
let joined: String = styled.spans.iter().map(|s| s.content.as_ref()).collect();
|
|
assert!(
|
|
!joined.starts_with("Task "),
|
|
"no description ⇒ no prefix, got: {joined:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_label_falls_back_to_command_for_blank_description() {
|
|
let mut task = make_bg_task("t4", "ls -la", BgTaskStatus::Running);
|
|
task.description = Some(" ".into());
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let label = match &entry {
|
|
TaskEntry::BgTask { label, .. } => label.as_str(),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
assert_eq!(label, "ls -la");
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_label_collapses_description_newlines() {
|
|
let mut task = make_bg_task("t5", "ls", BgTaskStatus::Running);
|
|
task.description = Some("First line\nSecond line".into());
|
|
let mut cache = HashMap::new();
|
|
let entry = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let label = match &entry {
|
|
TaskEntry::BgTask { label, .. } => label.as_str(),
|
|
_ => panic!("expected BgTask variant"),
|
|
};
|
|
assert_eq!(label, "Task First line Second line");
|
|
assert!(!label.contains('\n'));
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_stable_id_deterministic() {
|
|
let task = make_bg_task("t1", "ls", BgTaskStatus::Running);
|
|
let mut cache = HashMap::new();
|
|
let e1 = TaskEntry::from_bg_task(&task, &mut cache);
|
|
let e2 = TaskEntry::from_bg_task(&task, &mut cache);
|
|
assert_eq!(e1.stable_id(), e2.stable_id());
|
|
}
|
|
|
|
#[test]
|
|
fn bg_task_and_agent_ids_differ() {
|
|
let task = make_bg_task("shared-id", "ls", BgTaskStatus::Running);
|
|
let mut cache = HashMap::new();
|
|
let bg = TaskEntry::from_bg_task(&task, &mut cache);
|
|
|
|
let mut info = make_info();
|
|
info.child_session_id = "shared-id".into();
|
|
let agent = TaskEntry::from_subagent(&info);
|
|
|
|
assert_ne!(
|
|
bg.stable_id(),
|
|
agent.stable_id(),
|
|
"bg task and agent with same string id should have different stable_ids",
|
|
);
|
|
}
|
|
|
|
/// Render `pane` to a fresh buffer of the given size and return the
|
|
/// concatenated character content for every row. Lets tests do a
|
|
/// `joined.contains("(N)")`-style assertion without depending on cell
|
|
/// styling details.
|
|
fn render_pane_to_strings(
|
|
pane: &mut TasksPane,
|
|
bg_tasks: &std::collections::BTreeMap<String, BgTaskState>,
|
|
width: u16,
|
|
height: u16,
|
|
) -> Vec<String> {
|
|
let area = Rect::new(0, 0, width, height);
|
|
let mut buf = Buffer::empty(area);
|
|
let layout = crate::appearance::LayoutConfig::default();
|
|
pane.render(
|
|
area,
|
|
&mut buf,
|
|
false,
|
|
&layout,
|
|
bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
);
|
|
(0..height)
|
|
.map(|y| {
|
|
(0..width)
|
|
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
|
|
.collect::<String>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_line_count_badge_for_bg_task_with_stdout() {
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let mut task = make_bg_task("t1", "ls", BgTaskStatus::Running);
|
|
// 42 newline-terminated rows ⇒ `(42)`. Use `set_stdout` so the
|
|
// cached `stdout_line_count` is populated.
|
|
task.set_stdout((0..42).map(|i| format!("line {i}\n")).collect::<String>());
|
|
assert_eq!(task.stdout.lines().count(), 42);
|
|
assert_eq!(task.stdout_line_count, 42);
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t1".into(), task);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// 12+ rows so `desired_height` is non-zero; wide enough that the
|
|
// overlay isn't clipped.
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16);
|
|
let joined = lines.join("\n");
|
|
assert!(
|
|
joined.contains("(42)"),
|
|
"expected `(42)` badge in rendered buffer, got:\n{joined}",
|
|
);
|
|
}
|
|
|
|
/// Resume regression: bg tasks restored from a `session/load`
|
|
/// replay are historical context — they must not auto-open the overlay
|
|
/// (on cold resumes they die again within the same load; the open/close
|
|
/// flash looked like tasks "loading then failing"). A genuinely new live
|
|
/// task must still trigger the auto-show edge.
|
|
#[test]
|
|
fn restored_running_tasks_do_not_auto_show() {
|
|
let mut pane = TasksPane::new();
|
|
|
|
let mut restored = make_bg_task("t-restored", "tail -f deploy.log", BgTaskStatus::Running);
|
|
restored.restored_from_replay = true;
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t-restored".to_string(), restored);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
assert!(
|
|
!pane.is_visible(),
|
|
"replay-restored running tasks must not auto-open the tasks pane"
|
|
);
|
|
|
|
// A live (non-restored) task still triggers the 0→N auto-show edge.
|
|
bg_tasks.insert(
|
|
"t-live".to_string(),
|
|
make_bg_task("t-live", "cargo build", BgTaskStatus::Running),
|
|
);
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
assert!(
|
|
pane.is_visible(),
|
|
"a new live running task must still auto-open the tasks pane"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_plus_suffix_when_truncated() {
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let mut task = make_bg_task("t1", "ls", BgTaskStatus::Running);
|
|
task.set_stdout((0..42).map(|i| format!("line {i}\n")).collect::<String>());
|
|
task.truncated = true;
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t1".into(), task);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16);
|
|
let joined = lines.join("\n");
|
|
assert!(
|
|
joined.contains("(42+)"),
|
|
"expected `(42+)` badge in rendered buffer, got:\n{joined}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_hides_badge_when_stdout_empty() {
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let task = make_bg_task("t1", "ls", BgTaskStatus::Running);
|
|
assert!(task.stdout.is_empty());
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t1".into(), task);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 80, 16);
|
|
let joined = lines.join("\n");
|
|
assert!(
|
|
!joined.contains("()"),
|
|
"should not render empty `()` badge, got:\n{joined}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_bar_not_overwritten_by_task_overlay() {
|
|
// Regression: while subagents/tasks are running, opening the search
|
|
// bar (`/`) used to render broken UI — the overlay pass (spinner +
|
|
// kill/view buttons) painted over the bottom input-bar row that
|
|
// `ListPane` reserves, corrupting `search:` into `⸬earch:` (the `⸬`
|
|
// spinner glyph clobbering the leading `s`). The overlay must stop one
|
|
// row short of the input bar.
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
// Three running tasks ⇒ entries = [Tasks header, t0, t1, t2].
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
for i in 0..3 {
|
|
bg_tasks.insert(
|
|
format!("t{i}"),
|
|
make_bg_task(
|
|
&format!("t{i}"),
|
|
&format!("sleep {i}"),
|
|
BgTaskStatus::Running,
|
|
),
|
|
);
|
|
}
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// Press `/` to open the search bar.
|
|
assert!(pane.handle_key(&crate::key!('/').to_key_event()));
|
|
assert!(
|
|
pane.list_state.input_mode().is_some(),
|
|
"`/` should open the search input bar",
|
|
);
|
|
|
|
// Height 4: the header + 3 task rows exactly fill the area, so the
|
|
// search bar steals the bottom row. Without the fix, the third task's
|
|
// overlay lands on that same row and clobbers the `search:` prompt.
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 60, 4);
|
|
let joined = lines.join("\n");
|
|
|
|
assert!(
|
|
joined.contains("search:"),
|
|
"search bar prompt must render intact (not clobbered by the task \
|
|
overlay spinner), got:\n{joined}",
|
|
);
|
|
|
|
// The row carrying the prompt must start with `search:` (after the
|
|
// pane's left padding) — not an overlay spinner/kill glyph.
|
|
let bar_row = lines
|
|
.iter()
|
|
.find(|l| l.contains("search:"))
|
|
.expect("a rendered row containing the search bar");
|
|
assert!(
|
|
bar_row.trim_start().starts_with("search:"),
|
|
"search bar row should begin with the prompt, not an overlay \
|
|
glyph: {bar_row:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_bar_adds_a_line_keeping_last_entry_visible() {
|
|
// Opening the search bar should grow the pane by exactly one row so the
|
|
// bar gets its own line — the last task/agent must stay visible rather
|
|
// than being displaced by the bar.
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
for i in 0..3 {
|
|
bg_tasks.insert(
|
|
format!("t{i}"),
|
|
make_bg_task(
|
|
&format!("t{i}"),
|
|
&format!("sleep {i}"),
|
|
BgTaskStatus::Running,
|
|
),
|
|
);
|
|
}
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// Tall enough that all entries fit without scrolling.
|
|
let view_height = 40u16;
|
|
let h_before = pane.desired_height(view_height);
|
|
|
|
// Press `/` to open the search bar.
|
|
assert!(pane.handle_key(&crate::key!('/').to_key_event()));
|
|
let h_after = pane.desired_height(view_height);
|
|
assert_eq!(
|
|
h_after,
|
|
h_before + 1,
|
|
"opening search should add exactly one row for the bar",
|
|
);
|
|
|
|
// Render at the grown height: all three tasks AND the search bar must
|
|
// be visible together.
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 60, h_after);
|
|
let joined = lines.join("\n");
|
|
assert!(joined.contains("sleep 0"), "first task visible:\n{joined}");
|
|
assert!(joined.contains("sleep 1"), "second task visible:\n{joined}");
|
|
assert!(
|
|
joined.contains("sleep 2"),
|
|
"last task must remain visible after opening search:\n{joined}",
|
|
);
|
|
assert!(joined.contains("search:"), "search bar visible:\n{joined}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_loop_row_truncates_before_kill_button() {
|
|
// A non-scrollable loop row with a long prompt must truncate before
|
|
// the `[✗]` kill button — nothing may render to its right. Regression:
|
|
// the scrollbar-padding column used to be filled with label text when
|
|
// the list wasn't scrollable, bleeding one cell past `[✗]`.
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"l1".into(),
|
|
make_scheduled_info(
|
|
"l1",
|
|
"every 1m",
|
|
"do a very long thing that definitely overflows the row width several times",
|
|
None,
|
|
),
|
|
);
|
|
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// One header + one loop row in a tall pane ⇒ not scrollable.
|
|
let area = Rect::new(0, 0, 40, 10);
|
|
let mut buf = Buffer::empty(area);
|
|
let layout = crate::appearance::LayoutConfig::default();
|
|
pane.render(
|
|
area,
|
|
&mut buf,
|
|
false,
|
|
&layout,
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
);
|
|
|
|
// Locate the `✗` kill glyph; every cell past the closing `]` must be
|
|
// blank (no scrollbar when not scrollable, no leaked label text).
|
|
let mut found = false;
|
|
for y in 0..area.height {
|
|
let row: Vec<String> = (0..area.width)
|
|
.map(|x| {
|
|
buf.cell((x, y))
|
|
.map(|c| c.symbol().to_string())
|
|
.unwrap_or_default()
|
|
})
|
|
.collect();
|
|
if let Some(x) = row.iter().position(|s| s == "\u{2717}") {
|
|
found = true;
|
|
for cell in &row[x + 2..] {
|
|
assert!(
|
|
cell.trim().is_empty(),
|
|
"non-blank cell after `[✗]` on loop row: {row:?}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
assert!(found, "expected a `✗` kill button on the loop row");
|
|
}
|
|
|
|
#[test]
|
|
fn render_shows_centered_down_arrow_when_overflowing() {
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
for i in 0..20 {
|
|
bg_tasks.insert(
|
|
format!("t{i}"),
|
|
make_bg_task(&format!("t{i}"), &format!("cmd {i}"), BgTaskStatus::Running),
|
|
);
|
|
}
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// A short panel forces the list to overflow; at the top of the list a
|
|
// centered ▼ appears on the reserved bottom row.
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 40, 6);
|
|
let joined = lines.join("\n");
|
|
assert!(
|
|
joined.contains('\u{25BC}'),
|
|
"expected centered ▼ when the list overflows, got:\n{joined}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_hides_down_arrow_at_bottom() {
|
|
let mut pane = TasksPane::new();
|
|
pane.overlay.show();
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
for i in 0..20 {
|
|
bg_tasks.insert(
|
|
format!("t{i}"),
|
|
make_bg_task(&format!("t{i}"), &format!("cmd {i}"), BgTaskStatus::Running),
|
|
);
|
|
}
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// Establish the viewport, then scroll to the very bottom.
|
|
let _ = render_pane_to_strings(&mut pane, &bg_tasks, 40, 6);
|
|
pane.list_state.set_scroll_offset(1000);
|
|
let lines = render_pane_to_strings(&mut pane, &bg_tasks, 40, 6);
|
|
let joined = lines.join("\n");
|
|
|
|
// At the bottom: ▲ shows (content above), ▼ must NOT (nothing below).
|
|
assert!(
|
|
joined.contains('\u{25B2}'),
|
|
"expected ▲ when scrolled down, got:\n{joined}"
|
|
);
|
|
assert!(
|
|
!joined.contains('\u{25BC}'),
|
|
"▼ must hide at the bottom of the list, got:\n{joined}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_sorts_running_before_done() {
|
|
let mut pane = TasksPane::new();
|
|
pane.show_done = true;
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert(
|
|
"done".into(),
|
|
make_bg_task("done", "echo done", BgTaskStatus::Done),
|
|
);
|
|
bg_tasks.insert(
|
|
"running".into(),
|
|
make_bg_task("running", "sleep 99", BgTaskStatus::Running),
|
|
);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
assert!(pane.items.len() >= 2);
|
|
assert!(pane.items[0].is_running(), "first entry should be running",);
|
|
assert!(!pane.items[1].is_running(), "second entry should be done",);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_groups_agents_before_bg_tasks() {
|
|
let mut pane = TasksPane::new();
|
|
pane.show_done = true;
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Done));
|
|
|
|
let mut subagents = HashMap::new();
|
|
let mut info = make_info();
|
|
info.finished = true;
|
|
info.status = Some("completed".into());
|
|
subagents.insert("cs-1".into(), info);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
assert_eq!(pane.items.len(), 2);
|
|
assert!(
|
|
matches!(&pane.items[0], TaskEntry::Agent { .. }),
|
|
"agents should sort before bg tasks",
|
|
);
|
|
assert!(
|
|
matches!(&pane.items[1], TaskEntry::BgTask { .. }),
|
|
"bg tasks should sort after agents",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_groups_monitors_as_their_own_block() {
|
|
// Monitors are BgTask entries but get their own contiguous group:
|
|
// subagents → one-shot tasks → monitors → scheduled.
|
|
let mut pane = TasksPane::new();
|
|
pane.show_done = true;
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Running));
|
|
let mut mon = make_bg_task("m1", "tail -f log", BgTaskStatus::Running);
|
|
mon.is_monitor = true;
|
|
bg_tasks.insert("m1".into(), mon);
|
|
|
|
let mut subagents = HashMap::new();
|
|
subagents.insert("cs-1".into(), make_info()); // running subagent
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
assert_eq!(pane.items.len(), 3);
|
|
assert!(
|
|
matches!(&pane.items[0], TaskEntry::Agent { .. }),
|
|
"subagent first",
|
|
);
|
|
assert!(
|
|
matches!(
|
|
&pane.items[1],
|
|
TaskEntry::BgTask {
|
|
is_monitor: false,
|
|
..
|
|
}
|
|
),
|
|
"one-shot task second",
|
|
);
|
|
assert!(
|
|
matches!(
|
|
&pane.items[2],
|
|
TaskEntry::BgTask {
|
|
is_monitor: true,
|
|
..
|
|
}
|
|
),
|
|
"monitor in its own group, after one-shot tasks",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn monitors_and_loops_share_one_watchers_section() {
|
|
// Monitor (BgTask) and loop (Scheduled) tasks render under a single
|
|
// "Watchers" header, monitors sorted before loops.
|
|
let mut pane = TasksPane::new();
|
|
pane.show_done = true;
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
let mut mon = make_bg_task("m1", "tail -f log", BgTaskStatus::Running);
|
|
mon.is_monitor = true;
|
|
bg_tasks.insert("m1".into(), mon);
|
|
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"l1".into(),
|
|
make_scheduled_info("l1", "every 1m", "do x", None),
|
|
);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// items: monitor first, then loop.
|
|
assert_eq!(pane.items.len(), 2);
|
|
assert!(matches!(
|
|
&pane.items[0],
|
|
TaskEntry::BgTask {
|
|
is_monitor: true,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(&pane.items[1], TaskEntry::Scheduled { .. }));
|
|
|
|
// entries: ONE Watchers header (count 2), then monitor, then loop.
|
|
assert_eq!(pane.entries.len(), 3);
|
|
let header_text: String = match &pane.entries[0] {
|
|
TaskEntry::Header {
|
|
group: GroupKind::Watchers,
|
|
styled,
|
|
} => styled.spans.iter().map(|s| s.content.as_ref()).collect(),
|
|
_ => panic!("expected Watchers header first"),
|
|
};
|
|
assert!(header_text.contains("Watchers"), "got: {header_text}");
|
|
assert!(header_text.contains('2'), "combined count: {header_text}");
|
|
assert!(matches!(
|
|
&pane.entries[1],
|
|
TaskEntry::BgTask {
|
|
is_monitor: true,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(&pane.entries[2], TaskEntry::Scheduled { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn sync_hides_done_by_default() {
|
|
let mut pane = TasksPane::new();
|
|
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert(
|
|
"done".into(),
|
|
make_bg_task("done", "echo done", BgTaskStatus::Done),
|
|
);
|
|
bg_tasks.insert(
|
|
"running".into(),
|
|
make_bg_task("running", "sleep 99", BgTaskStatus::Running),
|
|
);
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
assert_eq!(pane.items.len(), 1, "only running tasks shown by default");
|
|
assert!(pane.items[0].is_running());
|
|
}
|
|
|
|
#[test]
|
|
fn sync_inserts_group_headers_with_counts() {
|
|
let mut pane = TasksPane::new();
|
|
let mut bg_tasks = std::collections::BTreeMap::new();
|
|
bg_tasks.insert("t1".into(), make_bg_task("t1", "ls", BgTaskStatus::Running));
|
|
let mut subagents = HashMap::new();
|
|
subagents.insert("cs-1".into(), make_info()); // running subagent
|
|
|
|
pane.sync(
|
|
&bg_tasks,
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// Display list interleaves a header before each group's items:
|
|
// [Header(Subagents), Agent, Header(Tasks), BgTask].
|
|
assert_eq!(pane.entries.len(), 4);
|
|
assert!(matches!(
|
|
&pane.entries[0],
|
|
TaskEntry::Header {
|
|
group: GroupKind::Subagents,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. }));
|
|
assert!(matches!(
|
|
&pane.entries[2],
|
|
TaskEntry::Header {
|
|
group: GroupKind::Tasks,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(&pane.entries[3], TaskEntry::BgTask { .. }));
|
|
|
|
// The header text carries the label and the count.
|
|
let header_text: String = match &pane.entries[0] {
|
|
TaskEntry::Header { styled, .. } => {
|
|
styled.spans.iter().map(|s| s.content.as_ref()).collect()
|
|
}
|
|
_ => unreachable!(),
|
|
};
|
|
assert!(header_text.contains("Subagents"), "got: {header_text}");
|
|
assert!(header_text.contains('1'), "count in header: {header_text}");
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_group_hides_and_shows_items() {
|
|
let mut pane = TasksPane::new();
|
|
let mut subagents = HashMap::new();
|
|
subagents.insert("cs-1".into(), make_info());
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// Expanded: header + item.
|
|
assert_eq!(pane.entries.len(), 2);
|
|
assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. }));
|
|
|
|
// Collapse → only the header remains.
|
|
pane.toggle_group(GroupKind::Subagents);
|
|
assert_eq!(pane.entries.len(), 1);
|
|
assert!(matches!(
|
|
&pane.entries[0],
|
|
TaskEntry::Header {
|
|
group: GroupKind::Subagents,
|
|
..
|
|
}
|
|
));
|
|
|
|
// Expand → item returns.
|
|
pane.toggle_group(GroupKind::Subagents);
|
|
assert_eq!(pane.entries.len(), 2);
|
|
assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn arrow_keys_expand_and_collapse_group() {
|
|
// ← collapses, → expands (vs Enter / click which toggle). The arrow
|
|
// handler calls `set_group_collapsed`; exercise it directly.
|
|
let mut pane = TasksPane::new();
|
|
let mut subagents = HashMap::new();
|
|
subagents.insert("cs-1".into(), make_info());
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
assert_eq!(pane.entries.len(), 2);
|
|
|
|
// ← collapses the expanded group.
|
|
assert!(pane.set_group_collapsed(GroupKind::Subagents, true));
|
|
assert_eq!(pane.entries.len(), 1);
|
|
// ← again: already collapsed, no change.
|
|
assert!(!pane.set_group_collapsed(GroupKind::Subagents, true));
|
|
assert_eq!(pane.entries.len(), 1);
|
|
|
|
// → expands it again.
|
|
assert!(pane.set_group_collapsed(GroupKind::Subagents, false));
|
|
assert_eq!(pane.entries.len(), 2);
|
|
assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. }));
|
|
// → again: already expanded, no change.
|
|
assert!(!pane.set_group_collapsed(GroupKind::Subagents, false));
|
|
assert_eq!(pane.entries.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn emptied_group_forgets_collapse_state() {
|
|
// Collapse a group, let it empty out, then repopulate it: the new
|
|
// items must be visible (group auto-expands) rather than hidden under
|
|
// a stale collapsed header.
|
|
let mut pane = TasksPane::new();
|
|
let mut subagents = HashMap::new();
|
|
subagents.insert("cs-1".into(), make_info());
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
pane.toggle_group(GroupKind::Subagents);
|
|
assert!(pane.collapsed_groups.contains(&GroupKind::Subagents));
|
|
|
|
// Group empties (no subagents) → collapse state is forgotten.
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
assert!(!pane.collapsed_groups.contains(&GroupKind::Subagents));
|
|
|
|
// A new subagent arrives → shown expanded (header + item), not hidden.
|
|
let mut next = make_info();
|
|
next.child_session_id = "cs-2".into();
|
|
next.subagent_id = "sa-2".into();
|
|
let mut subagents2 = HashMap::new();
|
|
subagents2.insert("cs-2".into(), next);
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&subagents2,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
assert_eq!(pane.entries.len(), 2);
|
|
assert!(matches!(&pane.entries[1], TaskEntry::Agent { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn subagents_ordered_by_agent_type() {
|
|
let mut pane = TasksPane::new();
|
|
let mut subagents = HashMap::new();
|
|
// Two running subagents of different types; both running so the
|
|
// running-first key ties and the type order decides.
|
|
let mut plan = make_info();
|
|
plan.child_session_id = "cs-plan".into();
|
|
plan.subagent_type = "plan".into();
|
|
let mut explore = make_info();
|
|
explore.child_session_id = "cs-explore".into();
|
|
explore.subagent_type = "explore".into();
|
|
subagents.insert("cs-plan".into(), plan);
|
|
subagents.insert("cs-explore".into(), explore);
|
|
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&subagents,
|
|
&HashMap::new(),
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
|
|
// Ordered by agent type alphabetically: Explore before Plan.
|
|
let types: Vec<&str> = pane
|
|
.items
|
|
.iter()
|
|
.map(|e| match e {
|
|
TaskEntry::Agent { type_label, .. } => type_label.as_str(),
|
|
_ => panic!("expected Agent"),
|
|
})
|
|
.collect();
|
|
assert_eq!(types, vec!["Explore", "Plan"]);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_label_includes_type_badge() {
|
|
let info = make_info();
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let label = match &entry {
|
|
TaskEntry::Agent { label, .. } => label.as_str(),
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
assert!(
|
|
label.starts_with("Explore "),
|
|
"label should start with capitalized type badge: {label}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_label_includes_meta() {
|
|
let mut info = make_info();
|
|
info.persona = Some("researcher".into());
|
|
info.model = Some("grok-3".into());
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let label = match &entry {
|
|
TaskEntry::Agent { label, .. } => label.as_str(),
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
assert!(
|
|
label.contains("Researcher"),
|
|
"label should contain capitalized persona: {label}",
|
|
);
|
|
assert!(
|
|
label.contains("grok-3"),
|
|
"label should contain model: {label}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_label_no_meta_when_empty() {
|
|
let info = make_info();
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let label = match &entry {
|
|
TaskEntry::Agent { label, .. } => label.as_str(),
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
assert_eq!(label, "Explore Find API endpoints");
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_activity_suffix_renders_while_running_only() {
|
|
let mut info = make_info();
|
|
info.activity_label = Some("Running: cargo build".into());
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let (label, styled) = match &entry {
|
|
TaskEntry::Agent { label, styled, .. } => (label, styled),
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
let suffix = styled.spans.last().unwrap();
|
|
assert_eq!(suffix.content.as_ref(), " \u{2014} Running: cargo build");
|
|
assert_eq!(suffix.style.fg, Some(Theme::current().gray));
|
|
assert!(
|
|
!label.contains("cargo build"),
|
|
"activity must stay out of the searchable label: {label}"
|
|
);
|
|
|
|
// Finished rows drop the suffix even if a stale label lingers.
|
|
info.finished = true;
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let styled = match &entry {
|
|
TaskEntry::Agent { styled, .. } => styled,
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
assert!(
|
|
styled
|
|
.spans
|
|
.iter()
|
|
.all(|s| !s.content.contains("cargo build")),
|
|
"no activity suffix on finished rows: {styled:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_activity_suffix_caps_description() {
|
|
let long_desc = "d".repeat(60);
|
|
let mut info = make_info();
|
|
info.description = Arc::from(long_desc.as_str());
|
|
info.activity_label = Some("Thinking".into());
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let (label, styled) = match &entry {
|
|
TaskEntry::Agent { label, styled, .. } => (label, styled),
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
let desc = styled.spans[1].content.as_ref();
|
|
assert!(
|
|
desc.ends_with('\u{2026}') && desc.chars().count() <= 40,
|
|
"description must be capped when an activity suffix renders: {desc}"
|
|
);
|
|
assert!(
|
|
label.contains(&long_desc),
|
|
"the searchable label keeps the full description: {label}"
|
|
);
|
|
|
|
// Without an activity suffix the description renders uncapped.
|
|
info.activity_label = None;
|
|
let entry = TaskEntry::from_subagent(&info);
|
|
let styled = match &entry {
|
|
TaskEntry::Agent { styled, .. } => styled,
|
|
_ => panic!("expected Agent variant"),
|
|
};
|
|
assert_eq!(styled.spans[1].content.as_ref(), long_desc);
|
|
}
|
|
|
|
fn make_scheduled_info(
|
|
id: &str,
|
|
schedule: &str,
|
|
prompt: &str,
|
|
next: Option<&str>,
|
|
) -> ScheduledTaskInfo {
|
|
ScheduledTaskInfo {
|
|
task_id: id.to_string(),
|
|
prompt: prompt.to_string(),
|
|
human_schedule: schedule.to_string(),
|
|
created_at: std::time::Instant::now(),
|
|
next_fire_at: next.map(|s| s.to_string()),
|
|
tag: "loop".into(),
|
|
last_subagent_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_label_shows_next_in_countdown() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
// future next
|
|
let next = (chrono::Utc::now() + chrono::Duration::seconds(125)).to_rfc3339();
|
|
scheduled.insert(
|
|
"t1".into(),
|
|
make_scheduled_info("t1", "every 1m", "do x", Some(&next)),
|
|
);
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
label.contains("next in 2m"),
|
|
"expected countdown in label: {label}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_label_shows_running_now_on_cron_match() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"cron1".into(),
|
|
make_scheduled_info("cron1", "every 5m", "loop", None),
|
|
);
|
|
pane.sync(
|
|
&std::collections::BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
Some("cron1"),
|
|
&HashSet::new(),
|
|
);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
label.contains("(running)"),
|
|
"expected (running) when cron matches: {label}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_provisional_shows_starting() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"provisional-abc".into(),
|
|
make_scheduled_info("provisional-abc", "every 10s", "soon", None),
|
|
);
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
label.contains("(starting)"),
|
|
"expected (starting) for provisional: {label}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_queued_shows_queued() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"q1".into(),
|
|
make_scheduled_info("q1", "every 5m", "check", None),
|
|
);
|
|
let mut queued = HashSet::new();
|
|
queued.insert("q1");
|
|
pane.sync(&BTreeMap::new(), &HashMap::new(), &scheduled, None, &queued);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
label.contains("(queued)"),
|
|
"expected (queued) when task is in pending_prompts: {label}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_past_shows_due_now() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
let past = (chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339();
|
|
scheduled.insert(
|
|
"due".into(),
|
|
make_scheduled_info("due", "every 1h", "past", Some(&past)),
|
|
);
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
label.contains("due now"),
|
|
"expected due now for past: {label}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_unicode_prompt_safe_no_panic() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
let unicode_prompt = "测试emoji🚀".repeat(20); // multi-byte >60 bytes
|
|
scheduled.insert(
|
|
"uni".into(),
|
|
make_scheduled_info("uni", "every 1s", &unicode_prompt, None),
|
|
);
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
let entry = &pane.items[0];
|
|
let label = match entry {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(label.contains("Loop"), "tag rendered in label");
|
|
assert!(
|
|
label.contains("...") || label.len() > 10,
|
|
"preview truncated or full"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_bad_next_fire_at_falls_back_to_approx() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"bad".into(),
|
|
make_scheduled_info("bad", "every 30s", "fallback", Some("not-a-date")),
|
|
);
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
label.contains("next in") || label.contains("due now"),
|
|
"bad rfc should fallback to approx countdown: {label}"
|
|
);
|
|
assert!(
|
|
!label.contains("~soon"),
|
|
"should not ~soon on rfc parse fail fallback: {label}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_unknown_schedule_no_suffix() {
|
|
let mut pane = TasksPane::new();
|
|
let mut scheduled = HashMap::new();
|
|
scheduled.insert(
|
|
"unk".into(),
|
|
make_scheduled_info("unk", "unknown schedule", "x", None),
|
|
);
|
|
pane.sync(
|
|
&BTreeMap::new(),
|
|
&HashMap::new(),
|
|
&scheduled,
|
|
None,
|
|
&HashSet::new(),
|
|
);
|
|
let label = match &pane.items[0] {
|
|
TaskEntry::Scheduled { label, .. } => label,
|
|
_ => panic!("expected Scheduled"),
|
|
};
|
|
assert!(
|
|
!label.contains("(next") && !label.contains("(running") && !label.contains("(queued"),
|
|
"unknown schedule should have no status suffix: {label}"
|
|
);
|
|
}
|
|
}
|