use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
pub struct TaskRow {
pub id: String,
pub name: String,
pub status: String,
pub due_at: Option<String>,
pub defer_until: Option<String>,
pub context: Option<String>,
pub project: Option<String>,
pub waiting_on: Option<String>,
pub overdue: bool,
pub closed: bool,
}
pub const ACTIVE_STATUSES: [&str; 4] = ["next", "inbox", "scheduled", "waiting"];
pub struct Form {
pub editing: Option<String>,
pub fields: Vec<(&'static str, String)>,
pub idx: usize,
pub error: Option<String>,
}
impl Form {
pub fn capture() -> Self {
Form {
editing: None,
fields: vec![
("name", String::new()),
("due", String::new()),
("project", String::new()),
("context", String::new()),
],
idx: 0,
error: None,
}
}
pub fn edit(row: &TaskRow) -> Self {
Form {
editing: Some(row.id.clone()),
fields: vec![
("due", row.due_at.clone().unwrap_or_default()),
("defer", row.defer_until.clone().unwrap_or_default()),
("context", row.context.clone().unwrap_or_default()),
],
idx: 0,
error: None,
}
}
pub fn move_by(&mut self, delta: isize) {
let len = self.fields.len() as isize;
self.idx = (((self.idx as isize + delta) % len + len) % len) as usize;
}
pub fn push(&mut self, c: char) {
self.fields[self.idx].1.push(c);
}
pub fn backspace(&mut self) {
self.fields[self.idx].1.pop();
}
pub fn value(&self, field: &str) -> &str {
self.fields
.iter()
.find(|(name, _)| *name == field)
.map(|(_, v)| v.as_str())
.unwrap_or_default()
}
pub fn title(&self) -> String {
match &self.editing {
Some(_) => " edit schedule · tab moves · enter saves · esc back ".into(),
None => " capture a task · tab moves · enter saves · esc back ".into(),
}
}
}
pub struct TasksModal {
pub rows: Vec<TaskRow>,
pub selected: usize,
pub detail: bool,
pub detail_scroll: u16,
pub show_closed: bool,
pub form: Option<Form>,
pub help: bool,
pub status: Option<String>,
pub today: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
Status(&'static str),
Cycle,
Add,
Edit,
Closed,
Refresh,
Close,
}
pub struct Key {
pub key: char,
pub short: &'static str,
pub note: &'static str,
}
pub const KEYS: &[Key] = &[
Key {
key: 'j',
short: "",
note: "move down (↓ too)",
},
Key {
key: 'k',
short: "",
note: "move up (↑ too)",
},
Key {
key: '\n',
short: "↵ open",
note: "the whole task: full name, id, dates, project",
},
Key {
key: 'a',
short: "a add",
note: "capture a task — lands in inbox, like every other capture",
},
Key {
key: 'e',
short: "e edit",
note: "due, defer and context of the selected task",
},
Key {
key: 'n',
short: "n next",
note: "status → next: committed to, actionable now",
},
Key {
key: 'i',
short: "i inbox",
note: "status → inbox: captured, not yet decided on",
},
Key {
key: 's',
short: "s sched",
note: "status → scheduled: it has a date and waits for it",
},
Key {
key: 'w',
short: "w wait",
note: "status → waiting: blocked on somebody else",
},
Key {
key: 'd',
short: "d done",
note: "status → done — reversible: n or i reopens it",
},
Key {
key: 'x',
short: "x drop",
note: "status → dropped — reversible too; nothing is ever deleted",
},
Key {
key: ' ',
short: "spc cycle",
note: "walk next → inbox → scheduled → waiting",
},
Key {
key: 'z',
short: "z closed",
note: "show or hide done and dropped",
},
Key {
key: 'r',
short: "r reload",
note: "re-read the board",
},
Key {
key: '?',
short: "",
note: "this list",
},
Key {
key: 'q',
short: "",
note: "close (esc too)",
},
];
pub fn key_strip() -> String {
KEYS.iter()
.filter(|k| !k.short.is_empty())
.map(|k| k.short)
.collect::<Vec<_>>()
.join(" · ")
}
pub fn action_for(key: char) -> Option<Action> {
Some(match key {
'n' => Action::Status("next"),
'i' => Action::Status("inbox"),
's' => Action::Status("scheduled"),
'w' => Action::Status("waiting"),
'd' => Action::Status("done"),
'x' => Action::Status("dropped"),
' ' => Action::Cycle,
'a' => Action::Add,
'e' => Action::Edit,
'z' => Action::Closed,
'r' => Action::Refresh,
'q' => Action::Close,
_ => return None,
})
}
impl TasksModal {
pub fn new(rows: Vec<TaskRow>, today: String) -> Self {
TasksModal {
rows,
selected: 0,
detail: false,
detail_scroll: 0,
show_closed: false,
form: None,
help: false,
status: None,
today,
}
}
pub fn selected_row(&self) -> Option<&TaskRow> {
self.rows.get(self.selected)
}
pub fn move_by(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let len = self.rows.len() as isize;
self.selected = (((self.selected as isize + delta) % len + len) % len) as usize;
self.detail_scroll = 0;
}
pub fn next_in_cycle(&self) -> Option<&'static str> {
let row = self.selected_row()?;
let at = ACTIVE_STATUSES.iter().position(|s| *s == row.status);
Some(match at {
Some(i) => ACTIVE_STATUSES[(i + 1) % ACTIVE_STATUSES.len()],
None => ACTIVE_STATUSES[0],
})
}
fn counts(&self) -> (usize, usize) {
let open = self.rows.iter().filter(|r| !r.closed).count();
let late = self.rows.iter().filter(|r| r.overdue).count();
(open, late)
}
fn title(&self) -> String {
if let Some(status) = &self.status {
return format!(" tasks — {status} ");
}
let (open, late) = self.counts();
let late = if late > 0 {
format!("{late} overdue · ")
} else {
String::new()
};
let closed = if self.show_closed {
"closed shown · "
} else {
""
};
format!(" tasks — {open} open · {late}{closed}? keys · esc ")
}
fn list_scroll(&self, visible: u16) -> u16 {
let visible = visible.max(1) as usize;
(self.selected + 1).saturating_sub(visible) as u16
}
pub fn scroll_detail(&mut self, delta: i16) {
self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
}
pub fn draw(&self, frame: &mut Frame) {
if self.help {
self.draw_help(frame);
return;
}
if let Some(form) = &self.form {
draw_form(frame, form);
return;
}
if self.detail {
self.draw_detail(frame);
return;
}
self.draw_list(frame);
}
fn draw_list(&self, frame: &mut Frame) {
let strip_text = format!(" {}", key_strip());
let strip = Line::styled(strip_text.clone(), Style::new().fg(Color::Cyan));
let body: Vec<Line> = if self.rows.is_empty() {
vec![Line::styled(
" nothing on the board — a captures one",
Style::new().fg(Color::DarkGray),
)]
} else {
self.rows
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == self.selected;
let marker = if selected { "›" } else { " " };
let late = if row.overdue { "!" } else { " " };
let text = format!(
"{marker} {late} {:<10} {:<11} {:<62} {}",
row.status,
row.due_at.as_deref().unwrap_or("—"),
truncate(&row.name, 62),
truncate(&row.tail(), 24),
);
if selected {
Line::styled(text, Style::new().fg(Color::Black).bg(Color::Cyan))
} else if row.closed {
Line::styled(text, Style::new().fg(Color::DarkGray))
} else if row.overdue {
Line::styled(text, Style::new().fg(Color::Red))
} else {
Line::styled(text, Style::new().fg(Color::White))
}
})
.collect()
};
let width = 120u16.min(frame.area().width);
let strip_lines = strip_height(&strip_text, width.saturating_sub(2));
let height =
super::list_height_reserving(body.len() as u16, frame.area().height, strip_lines);
let area = super::centered(frame.area(), width, height);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(self.title());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let lines = strip_height(&strip_text, inner.width);
frame.render_widget(
Paragraph::new(strip).wrap(Wrap { trim: false }),
Rect {
height: lines.min(inner.height),
..inner
},
);
let list = Rect {
y: inner.y + lines,
height: inner.height.saturating_sub(lines),
..inner
};
frame.render_widget(
Paragraph::new(body).scroll((self.list_scroll(list.height), 0)),
list,
);
}
fn draw_detail(&self, frame: &mut Frame) {
let Some(row) = self.selected_row() else {
return;
};
let white = Style::new().fg(Color::White);
let grey = Style::new().fg(Color::DarkGray);
let red = Style::new().fg(Color::Red);
let mut body = vec![
Line::styled(row.name.clone(), white),
Line::raw(""),
Line::styled(
format!("status {}", row.status),
if row.closed { grey } else { white },
),
Line::styled(
format!("due {}", row.due_at.as_deref().unwrap_or("—")),
if row.overdue { red } else { white },
),
];
for (label, value) in [
("defer", &row.defer_until),
("project", &row.project),
("context", &row.context),
("waiting", &row.waiting_on),
] {
if let Some(v) = value.as_deref().filter(|v| !v.is_empty()) {
body.push(Line::styled(format!("{label:<9} {v}"), white));
}
}
body.push(Line::raw(""));
body.push(Line::styled(
format!("{} · today is {}", row.id, self.today),
grey,
));
let paragraph = Paragraph::new(body).wrap(Wrap { trim: false });
let width = 100u16.min(frame.area().width);
let drawn = paragraph.line_count(width.saturating_sub(2)) as u16;
let area = super::centered(frame.area(), width, (drawn + 2).min(frame.area().height));
let visible = area.height.saturating_sub(2);
let max_scroll = drawn.saturating_sub(visible);
let scroll = self.detail_scroll.min(max_scroll);
let title = if max_scroll == 0 {
" task · e edit · n/i/s/w/d/x status · esc back ".to_string()
} else {
format!(
" task · {}/{} · ↑↓ scrolls · e edit · esc back ",
(scroll + visible).min(drawn),
drawn
)
};
frame.render_widget(Clear, area);
frame.render_widget(
paragraph.scroll((scroll, 0)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(title),
),
area,
);
}
fn draw_help(&self, frame: &mut Frame) {
let body: Vec<Line> = KEYS
.iter()
.map(|k| {
let key = match k.key {
'\n' => "enter".to_string(),
' ' => "space".to_string(),
c => c.to_string(),
};
Line::from(vec![
Span::styled(format!(" {key:<8}"), Style::new().fg(Color::Cyan)),
Span::styled(k.note, Style::new().fg(Color::White)),
])
})
.collect();
let area = super::centered(frame.area(), 74, body.len() as u16 + 2);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(" task keys · any key closes "),
),
area,
);
}
}
impl TaskRow {
pub fn tail(&self) -> String {
[&self.project, &self.context, &self.waiting_on]
.iter()
.filter_map(|v| v.as_deref().filter(|v| !v.is_empty()))
.collect::<Vec<_>>()
.join(" · ")
}
}
fn draw_form(frame: &mut Frame, form: &Form) {
let mut body: Vec<Line> = form
.fields
.iter()
.enumerate()
.map(|(i, (label, value))| {
let here = i == form.idx;
let cursor = if here { "▏" } else { "" };
Line::from(vec![
Span::styled(
format!(" {label:<9}"),
Style::new().fg(if here { Color::Cyan } else { Color::DarkGray }),
),
Span::styled(format!("{value}{cursor}"), Style::new().fg(Color::White)),
])
})
.collect();
body.push(Line::raw(""));
match &form.error {
Some(e) => body.push(Line::styled(format!(" {e}"), Style::new().fg(Color::Red))),
None => body.push(Line::styled(
" due takes YYYY-MM-DD, today, tomorrow or +Nd · project must already be in the graph",
Style::new().fg(Color::DarkGray),
)),
}
let area = super::centered(frame.area(), 96, body.len() as u16 + 2);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(form.title()),
),
area,
);
}
fn strip_height(strip: &str, width: u16) -> u16 {
let width = width.max(1) as usize;
(strip.chars().count().div_ceil(width) as u16).max(1)
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
format!(
"{}…",
s.chars().take(n.saturating_sub(1)).collect::<String>()
)
}
pub fn rows_from_json(text: &str) -> anyhow::Result<(Vec<TaskRow>, String)> {
use anyhow::Context;
let board: serde_json::Value =
serde_json::from_str(text).context("`mecha tasks list --json` did not answer with JSON")?;
let today = board["today"].as_str().unwrap_or_default().to_string();
let rows = board["items"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.map(|t| {
let text = |key: &str| {
t[key]
.as_str()
.filter(|v| !v.is_empty())
.map(str::to_string)
};
TaskRow {
id: t["id"].as_str().unwrap_or_default().to_string(),
name: t["name"].as_str().unwrap_or_default().to_string(),
status: t["status"].as_str().unwrap_or("?").to_string(),
due_at: text("due_at"),
defer_until: text("defer_until"),
context: text("context"),
project: text("project"),
waiting_on: text("waiting_on"),
overdue: t["overdue"].as_bool().unwrap_or(false),
closed: t["completed_at"].is_string(),
}
})
.collect();
Ok((rows, today))
}
#[cfg(test)]
mod tests {
use super::*;
const BOARD: &str = r#"{"v":1,"today":"2026-08-20","truncated":false,"items":[
{"id":"task-790c1384","name":"Verify suspicious Microsoft invoice email","status":"inbox",
"due_at":"2026-08-15","defer_until":null,"context":"@email","project":null,
"waiting_on":null,"completed_at":null,"overdue":true},
{"id":"task-b34fb2d0","name":"Edit Alexis's Master's thesis","status":"next",
"due_at":"2026-08-20","defer_until":null,"context":null,"project":"Alexis Cameron",
"waiting_on":null,"completed_at":null,"overdue":false},
{"id":"task-dead0000","name":"Something finished","status":"done",
"due_at":null,"defer_until":null,"context":null,"project":null,
"waiting_on":null,"completed_at":"2026-08-19","overdue":false}]}"#;
use ratatui::backend::TestBackend;
fn frame_text(m: &TasksModal, width: u16, height: u16) -> String {
let mut t = Terminal::new(TestBackend::new(width, height)).unwrap();
t.draw(|f| m.draw(f)).unwrap();
let buf = t.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn long_named_task() -> TaskRow {
TaskRow {
id: "task-790c1384".into(),
name: "Verify the suspicious Microsoft invoice email that arrived on Tuesday \
and work out whether it is a phishing attempt or a real renewal notice \
before the quoted deadline passes and the licence lapses"
.into(),
status: "next".into(),
due_at: Some("2026-08-25".into()),
defer_until: None,
context: Some("@email".into()),
project: Some("Admin".into()),
waiting_on: None,
overdue: false,
closed: false,
}
}
#[test]
fn a_wrapped_task_name_does_not_push_the_id_off_the_detail() {
let mut m = TasksModal::new(vec![long_named_task()], "2026-08-20".into());
m.detail = true;
let text = frame_text(&m, 60, 30);
assert!(
text.contains("task-790c1384"),
"the id is off screen: {text}"
);
assert!(text.contains("context @email"), "{text}");
assert!(!text.contains("↑↓ scrolls"), "{text}");
}
#[test]
fn a_terminal_too_short_for_the_detail_scrolls_to_the_tail() {
let mut m = TasksModal::new(vec![long_named_task()], "2026-08-20".into());
m.detail = true;
let top = frame_text(&m, 60, 10);
assert!(top.contains("↑↓ scrolls"), "{top}");
assert!(!top.contains("task-790c1384"), "{top}");
m.scroll_detail(99);
let bottom = frame_text(&m, 60, 10);
assert!(
bottom.contains("task-790c1384"),
"scrolling reaches it: {bottom}"
);
}
#[test]
fn moving_the_selection_resets_the_task_detail_scroll() {
let mut m = TasksModal::new(
vec![long_named_task(), long_named_task()],
"2026-08-20".into(),
);
m.scroll_detail(9);
assert_eq!(m.detail_scroll, 9);
m.move_by(1);
assert_eq!(m.detail_scroll, 0);
m.scroll_detail(-3);
assert_eq!(m.detail_scroll, 0);
}
#[test]
fn the_legend_and_the_key_map_are_the_same_set() {
for key in KEYS.iter().filter(|k| !k.short.is_empty() && k.key != '\n') {
assert!(
action_for(key.key).is_some(),
"the strip advertises `{}`, which the map does not answer to",
key.key
);
}
for c in ' '..='~' {
if let Some(action) = action_for(c) {
assert!(
KEYS.iter().any(|k| k.key == c),
"`{c}` runs {action:?} and is in no legend",
);
}
}
}
#[test]
fn the_status_letters_match_the_graph_tui() {
for (key, status) in [
('n', "next"),
('i', "inbox"),
('w', "waiting"),
('s', "scheduled"),
('d', "done"),
('x', "dropped"),
] {
assert_eq!(
action_for(key),
Some(Action::Status(status)),
"`{key}` is {status} in mecha-graph tui screen 6"
);
}
}
#[test]
fn the_key_strip_names_the_actions_and_fits_a_line() {
let strip = key_strip();
for expected in ["a add", "e edit", "d done", "spc cycle", "r reload"] {
assert!(strip.contains(expected), "{expected} missing from {strip}");
}
assert!(!strip.contains(" j "), "{strip}");
assert!(
strip.chars().count() <= 116,
"{} wide: {strip}",
strip.chars().count()
);
}
#[test]
fn a_narrow_terminal_wraps_the_legend_instead_of_cutting_it() {
assert_eq!(strip_height("abcdef", 100), 1);
assert_eq!(strip_height("abcdef", 3), 2);
assert_eq!(strip_height("abcdef", 2), 3);
assert_eq!(strip_height("", 0), 1, "never zero-height");
}
#[test]
fn the_box_reserves_the_legends_rows_and_not_something_else() {
assert_eq!(super::super::list_height_reserving(3, 24, 1), 6);
assert_eq!(super::super::list_height_reserving(100, 24, 1), 22);
assert_ne!(super::super::list_height_reserving(3, 1, 24), 6);
}
#[test]
fn no_terminal_size_panics_the_draw() {
let (rows, today) = rows_from_json(BOARD).unwrap();
for (w, h) in [
(130, 24),
(130, 5),
(130, 3),
(130, 1),
(60, 5),
(20, 8),
(20, 3),
(8, 2),
(1, 1),
] {
let (rows, today) = (clone_rows(&rows), today.clone());
let mut modal = TasksModal::new(rows, today);
for view in 0..4 {
match view {
1 => modal.detail = true,
2 => {
modal.detail = false;
modal.help = true;
}
3 => {
modal.help = false;
modal.form = Some(Form::capture());
}
_ => {}
}
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(w, h)).unwrap();
terminal
.draw(|f| modal.draw(f))
.unwrap_or_else(|e| panic!("{w}x{h} view {view}: {e}"));
}
}
assert!(strip_height(&format!(" {}", key_strip()), 18) > 8u16.saturating_sub(4));
}
fn clone_rows(rows: &[TaskRow]) -> Vec<TaskRow> {
rows.iter()
.map(|r| TaskRow {
id: r.id.clone(),
name: r.name.clone(),
status: r.status.clone(),
due_at: r.due_at.clone(),
defer_until: r.defer_until.clone(),
context: r.context.clone(),
project: r.project.clone(),
waiting_on: r.waiting_on.clone(),
overdue: r.overdue,
closed: r.closed,
})
.collect()
}
#[test]
fn the_board_parses_into_rows() {
let (rows, today) = rows_from_json(BOARD).unwrap();
assert_eq!(today, "2026-08-20");
assert_eq!(rows.len(), 3);
assert!(rows[0].overdue && !rows[0].closed);
assert_eq!(rows[0].context.as_deref(), Some("@email"));
assert_eq!(rows[1].tail(), "Alexis Cameron");
assert!(rows[2].closed, "completed_at is what closes a task");
}
#[test]
fn absent_fields_stay_absent() {
let (rows, _) = rows_from_json(BOARD).unwrap();
assert_eq!(rows[0].project, None);
assert_eq!(rows[1].tail(), "Alexis Cameron", "no empty separators");
assert_eq!(rows[0].tail(), "@email");
}
#[test]
fn cycling_stays_among_the_actionable_statuses() {
let (rows, today) = rows_from_json(BOARD).unwrap();
let mut modal = TasksModal::new(rows, today);
assert_eq!(
modal.next_in_cycle(),
Some("scheduled"),
"inbox → scheduled"
);
modal.selected = 1;
assert_eq!(modal.next_in_cycle(), Some("inbox"), "next → inbox");
modal.selected = 2;
assert_eq!(modal.next_in_cycle(), Some("next"));
}
#[test]
fn the_edit_form_starts_from_what_the_task_already_is() {
let (rows, _) = rows_from_json(BOARD).unwrap();
let form = Form::edit(&rows[0]);
assert_eq!(form.editing.as_deref(), Some("task-790c1384"));
assert_eq!(form.value("due"), "2026-08-15");
assert_eq!(form.value("context"), "@email");
assert_eq!(form.value("defer"), "", "it has none, and says so");
}
#[test]
fn the_list_shows_the_task_and_the_detail_shows_the_id() {
let (rows, today) = rows_from_json(BOARD).unwrap();
let mut modal = TasksModal::new(rows, today);
assert!(!render(&modal).contains("task-790c1384"));
assert!(render(&modal).contains("Verify suspicious Microsoft invoice"));
assert!(render(&modal).contains("a add"), "the legend draws unasked");
modal.detail = true;
assert!(render(&modal).contains("task-790c1384"));
}
fn render(modal: &TasksModal) -> String {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(130, 24)).unwrap();
terminal.draw(|f| modal.draw(f)).unwrap();
terminal
.backend()
.buffer()
.content()
.chunks(130)
.map(|row| row.iter().map(|c| c.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
}