1use ratatui::style::Style;
22use ratatui::text::{Line, Span};
23
24use crate::domain::tasks::{TaskItem, TaskStatus, TaskStore};
25use crate::domain::{TaskOrigin, TurnState};
26use crate::render::theme::Theme;
27
28use super::truncate_to_cells;
29
30const MAX_ROWS: usize = 8;
32
33pub fn tasks_visible(store: &TaskStore, turn: &TurnState, collapsed: bool, attached: bool) -> bool {
39 if store.is_empty() {
40 return false;
41 }
42 if collapsed && !attached {
43 return false;
44 }
45 !(matches!(turn, TurnState::Idle) && store.all_done())
46}
47
48pub fn build_task_lines(
53 store: &TaskStore,
54 collapsed: bool,
55 attached: bool,
56 width: u16,
57 theme: &Theme,
58) -> Vec<Line<'static>> {
59 if width < 10 {
60 return Vec::new();
61 }
62 let width = width as usize;
63 let meta_style = Style::new()
64 .fg(theme.colors.text_secondary.to_color())
65 .dim();
66
67 if collapsed {
68 return vec![collapsed_line(store, width, theme, meta_style)];
69 }
70
71 let visible: Vec<&TaskItem> = store.visible().collect();
72 let (window, hidden_completed, hidden_pending, hidden_blocked) = window_rows(&visible);
73
74 let mut lines = Vec::with_capacity(window.len() + 1);
75 for (i, task) in window.iter().enumerate() {
76 lines.push(task_row(task, i == 0, attached, width, theme, meta_style));
77 }
78 if hidden_completed + hidden_pending + hidden_blocked > 0 {
79 let mut bits = Vec::new();
80 if hidden_pending > 0 {
81 bits.push(format!("+{hidden_pending} pending"));
82 }
83 if hidden_blocked > 0 {
84 bits.push(format!("+{hidden_blocked} blocked"));
85 }
86 if hidden_completed > 0 {
87 bits.push(format!("{hidden_completed} completed"));
88 }
89 let footer_pad = if attached { " " } else { " " };
91 lines.push(Line::from(Span::styled(
92 truncate_to_cells(&format!("{footer_pad}… {}", bits.join(", ")), width),
93 meta_style,
94 )));
95 }
96 lines
97}
98
99pub fn tasks_height(store: &TaskStore, collapsed: bool) -> u16 {
101 if collapsed {
102 return 1;
103 }
104 let visible = store.visible().count();
105 if visible <= MAX_ROWS {
106 visible as u16
107 } else {
108 (MAX_ROWS as u16) + 1
110 }
111}
112
113fn window_rows<'a>(visible: &[&'a TaskItem]) -> (Vec<&'a TaskItem>, usize, usize, usize) {
117 if visible.len() <= MAX_ROWS {
118 return (visible.to_vec(), 0, 0, 0);
119 }
120 let start = visible
121 .iter()
122 .position(|t| t.status != TaskStatus::Completed)
123 .unwrap_or(0);
124 let start = start.min(visible.len() - MAX_ROWS);
127 let window: Vec<&TaskItem> = visible[start..start + MAX_ROWS].to_vec();
128 let hidden = |slice: &[&TaskItem], status: TaskStatus| {
129 slice.iter().filter(|t| t.status == status).count()
130 };
131 let before = &visible[..start];
132 let after = &visible[start + MAX_ROWS..];
133 (
134 window,
135 hidden(before, TaskStatus::Completed) + hidden(after, TaskStatus::Completed),
136 hidden(before, TaskStatus::Pending)
137 + hidden(after, TaskStatus::Pending)
138 + hidden(before, TaskStatus::InProgress)
139 + hidden(after, TaskStatus::InProgress),
140 hidden(before, TaskStatus::Blocked) + hidden(after, TaskStatus::Blocked),
141 )
142}
143
144fn task_row(
148 task: &TaskItem,
149 first: bool,
150 attached: bool,
151 width: usize,
152 theme: &Theme,
153 meta_style: Style,
154) -> Line<'static> {
155 let gutter = match (attached, first) {
156 (true, true) => " ⎿ ",
157 (true, false) => " ",
158 (false, _) => "",
159 };
160 let brand = Style::new().fg(theme.colors.brand.to_color());
161 let warning = Style::new().fg(theme.colors.warning.to_color());
162 let text = Style::new().fg(theme.colors.text_primary.to_color());
163
164 let suffix = if task.status == TaskStatus::Completed {
166 cost_suffix(task)
167 } else {
168 String::new()
169 };
170 let user_marker = if task.origin == TaskOrigin::User {
171 " (you)"
172 } else {
173 ""
174 };
175
176 let budget = width
177 .saturating_sub(gutter.len() + 2) .saturating_sub(suffix.len())
179 .saturating_sub(user_marker.len());
180 let subject = truncate_to_cells(&task.subject, budget.max(4));
181
182 let mut spans = vec![Span::styled(gutter.to_string(), meta_style)];
183 match task.status {
184 TaskStatus::Completed => {
185 spans.push(Span::styled("√ ", brand));
186 spans.push(Span::styled(subject, meta_style.crossed_out()));
187 },
188 TaskStatus::InProgress => {
189 spans.push(Span::styled("■ ", warning));
190 spans.push(Span::styled(subject, brand.bold()));
191 },
192 TaskStatus::Pending => {
193 spans.push(Span::styled("□ ", meta_style));
194 spans.push(Span::styled(subject, text));
195 },
196 TaskStatus::Blocked => {
199 spans.push(Span::styled("⊘ ", warning));
200 spans.push(Span::styled(subject, text));
201 },
202 TaskStatus::Deleted => {
205 spans.push(Span::styled("x ", meta_style));
206 spans.push(Span::styled(subject, meta_style));
207 },
208 }
209 if !user_marker.is_empty() {
210 spans.push(Span::styled(user_marker.to_string(), meta_style));
211 }
212 if !suffix.is_empty() {
213 spans.push(Span::styled(suffix, meta_style));
214 }
215 Line::from(spans)
216}
217
218fn collapsed_line(
221 store: &TaskStore,
222 width: usize,
223 theme: &Theme,
224 meta_style: Style,
225) -> Line<'static> {
226 let text_style = Style::new().fg(theme.colors.text_primary.to_color());
227 match store.next_pending() {
228 Some(next) => {
229 let head = " ⎿ Next: ";
230 let budget = (width).saturating_sub(head.len()).max(4);
231 Line::from(vec![
232 Span::styled(head.to_string(), meta_style),
233 Span::styled(truncate_to_cells(&next.subject, budget), text_style),
234 ])
235 },
236 None => Line::from(Span::styled(
237 format!(" ⎿ {}", store.progress_string()),
238 meta_style,
239 )),
240 }
241}
242
243fn cost_suffix(task: &TaskItem) -> String {
245 let mut bits = Vec::new();
246 if let Some(secs) = task.elapsed_secs()
247 && secs > 0
248 {
249 bits.push(format_duration(secs));
250 }
251 if let Some(tokens) = task.tokens_spent
252 && tokens > 0
253 {
254 bits.push(format!(
255 "{} tok",
256 crate::domain::format_compact_count(tokens as usize)
257 ));
258 }
259 if bits.is_empty() {
260 String::new()
261 } else {
262 format!(" ({})", bits.join(" · "))
263 }
264}
265
266fn format_duration(secs: u64) -> String {
267 if secs >= 3600 {
268 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
269 } else if secs >= 60 {
270 format!("{}m {}s", secs / 60, secs % 60)
271 } else {
272 format!("{secs}s")
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::domain::TaskEdit;
280 use crate::domain::tasks::{Stamp, TaskSpec};
281
282 fn store_of(statuses: &[TaskStatus]) -> TaskStore {
283 let mut store = TaskStore::default();
284 store.create(
285 statuses
286 .iter()
287 .enumerate()
288 .map(|(i, _)| TaskSpec {
289 subject: format!("task number {i}"),
290 active_form: format!("doing task {i}"),
291 description: None,
292 in_progress: false,
293 })
294 .collect(),
295 TaskOrigin::Model,
296 Stamp::default(),
297 );
298 let edits: Vec<TaskEdit> = statuses
299 .iter()
300 .enumerate()
301 .filter(|(_, s)| **s != TaskStatus::Pending)
302 .map(|(i, s)| TaskEdit {
303 id: (i + 1) as u32,
304 status: Some(*s),
305 ..TaskEdit::default()
306 })
307 .collect();
308 store.apply(&edits, Stamp::default());
309 store
310 }
311
312 fn rendered(lines: &[Line<'_>]) -> Vec<String> {
313 lines
314 .iter()
315 .map(|l| {
316 l.spans
317 .iter()
318 .map(|s| s.content.as_ref())
319 .collect::<String>()
320 })
321 .collect()
322 }
323
324 #[test]
325 fn visibility_rules() {
326 use TaskStatus::*;
327 let store = store_of(&[Completed, InProgress, Pending]);
328 assert!(tasks_visible(&store, &TurnState::Idle, false, false));
329 let done = store_of(&[Completed, Completed]);
330 assert!(
331 !tasks_visible(&done, &TurnState::Idle, false, true),
332 "all-done idle retires"
333 );
334 assert!(!tasks_visible(
335 &TaskStore::default(),
336 &TurnState::Idle,
337 false,
338 true
339 ));
340 assert!(!tasks_visible(&store, &TurnState::Idle, true, false));
342 assert!(tasks_visible(&store, &TurnState::Idle, true, true));
344 }
345
346 #[test]
347 fn expanded_rows_carry_glyphs_and_gutter() {
348 use TaskStatus::*;
349 let store = store_of(&[Completed, InProgress, Pending]);
350 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
351 let rows = rendered(&lines);
352 assert_eq!(rows.len(), 3);
353 assert!(rows[0].starts_with(" ⎿ √ "), "{:?}", rows[0]);
354 assert!(rows[1].starts_with(" ■ "), "{:?}", rows[1]);
355 assert!(rows[2].starts_with(" □ "), "{:?}", rows[2]);
356 }
357
358 #[test]
359 fn detached_rows_drop_elbow_and_sit_flush() {
360 use TaskStatus::*;
361 let store = store_of(&[Completed, InProgress, Pending]);
362 let lines = build_task_lines(&store, false, false, 80, &Theme::dark());
363 let rows = rendered(&lines);
364 assert_eq!(rows.len(), 3);
365 assert!(rows[0].starts_with("√ "), "{:?}", rows[0]);
366 assert!(rows[1].starts_with("■ "), "{:?}", rows[1]);
367 assert!(rows[2].starts_with("□ "), "{:?}", rows[2]);
368 assert!(!rows.iter().any(|r| r.contains('⎿')), "{rows:?}");
369 }
370
371 #[test]
372 fn long_lists_window_and_summarize() {
373 use TaskStatus::*;
374 let statuses: Vec<TaskStatus> = [Completed, Completed]
375 .into_iter()
376 .chain([InProgress])
377 .chain(std::iter::repeat_n(Pending, 9))
378 .collect();
379 let store = store_of(&statuses);
380 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
381 let rows = rendered(&lines);
382 assert_eq!(rows.len(), 9);
384 assert!(
385 rows[0].contains("■"),
386 "window starts at in_progress: {:?}",
387 rows[0]
388 );
389 let footer = rows.last().unwrap();
390 assert!(footer.contains("+2 pending"), "{footer:?}");
391 assert!(footer.contains("2 completed"), "{footer:?}");
392 assert_eq!(tasks_height(&store, false), 9);
393 }
394
395 #[test]
396 fn blocked_rows_render_glyph_and_footer_counts_them() {
397 use TaskStatus::*;
398 let store = store_of(&[Blocked, InProgress, Pending]);
399 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
400 let rows = rendered(&lines);
401 assert!(rows[0].starts_with(" ⎿ ⊘ "), "{:?}", rows[0]);
402
403 let statuses: Vec<TaskStatus> = [InProgress]
405 .into_iter()
406 .chain(std::iter::repeat_n(Pending, 7))
407 .chain([Blocked])
408 .chain(std::iter::repeat_n(Pending, 2))
409 .collect();
410 let store = store_of(&statuses);
411 let lines = build_task_lines(&store, false, true, 80, &Theme::dark());
412 let footer = rendered(&lines).last().unwrap().clone();
413 assert!(footer.contains("+1 blocked"), "{footer:?}");
414 assert!(footer.contains("+2 pending"), "{footer:?}");
415 }
416
417 #[test]
418 fn collapsed_shows_next_pending() {
419 use TaskStatus::*;
420 let store = store_of(&[Completed, InProgress, Pending]);
421 let lines = build_task_lines(&store, true, true, 80, &Theme::dark());
422 let rows = rendered(&lines);
423 assert_eq!(rows.len(), 1);
424 assert!(rows[0].contains("Next: task number 2"), "{:?}", rows[0]);
425 assert_eq!(tasks_height(&store, true), 1);
426
427 let no_pending = store_of(&[Completed, InProgress]);
428 let rows = rendered(&build_task_lines(
429 &no_pending,
430 true,
431 true,
432 80,
433 &Theme::dark(),
434 ));
435 assert!(rows[0].contains("Tasks 1/2"), "{:?}", rows[0]);
436 }
437
438 #[test]
439 fn completed_rows_show_cost_and_user_marker() {
440 let mut store = TaskStore::default();
441 store.create(
442 vec![TaskSpec {
443 subject: "review the docs".into(),
444 active_form: "reviewing the docs".into(),
445 description: None,
446 in_progress: true,
447 }],
448 TaskOrigin::User,
449 Stamp {
450 now_epoch: 100,
451 run_tokens: 1_000,
452 },
453 );
454 store.apply(
455 &[TaskEdit {
456 id: 1,
457 status: Some(TaskStatus::Completed),
458 ..TaskEdit::default()
459 }],
460 Stamp {
461 now_epoch: 230,
462 run_tokens: 9_400,
463 },
464 );
465 let lines = build_task_lines(&store, false, true, 100, &Theme::dark());
467 let row = &rendered(&lines)[0];
468 assert!(row.contains("(2m 10s · 8.4k tok)"), "{row:?}");
469 assert!(row.contains("(you)"), "{row:?}");
470 }
471}