use std::time::Instant;
use chrono::{DateTime, Utc};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use uuid::Uuid;
use crate::app::events::{AppTask, BackgroundKind, RunProgressKind, TaskList, TaskRun};
use crate::entities::subagent::RunOutcome;
use crate::screens::awaited_chat::AwaitedChat;
use crate::shared::i18n::Locale;
use crate::shared::keys;
use crate::shared::theme::Palette;
use crate::shared::ui::{keep_visible, render_scrollbar, screen_chrome};
use crate::shared::wrap;
use crate::widgets::chat_list::run_state_key;
use crate::widgets::help_dialog::{HelpContext, HelpSection};
pub(crate) static HELP_SECTION: HelpSection = HelpSection {
title: "ui.help.sec.tasks",
context: Some(HelpContext::Tasks),
rows: &[
("↑/↓", "ui.help.tk_select"),
("Enter", "ui.help.tk_open"),
("P", "ui.help.tk_parent"),
("F6", "ui.help.tk_stop"),
("Esc", "ui.help.tk_close"),
],
openers: &[],
};
const PAGE_STEP: usize = 10;
const REPAINT_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
const PARENT_MAX: usize = 22;
const STATE_MAX: usize = 28;
const TITLE_MIN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TasksIntent {
Close,
Quit,
OpenRun(Uuid),
StopRun(Uuid),
OpenParent(Uuid),
StopTask(BackgroundKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Row {
Run(usize),
App(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ident {
Run(Uuid),
App(BackgroundKind),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Stoppable {
Run(Uuid),
Task(BackgroundKind),
}
pub struct TasksScreen {
list: Option<TaskList>,
palette: Palette,
loc: &'static Locale,
selected: usize,
scroll: usize,
last_drawn: Instant,
awaited: AwaitedChat,
}
impl TasksScreen {
pub fn new(palette: Palette, loc: &'static Locale) -> Self {
Self {
list: None,
palette,
loc,
selected: 0,
scroll: 0,
last_drawn: Instant::now(),
awaited: AwaitedChat::Nothing,
}
}
pub fn await_chat(&mut self, chat: Uuid) {
self.awaited = AwaitedChat::Chat(chat);
}
pub fn take_awaited(&mut self, id: Uuid) -> bool {
self.awaited.take_if_arrived(id)
}
pub fn set_list(&mut self, list: TaskList) {
let previous = self.selected_ident();
self.list = Some(list);
let rows = self.rows();
self.selected = previous
.and_then(|ident| {
rows.iter()
.position(|row| self.ident_of(*row) == Some(ident))
})
.unwrap_or_else(|| self.selected.min(rows.len().saturating_sub(1)));
}
pub fn set_palette(&mut self, palette: Palette) {
self.palette = palette;
}
pub fn set_loc(&mut self, loc: &'static Locale) {
self.loc = loc;
}
pub fn has_running(&self) -> bool {
self.list
.as_ref()
.is_some_and(|l| l.runs.iter().any(|r| r.running))
}
pub fn has_app_task_running(&self) -> bool {
self.list
.as_ref()
.is_some_and(|l| l.app.iter().any(|t| t.running))
}
pub fn needs_repaint(&self) -> bool {
(self.has_running() || self.has_app_task_running())
&& self.last_drawn.elapsed() >= REPAINT_EVERY
}
fn rows(&self) -> Vec<Row> {
let Some(list) = &self.list else {
return Vec::new();
};
(0..list.runs.len())
.map(Row::Run)
.chain((0..list.app.len()).map(Row::App))
.collect()
}
fn selected_row(&self) -> Option<Row> {
self.rows().get(self.selected).copied()
}
fn selected_run(&self) -> Option<&TaskRun> {
match self.selected_row()? {
Row::Run(i) => self.list.as_ref()?.runs.get(i),
Row::App(_) => None,
}
}
fn ident_of(&self, row: Row) -> Option<Ident> {
let list = self.list.as_ref()?;
Some(match row {
Row::Run(i) => Ident::Run(list.runs.get(i)?.id),
Row::App(i) => Ident::App(list.app.get(i)?.kind),
})
}
fn selected_ident(&self) -> Option<Ident> {
self.ident_of(self.selected_row()?)
}
fn selected_stoppable(&self) -> Option<Stoppable> {
let list = self.list.as_ref()?;
match self.selected_row()? {
Row::Run(i) => list
.runs
.get(i)
.filter(|r| r.running && r.background)
.map(|r| Stoppable::Run(r.id)),
Row::App(i) => list
.app
.get(i)
.filter(|t| t.running)
.map(|t| Stoppable::Task(t.kind)),
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<TasksIntent> {
if key.kind != KeyEventKind::Press {
return None;
}
self.awaited.clear();
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if key.code == KeyCode::F(10) || (ctrl && keys::hotkey_char(&key) == Some('q')) {
return Some(TasksIntent::Quit);
}
if ctrl {
return None;
}
let last = self.rows().len().saturating_sub(1);
match key.code {
KeyCode::Esc => return Some(TasksIntent::Close),
KeyCode::Up => self.selected = self.selected.saturating_sub(1),
KeyCode::Down => self.selected = (self.selected + 1).min(last),
KeyCode::PageUp => self.selected = self.selected.saturating_sub(PAGE_STEP),
KeyCode::PageDown => self.selected = (self.selected + PAGE_STEP).min(last),
KeyCode::Home => self.selected = 0,
KeyCode::End => self.selected = last,
KeyCode::Enter => return self.selected_run().map(|r| TasksIntent::OpenRun(r.id)),
KeyCode::F(6) => {
return self.selected_stoppable().map(|s| match s {
Stoppable::Run(id) => TasksIntent::StopRun(id),
Stoppable::Task(kind) => TasksIntent::StopTask(kind),
});
}
KeyCode::Char(_) if keys::hotkey_char(&key) == Some('p') => {
return self
.selected_run()
.map(|r| TasksIntent::OpenParent(r.parent));
}
_ => {}
}
None
}
fn hints(&self) -> Vec<(&'static str, &'static str, bool)> {
let loc = self.loc;
let mut hk = vec![("↑↓", loc.t("ui.tasks.hk.select"), false)];
if self.selected_run().is_some() {
hk.push(("Enter", loc.t("ui.tasks.hk.open"), false));
hk.push(("P", loc.t("ui.tasks.hk.parent"), false));
}
if self.selected_stoppable().is_some() {
hk.push(("F6", loc.t("ui.tasks.hk.stop"), false));
}
hk.extend([
("F1", loc.t("ui.tasks.hk.help"), false),
("Esc", loc.t("ui.tasks.hk.back"), false),
("Ctrl+Q", loc.t("ui.tasks.hk.quit"), false),
]);
hk
}
fn summary(&self) -> Option<String> {
let list = self.list.as_ref()?;
let running = list.runs.iter().filter(|r| r.running).count();
let landed = list.runs.len() - running + list.more_landed;
Some(self.loc.tf(
"ui.tasks.summary",
&[
("running", &running.to_string()),
("landed", &landed.to_string()),
],
))
}
pub fn render(&mut self, frame: &mut Frame) {
self.last_drawn = Instant::now();
let palette = self.palette;
let loc = self.loc;
let hk = self.hints();
let chrome = screen_chrome(
frame,
&palette,
format!(
"{} {}",
palette.glyphs().title_marker,
loc.t("ui.tasks.title")
),
self.summary(),
&hk,
);
let (inner, status_area, hotkeys) = (chrome.inner, chrome.status, chrome.hotkeys);
frame.render_widget(Paragraph::new(hotkeys), status_area);
let Some(list) = &self.list else {
frame.render_widget(
Paragraph::new(Line::styled(
loc.t("ui.tasks.loading"),
palette.muted_style(),
)),
inner,
);
return;
};
let lines = self.lines(list, inner.width as usize, Utc::now());
let total = lines.len();
let view = inner.height as usize;
let selected_line = lines
.iter()
.position(|(_, row)| *row == Some(self.selected))
.unwrap_or(0);
self.scroll = keep_visible(self.scroll, selected_line, view);
let shown: Vec<Line<'static>> = lines
.into_iter()
.skip(self.scroll)
.take(view)
.map(|(line, _)| line)
.collect();
frame.render_widget(Paragraph::new(shown), inner);
render_scrollbar(
frame,
chrome.panel,
total,
view,
self.scroll,
true,
&palette,
);
}
fn lines(
&self,
list: &TaskList,
width: usize,
now: DateTime<Utc>,
) -> Vec<(Line<'static>, Option<usize>)> {
let p = &self.palette;
let loc = self.loc;
let header = |key: &str| {
(
Line::from(Span::styled(
loc.t(key).to_string(),
Style::new().fg(p.accent).add_modifier(Modifier::BOLD),
)),
None,
)
};
let mut out = vec![header("ui.tasks.sec.runs")];
if list.runs.is_empty() {
let note = Line::from(Span::styled(
format!(" {}", loc.t("ui.tasks.no_runs")),
p.muted_style(),
));
out.extend(
wrap::wrap_line(¬e, width.max(1))
.into_iter()
.map(|line| (line, None)),
);
} else {
let cols = Columns::measure(self, list, width, now);
for (i, run) in list.runs.iter().enumerate() {
let selected = self.selected_row() == Some(Row::Run(i));
out.push((self.run_line(run, &cols, selected, now), Some(i)));
}
if list.more_landed > 0 {
out.push((
Line::from(Span::styled(
format!(
" {}",
loc.tf("ui.tasks.more", &[("n", &list.more_landed.to_string())])
),
p.muted_style(),
)),
None,
));
}
}
out.push((Line::default(), None));
out.push(header("ui.tasks.sec.app"));
let base = list.runs.len();
let label_w = list
.app
.iter()
.map(|t| wrap::str_width(loc.t(t.kind.label_key())))
.max()
.unwrap_or(0);
for (i, task) in list.app.iter().enumerate() {
let selected = self.selected_row() == Some(Row::App(i));
out.push((self.app_line(task, label_w, selected), Some(base + i)));
}
out
}
fn run_line(
&self,
run: &TaskRun,
cols: &Columns,
selected: bool,
now: DateTime<Utc>,
) -> Line<'static> {
let p = &self.palette;
let (glyph, glyph_style) = self.glyph_of(run);
let title_style = if selected {
Style::new().fg(p.text).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(p.text)
};
let (title, title_w) = wrap::truncate_to_width(&run.title, cols.title);
let mut spans = vec![
Span::styled(
if selected { "▌" } else { " " }.to_string(),
p.accent_style(),
),
Span::styled(format!("{glyph} "), glyph_style),
Span::styled(
format!(
"{title}{:pad$}",
"",
pad = cols.title.saturating_sub(title_w)
),
title_style,
),
];
if cols.parent > 0 {
let (parent, w) = wrap::truncate_to_width(&run.parent_title, cols.parent);
spans.push(Span::styled(
format!(" {parent}{:pad$}", "", pad = cols.parent.saturating_sub(w)),
p.muted_style(),
));
}
if cols.state > 0 {
let (state, w) = wrap::truncate_to_width(&self.state_of(run), cols.state);
spans.push(Span::styled(
format!(" {state}{:pad$}", "", pad = cols.state.saturating_sub(w)),
if run.running {
Style::new().fg(p.text)
} else {
glyph_style
},
));
}
spans.push(Span::styled(
format!(" {:>w$}", time_of(run, now), w = cols.time),
p.muted_style(),
));
if cols.tokens > 0 {
spans.push(Span::styled(
format!(" {:>w$}", tokens_label(run.tokens), w = cols.tokens),
p.muted_style(),
));
}
Line::from(spans)
}
fn app_line(&self, task: &AppTask, label_w: usize, selected: bool) -> Line<'static> {
let p = &self.palette;
let loc = self.loc;
let label = loc.t(task.kind.label_key());
let pad = label_w.saturating_sub(wrap::str_width(label));
let (glyph, state, state_style) = if task.running && task.waiting {
(
p.glyphs().background,
loc.t("ui.tasks.app.waiting"),
p.muted_style(),
)
} else if task.running {
(
p.glyphs().background,
loc.t("ui.tasks.app.running"),
p.accent_style(),
)
} else {
(" ", loc.t("ui.tasks.app.idle"), p.muted_style())
};
Line::from(vec![
Span::styled(
if selected { "▌" } else { " " }.to_string(),
p.accent_style(),
),
Span::styled(format!("{glyph} "), p.accent_style()),
Span::styled(
format!("{label}{:pad$}", "", pad = pad),
if selected {
Style::new().fg(p.text).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(p.text)
},
),
Span::styled(format!(" {state}"), state_style),
])
}
fn glyph_of(&self, run: &TaskRun) -> (&'static str, Style) {
let p = &self.palette;
let g = p.glyphs();
if run.running {
return (g.busy, p.accent_style());
}
match run.outcome {
Some(RunOutcome::Completed) => (g.ok, p.success_style()),
Some(RunOutcome::Cancelled | RunOutcome::TimedOut | RunOutcome::Failed) => {
(g.failed, Style::new().fg(p.error))
}
Some(RunOutcome::RoundLimit) | None => (g.warn, Style::new().fg(p.warning)),
}
}
fn state_of(&self, run: &TaskRun) -> String {
let loc = self.loc;
if run.running {
return match &run.position {
None => loc.t("ui.tasks.pos.starting").to_string(),
Some(pos) => {
let n = pos.round.to_string();
match (pos.kind, &pos.tool) {
(RunProgressKind::DialogueLine, _) => {
loc.tf("ui.tasks.pos.line", &[("line", &n)])
}
(RunProgressKind::DialogueDirector, _) => {
loc.t("ui.tasks.pos.director").to_string()
}
(RunProgressKind::Subagent, Some(tool)) => {
loc.tf("ui.tasks.pos.round_tool", &[("round", &n), ("tool", tool)])
}
(RunProgressKind::Subagent, None) => {
loc.tf("ui.tasks.pos.round", &[("round", &n)])
}
}
}
};
}
match run_state_key(run.outcome, false, run.background) {
Some(key) => loc.t(key).to_string(),
None => loc.t("ui.tasks.completed").to_string(),
}
}
}
struct Columns {
title: usize,
parent: usize,
state: usize,
time: usize,
tokens: usize,
}
impl Columns {
fn measure(screen: &TasksScreen, list: &TaskList, width: usize, now: DateTime<Utc>) -> Self {
let widest = |f: &dyn Fn(&TaskRun) -> String| {
list.runs
.iter()
.map(|r| wrap::str_width(&f(r)))
.max()
.unwrap_or(0)
};
let mut cols = Columns {
title: 0,
parent: widest(&|r| r.parent_title.clone()).min(PARENT_MAX),
state: widest(&|r| screen.state_of(r)).min(STATE_MAX),
time: widest(&|r| time_of(r, now)),
tokens: widest(&|r| tokens_label(r.tokens)),
};
let fixed = |c: &Columns| 3 + gap(c.parent) + gap(c.state) + 2 + c.time + gap(c.tokens);
for shed in [
&mut |c: &mut Columns| c.tokens = 0,
&mut |c: &mut Columns| c.parent = 0,
&mut |c: &mut Columns| c.state = 0,
] as [&mut dyn FnMut(&mut Columns); 3]
{
if width.saturating_sub(fixed(&cols)) >= TITLE_MIN {
break;
}
shed(&mut cols);
}
cols.title = width.saturating_sub(fixed(&cols)).max(1);
cols
}
}
fn gap(col: usize) -> usize {
if col == 0 { 0 } else { 2 + col }
}
fn time_of(run: &TaskRun, now: DateTime<Utc>) -> String {
if run.running {
elapsed_label(run.created_at, now)
} else {
finish_label(run.finished_at.unwrap_or(run.created_at), now)
}
}
fn elapsed_label(created: DateTime<Utc>, now: DateTime<Utc>) -> String {
let secs = (now - created).num_seconds().max(0);
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
fn finish_label(at: DateTime<Utc>, now: DateTime<Utc>) -> String {
let local = at.with_timezone(&chrono::Local);
if local.date_naive() == now.with_timezone(&chrono::Local).date_naive() {
local.format("%H:%M").to_string()
} else {
local.format("%Y-%m-%d").to_string()
}
}
fn tokens_label(tokens: u64) -> String {
match tokens {
0 => String::new(),
t if t < 1_000 => t.to_string(),
t if t < 10_000 => format!("{:.1}k", t as f64 / 1000.0),
t => format!("{}k", t / 1000),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::events::SubagentProgress;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn loc() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn en() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::En)
}
fn landed(title: &str) -> TaskRun {
TaskRun {
finished_at: Some(Utc::now() - chrono::Duration::minutes(5)),
outcome: Some(RunOutcome::Completed),
running: false,
tokens: 3400,
..TaskRun::fixture(title)
}
}
fn running(title: &str) -> TaskRun {
TaskRun {
tokens: 1200,
position: Some(SubagentProgress {
name: title.into(),
round: 3,
tool: Some("fs_read".into()),
kind: RunProgressKind::Subagent,
}),
..TaskRun::fixture(title)
}
}
fn app_tasks() -> Vec<AppTask> {
[
BackgroundKind::Reflection,
BackgroundKind::Consolidation,
BackgroundKind::SelfConsolidation,
BackgroundKind::Compaction,
]
.into_iter()
.map(|kind| AppTask {
kind,
running: kind == BackgroundKind::Reflection,
waiting: false,
})
.collect()
}
fn list(runs: Vec<TaskRun>) -> TaskList {
TaskList {
runs,
more_landed: 0,
app: app_tasks(),
}
}
fn screen(runs: Vec<TaskRun>) -> TasksScreen {
let mut s = TasksScreen::new(Palette::default(), loc());
s.set_list(list(runs));
s
}
fn press(s: &mut TasksScreen, code: KeyCode) -> Option<TasksIntent> {
s.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn keys(s: &TasksScreen) -> Vec<&'static str> {
s.hints().iter().map(|(k, _, _)| *k).collect()
}
fn text_at(s: &mut TasksScreen, width: u16, height: u16) -> String {
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
let mut out = String::new();
for y in buf.area.top()..buf.area.bottom() {
for x in buf.area.left()..buf.area.right() {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn text(s: &mut TasksScreen) -> String {
text_at(s, 100, 24)
}
#[test]
fn the_footer_and_the_keys_follow_the_selected_row() {
let out = running("Critic");
let done = landed("Reviewer");
let (out_id, done_id, done_parent) = (out.id, done.id, done.parent);
let mut s = screen(vec![out, done]);
assert!(keys(&s).contains(&"Enter") && keys(&s).contains(&"P"));
assert!(keys(&s).contains(&"F6"), "{:?}", s.hints());
assert_eq!(
press(&mut s, KeyCode::F(6)),
Some(TasksIntent::StopRun(out_id))
);
assert_eq!(
press(&mut s, KeyCode::Enter),
Some(TasksIntent::OpenRun(out_id))
);
press(&mut s, KeyCode::Down);
assert!(keys(&s).contains(&"Enter"));
assert!(!keys(&s).contains(&"F6"), "{:?}", s.hints());
assert_eq!(press(&mut s, KeyCode::F(6)), None);
assert_eq!(
press(&mut s, KeyCode::Enter),
Some(TasksIntent::OpenRun(done_id))
);
assert_eq!(
press(&mut s, KeyCode::Char('p')),
Some(TasksIntent::OpenParent(done_parent))
);
press(&mut s, KeyCode::Down);
for k in ["Enter", "P"] {
assert!(
!keys(&s).contains(&k),
"{k} offered on a task row: {:?}",
s.hints()
);
}
assert!(keys(&s).contains(&"F6"), "{:?}", s.hints());
assert_eq!(press(&mut s, KeyCode::Enter), None);
assert_eq!(
press(&mut s, KeyCode::F(6)),
Some(TasksIntent::StopTask(BackgroundKind::Reflection))
);
assert_eq!(press(&mut s, KeyCode::Char('p')), None);
press(&mut s, KeyCode::Down);
assert!(!keys(&s).contains(&"F6"), "{:?}", s.hints());
assert_eq!(press(&mut s, KeyCode::F(6)), None);
assert!(keys(&s).contains(&"F1") && keys(&s).contains(&"Esc"));
}
#[test]
fn a_waiting_task_can_be_stopped_too() {
let mut s = TasksScreen::new(Palette::default(), loc());
let mut l = list(Vec::new());
l.app[3] = AppTask {
kind: BackgroundKind::Compaction,
running: true,
waiting: true,
};
s.set_list(l);
press(&mut s, KeyCode::End);
assert!(keys(&s).contains(&"F6"), "{:?}", s.hints());
assert_eq!(
press(&mut s, KeyCode::F(6)),
Some(TasksIntent::StopTask(BackgroundKind::Compaction))
);
}
#[test]
fn a_turn_child_cannot_be_stopped_from_here() {
let mut child = running("Helper");
child.background = false;
let mut s = screen(vec![child]);
assert!(!keys(&s).contains(&"F6"), "{:?}", s.hints());
assert_eq!(press(&mut s, KeyCode::F(6)), None);
assert!(keys(&s).contains(&"Enter"), "it can still be opened");
}
#[test]
fn every_footer_key_is_in_the_help_section() {
let s = screen(vec![running("Critic")]);
for key in keys(&s) {
if key == "F1" || key == "Ctrl+Q" || key == "↑↓" {
continue;
}
assert!(
HELP_SECTION.rows.iter().any(|(k, _)| *k == key),
"{key} is advertised but has no help row"
);
}
assert!(HELP_SECTION.rows.iter().any(|(k, _)| *k == "↑/↓"));
}
#[test]
fn both_sections_and_every_row_are_drawn() {
let mut s = screen(vec![running("Critic"), landed("Reviewer")]);
let shown = text(&mut s);
for needle in [
loc().t("ui.tasks.sec.runs"),
loc().t("ui.tasks.sec.app"),
"Critic",
"Reviewer",
"Plans",
"fs_read",
"3.4k",
"1.2k",
loc().t("ui.tasks.completed"),
loc().t("ui.tasks.app.reflection"),
loc().t("ui.tasks.app.running"),
loc().t("ui.tasks.app.idle"),
] {
assert!(shown.contains(needle), "missing {needle:?}:\n{shown}");
}
}
#[test]
fn the_columns_line_up_across_rows() {
let mut s = screen(vec![running("Critic"), landed("Reviewer")]);
let shown = text(&mut s);
let row = |needle: &str| -> String {
shown
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no row with {needle}: {shown}"))
.to_string()
};
let col_of = |row: &str, needle: &str| row[..row.find(needle).unwrap()].chars().count();
let (a, b) = (row("Critic"), row("Reviewer"));
assert_eq!(col_of(&a, "Plans"), col_of(&b, "Plans"), "{shown}");
assert_eq!(col_of(&a, "1.2k"), col_of(&b, "3.4k"), "{shown}");
}
#[test]
fn waiting_empty_and_capped_states_explain_themselves() {
let mut s = TasksScreen::new(Palette::default(), loc());
assert!(text(&mut s).contains(loc().t("ui.tasks.loading")));
assert!(
keys(&s).contains(&"Esc"),
"a waiting screen can still be left"
);
s.set_list(list(Vec::new()));
let shown = text(&mut s);
assert!(shown.contains("call_subagent"), "{shown}");
assert!(
shown.contains(loc().t("ui.tasks.app.reflection")),
"the app's own rows are there even with no runs: {shown}"
);
let mut capped = list(vec![landed("Reviewer")]);
capped.more_landed = 7;
s.set_list(capped);
let shown = text(&mut s);
assert!(shown.contains("7"), "{shown}");
assert!(
shown.contains(&loc().tf("ui.tasks.more", &[("n", "7")])),
"{shown}"
);
}
#[test]
fn the_summary_counts_past_the_cap() {
let mut capped = list(vec![running("Critic"), landed("Reviewer")]);
capped.more_landed = 7;
let mut s = TasksScreen::new(Palette::default(), en());
s.set_list(capped);
assert_eq!(s.summary().as_deref(), Some("1 running · 8 landed"));
}
#[test]
fn the_selection_is_clamped_at_both_ends() {
let mut s = screen(vec![landed("a"), landed("b")]);
for _ in 0..5 {
press(&mut s, KeyCode::Up);
}
assert_eq!(s.selected, 0);
press(&mut s, KeyCode::End);
assert_eq!(s.selected, 2 + app_tasks().len() - 1);
for _ in 0..5 {
press(&mut s, KeyCode::Down);
}
assert_eq!(s.selected, 2 + app_tasks().len() - 1);
press(&mut s, KeyCode::Home);
assert_eq!(s.selected, 0);
press(&mut s, KeyCode::PageDown);
assert_eq!(
s.selected,
2 + app_tasks().len() - 1,
"a page past the end stops at it"
);
}
#[test]
fn the_selection_follows_the_run_not_the_index() {
let (a, b) = (running("a"), landed("b"));
let b_id = b.id;
let mut s = screen(vec![a.clone(), b.clone()]);
press(&mut s, KeyCode::Down);
assert_eq!(s.selected_run().map(|r| r.id), Some(b_id));
let mut a_landed = a;
a_landed.running = false;
a_landed.outcome = Some(RunOutcome::Cancelled);
s.set_list(list(vec![running("c"), a_landed, b]));
assert_eq!(s.selected_run().map(|r| r.id), Some(b_id));
press(&mut s, KeyCode::End);
s.set_list(list(vec![running("d")]));
assert_eq!(
s.selected_row(),
Some(Row::App(app_tasks().len() - 1)),
"the last task is still selected"
);
}
#[test]
fn losing_the_selected_run_clamps() {
let mut s = screen(vec![landed("a")]);
s.set_list(TaskList::default());
assert_eq!(s.selected, 0);
assert_eq!(press(&mut s, KeyCode::Enter), None);
}
#[test]
fn the_selection_stays_visible_when_the_list_is_tall() {
let runs: Vec<TaskRun> = (0..30).map(|i| landed(&format!("run-{i:02}"))).collect();
let mut s = screen(runs);
press(&mut s, KeyCode::End);
let shown = text_at(&mut s, 100, 12);
assert!(
shown.contains(loc().t("ui.tasks.app.compaction")),
"the last task row must be visible after End:\n{shown}"
);
assert!(!shown.contains("run-00"), "the top scrolled away:\n{shown}");
press(&mut s, KeyCode::Home);
let shown = text_at(&mut s, 100, 12);
assert!(shown.contains("run-00"), "{shown}");
}
#[test]
fn a_narrow_screen_keeps_the_title() {
let mut s = screen(vec![running("A run with a fairly long title")]);
let shown = text_at(&mut s, 40, 12);
assert!(
shown.contains("A run with"),
"the title start must survive at 40 columns:\n{shown}"
);
}
#[test]
fn a_title_is_cut_by_the_column_and_marked() {
let long = "A run whose title runs on well past any sensible column and then some more";
let mut s = screen(vec![landed(long)]);
let shown = text_at(&mut s, 160, 12);
assert!(
shown.contains(long),
"the whole title must fit at 160:
{shown}"
);
let shown = text_at(&mut s, 90, 12);
assert!(
!shown.contains(long),
"it cannot fit at 90:
{shown}"
);
assert!(shown.contains("A run whose title"), "{shown}");
let row = shown
.lines()
.find(|l| l.contains("A run whose title"))
.unwrap();
assert!(row.contains('…'), "a cut title says so: {row}");
}
#[test]
fn elapsed_and_finish_labels() {
let t0 = Utc::now();
let at = |secs: i64| t0 + chrono::Duration::seconds(secs);
assert_eq!(elapsed_label(t0, at(0)), "0:00");
assert_eq!(elapsed_label(t0, at(252)), "4:12");
assert_eq!(elapsed_label(t0, at(3600 + 65)), "1:01:05");
assert_eq!(
elapsed_label(at(10), t0),
"0:00",
"a clock behind the start"
);
assert_eq!(finish_label(t0, t0).len(), 5);
let long_ago = t0 - chrono::Duration::days(3);
assert_eq!(finish_label(long_ago, t0).len(), 10);
}
#[test]
fn tokens_label_scales() {
assert_eq!(tokens_label(0), "");
assert_eq!(tokens_label(999), "999");
assert_eq!(tokens_label(1234), "1.2k");
assert_eq!(tokens_label(12_345), "12k");
}
#[test]
fn the_repaint_tick_follows_the_running_rows() {
let mut s = screen(vec![landed("a")]);
assert!(!s.has_running());
assert!(!s.needs_repaint());
s.set_list(list(vec![running("b")]));
assert!(s.has_running());
assert!(!s.needs_repaint(), "just drawn — no repaint yet");
s.last_drawn = Instant::now() - REPAINT_EVERY;
assert!(s.needs_repaint());
}
#[test]
fn the_position_is_worded_for_every_kind() {
let s = TasksScreen::new(Palette::default(), en());
let mut run = running("Critic");
assert_eq!(s.state_of(&run), "round 3 · fs_read");
run.position.as_mut().unwrap().tool = None;
assert_eq!(s.state_of(&run), "round 3");
run.position.as_mut().unwrap().kind = RunProgressKind::DialogueLine;
assert_eq!(s.state_of(&run), "line 3");
run.position.as_mut().unwrap().kind = RunProgressKind::DialogueDirector;
assert_eq!(s.state_of(&run), "director");
run.position = None;
assert_eq!(s.state_of(&run), "starting");
let mut done = landed("Reviewer");
assert_eq!(s.state_of(&done), "completed");
done.outcome = Some(RunOutcome::Cancelled);
assert_eq!(s.state_of(&done), en().t("ui.chatlist.run.cancelled"));
done.outcome = None;
assert_eq!(s.state_of(&done), en().t("ui.chatlist.run.unfinished"));
done.background = false;
assert_eq!(s.state_of(&done), en().t("ui.chatlist.run.interrupted"));
}
#[test]
fn a_waiting_task_says_so() {
let mut list = list(Vec::new());
list.app[1] = AppTask {
kind: BackgroundKind::Consolidation,
running: true,
waiting: true,
};
let mut s = TasksScreen::new(Palette::default(), loc());
s.set_list(list);
let shown = text(&mut s);
assert!(shown.contains(loc().t("ui.tasks.app.waiting")), "{shown}");
assert!(shown.contains(loc().t("ui.tasks.app.running")), "{shown}");
}
#[test]
fn quit_and_close() {
let mut s = screen(vec![landed("a")]);
assert_eq!(press(&mut s, KeyCode::F(10)), Some(TasksIntent::Quit));
assert_eq!(press(&mut s, KeyCode::Esc), Some(TasksIntent::Close));
}
}