279 lines
10 KiB
Rust
279 lines
10 KiB
Rust
//! Minimal-mode todo panel: the persistent list shown directly above the prompt
|
||
//! while a turn has todos.
|
||
//!
|
||
//! It auto-hides once every todo is done (so a finished list doesn't linger),
|
||
//! unless pinned open with `Ctrl+T` ([`todo_panel_visible`]). The overlay host
|
||
//! sizes the idle viewport with [`todo_panel_height`] so the prompt sits right
|
||
//! after the panel; [`draw_live`](super::live::draw_live) paints it with
|
||
//! [`todo_panel_lines`] + [`render_todo_panel`]. Mirrors the full-TUI `TodoPane`
|
||
//! glyphs/colors without its interactive chrome.
|
||
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::{Color, Modifier, Style};
|
||
use ratatui::text::{Line, Span};
|
||
|
||
use xai_grok_pager::theme::Theme;
|
||
use xai_grok_shell::tools::TodoStatus;
|
||
|
||
/// Default cap on visible todo rows (the last becomes a `+N more` overflow row);
|
||
/// `Ctrl+T` expands past it.
|
||
pub(super) const MAX_TODO_ROWS: u16 = 8;
|
||
|
||
/// Whether the todo panel should render this frame. Hidden when there are no
|
||
/// todos, or when every todo is finished (so a completed list doesn't linger —
|
||
/// nit: "still showing old TODOs on every turn even though all are complete").
|
||
/// A new turn that creates fresh pending todos re-shows it immediately. `force`
|
||
/// (Ctrl+T) pins it visible regardless, e.g. to review a finished list.
|
||
pub(super) fn todo_panel_visible(
|
||
agent: &xai_grok_pager::app::agent_view::AgentView,
|
||
force: bool,
|
||
) -> bool {
|
||
let todos = agent.todo.todos();
|
||
if todos.is_empty() {
|
||
return false;
|
||
}
|
||
if force {
|
||
return true;
|
||
}
|
||
todos
|
||
.iter()
|
||
.any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
|
||
}
|
||
|
||
/// Rows the todo panel will occupy (0 when hidden — see [`todo_panel_visible`] —
|
||
/// or there are no todos), capped at [`MAX_TODO_ROWS`]. The overlay host uses
|
||
/// this to size the idle viewport to exactly its content so the prompt sits
|
||
/// right after the committed conversation (no bottom-pin, no gap).
|
||
pub(super) fn todo_panel_height(
|
||
agent: &xai_grok_pager::app::agent_view::AgentView,
|
||
force: bool,
|
||
) -> u16 {
|
||
if !todo_panel_visible(agent, force) {
|
||
return 0;
|
||
}
|
||
let len = agent.todo.todos().len() as u16;
|
||
// Ctrl+T (force) expands the full list (clamped to the screen by the caller);
|
||
// otherwise cap at `MAX_TODO_ROWS` with a `+N more` overflow row.
|
||
if force { len } else { len.min(MAX_TODO_ROWS) }
|
||
}
|
||
|
||
/// Render the persistent todo panel into `area` (one line per item). Background
|
||
/// is reset so the panel inherits the terminal's own background (transparency),
|
||
/// matching the rest of the minimal live region.
|
||
pub(super) fn render_todo_panel(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
lines: &[Line<'static>],
|
||
) {
|
||
buf.set_style(area, theme.muted().bg(Color::Reset));
|
||
for (i, line) in lines.iter().enumerate() {
|
||
let y = area.y + i as u16;
|
||
if y >= area.y + area.height {
|
||
break;
|
||
}
|
||
buf.set_line(area.x, y, line, area.width);
|
||
}
|
||
}
|
||
|
||
/// Build the persistent todo-panel lines (status glyph + content per item),
|
||
/// shown directly above the prompt while there are todos. Capped to `max_rows`
|
||
/// (the last row becomes `… +N more` on overflow). Empty when there are no
|
||
/// todos. Mirrors the full-TUI `TodoPane`'s glyphs/colors.
|
||
pub(super) fn todo_panel_lines(
|
||
agent: &xai_grok_pager::app::agent_view::AgentView,
|
||
max_rows: u16,
|
||
force: bool,
|
||
) -> Vec<Line<'static>> {
|
||
let todos = agent.todo.todos();
|
||
if todos.is_empty() || max_rows == 0 {
|
||
return Vec::new();
|
||
}
|
||
let theme = Theme::current();
|
||
let cap = max_rows as usize;
|
||
let overflow = todos.len() > cap;
|
||
// Leave the last row for the overflow marker when truncating.
|
||
let shown = if overflow {
|
||
cap.saturating_sub(1)
|
||
} else {
|
||
todos.len()
|
||
};
|
||
|
||
let mut lines: Vec<Line<'static>> = todos
|
||
.iter()
|
||
.take(shown)
|
||
.map(|t| {
|
||
let (glyph, style) = match t.status {
|
||
TodoStatus::Pending => ("\u{25a1}", Style::default().fg(theme.text_primary)),
|
||
TodoStatus::InProgress => (
|
||
"\u{25b6}",
|
||
Style::default()
|
||
.fg(theme.warning)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
TodoStatus::Completed => (xai_grok_pager::glyphs::check_mark(), theme.muted()),
|
||
TodoStatus::Cancelled => (
|
||
xai_grok_pager::glyphs::ballot_x(),
|
||
theme.muted().add_modifier(Modifier::CROSSED_OUT),
|
||
),
|
||
};
|
||
let content = truncate_chars(t.content.lines().next().unwrap_or("").trim(), 64);
|
||
// No leading pad: the caller places the panel at the shared
|
||
// live-region left edge (`live::live_left_inset` = 0, flush-left),
|
||
// so the glyph
|
||
// column lines up with committed `◆` bullets and the prompt `❯`.
|
||
Line::from(vec![
|
||
Span::styled(format!("{glyph} "), style),
|
||
Span::styled(content, style),
|
||
])
|
||
})
|
||
.collect();
|
||
|
||
if overflow {
|
||
let remaining = todos.len() - shown;
|
||
// When collapsed, advertise the chord that expands the full list; when
|
||
// already forced open (still overflowing a tiny screen) drop the hint.
|
||
let label = if force {
|
||
format!("\u{2026} +{remaining} more")
|
||
} else {
|
||
format!("\u{2026} +{remaining} more \u{00b7} ctrl+t to expand")
|
||
};
|
||
lines.push(Line::from(Span::styled(label, theme.dim())));
|
||
}
|
||
lines
|
||
}
|
||
|
||
/// Truncate `s` to at most `max` characters, appending `…` when shortened.
|
||
fn truncate_chars(s: &str, max: usize) -> String {
|
||
if s.chars().count() <= max {
|
||
return s.to_string();
|
||
}
|
||
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
|
||
format!("{kept}…")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use xai_grok_pager::minimal_api;
|
||
use xai_grok_shell::tools::{TodoItem, TodoPriority};
|
||
|
||
fn agent() -> xai_grok_pager::app::agent_view::AgentView {
|
||
minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"))
|
||
}
|
||
|
||
fn todo(content: &str, status: TodoStatus) -> TodoItem {
|
||
TodoItem {
|
||
content: content.into(),
|
||
priority: TodoPriority::default(),
|
||
status,
|
||
meta: None,
|
||
}
|
||
}
|
||
|
||
/// Plain text of a rendered line (span contents concatenated).
|
||
fn line_text(line: &Line<'_>) -> String {
|
||
line.spans.iter().map(|s| s.content.as_ref()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn todo_panel_visibility_auto_hides_when_work_is_done() {
|
||
use xai_grok_pager::app::agent::AgentState;
|
||
let mut a = agent();
|
||
// No todos → hidden.
|
||
assert!(!todo_panel_visible(&a, false));
|
||
|
||
// At least one unfinished todo → shown.
|
||
a.todo.update_todos(vec![
|
||
todo("done", TodoStatus::Completed),
|
||
todo("doing", TodoStatus::InProgress),
|
||
]);
|
||
assert!(todo_panel_visible(&a, false));
|
||
|
||
// All completed + idle → auto-hidden (don't linger forever).
|
||
a.todo.update_todos(vec![
|
||
todo("a", TodoStatus::Completed),
|
||
todo("b", TodoStatus::Completed),
|
||
]);
|
||
assert!(
|
||
!todo_panel_visible(&a, false),
|
||
"auto-hide once every todo is done and the turn is idle"
|
||
);
|
||
|
||
// …and stays hidden even while a turn is actively running, so a previous
|
||
// turn's finished list never lingers at the start of the next turn.
|
||
a.session.state = AgentState::TurnRunning;
|
||
assert!(
|
||
!todo_panel_visible(&a, false),
|
||
"all-complete list hides even mid-turn"
|
||
);
|
||
|
||
// The Ctrl+T force-show pin overrides the auto-hide.
|
||
a.session.state = AgentState::Idle;
|
||
assert!(
|
||
todo_panel_visible(&a, true),
|
||
"Ctrl+T pin keeps a finished list visible"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn todo_panel_empty_when_no_todos() {
|
||
assert!(todo_panel_lines(&agent(), 8, false).is_empty());
|
||
// …and empty when the cap is zero, regardless of todos.
|
||
let mut a = agent();
|
||
a.todo.update_todos(vec![todo("x", TodoStatus::Pending)]);
|
||
assert!(todo_panel_lines(&a, 0, false).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn todo_panel_lists_items_with_status_glyphs() {
|
||
let mut agent = agent();
|
||
agent.todo.update_todos(vec![
|
||
todo("done one", TodoStatus::Completed),
|
||
todo("active item", TodoStatus::InProgress),
|
||
todo("later", TodoStatus::Pending),
|
||
]);
|
||
let lines = todo_panel_lines(&agent, 8, false);
|
||
assert_eq!(lines.len(), 3);
|
||
assert!(line_text(&lines[0]).contains("done one"));
|
||
assert!(
|
||
line_text(&lines[1]).contains("\u{25b6}"),
|
||
"in-progress row uses the ▶ glyph"
|
||
);
|
||
assert!(line_text(&lines[1]).contains("active item"));
|
||
assert!(
|
||
line_text(&lines[2]).contains("\u{25a1}"),
|
||
"pending row uses the □ glyph"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn todo_panel_caps_with_overflow_row() {
|
||
let mut agent = agent();
|
||
agent.todo.update_todos(
|
||
(0..10)
|
||
.map(|i| todo(&format!("item {i}"), TodoStatus::Pending))
|
||
.collect(),
|
||
);
|
||
let lines = todo_panel_lines(&agent, 4, false);
|
||
assert_eq!(lines.len(), 4, "capped to max_rows");
|
||
// 3 items + a "+7 more" overflow row (10 total, 3 shown), with a hint.
|
||
assert!(
|
||
line_text(&lines[3]).contains("+7 more"),
|
||
"got: {:?}",
|
||
line_text(&lines[3])
|
||
);
|
||
assert!(
|
||
line_text(&lines[3]).contains("ctrl+t"),
|
||
"overflow row advertises the expand chord: {:?}",
|
||
line_text(&lines[3])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_chars_adds_ellipsis_only_when_needed() {
|
||
assert_eq!(truncate_chars("hello", 10), "hello");
|
||
assert_eq!(truncate_chars("hello world", 5), "hell…");
|
||
}
|
||
}
|