feat(tui): delete/tombstone a task with D (y/N confirm) (§8.1)

`D` arms a delete on the highlighted task; the status line shows
"Delete \"title\"? (y / N)" and the next key confirms (y) or cancels (anything
else). Confirming calls node.tombstone — a true soft-delete that removes the
task from every view, recurring tasks included (unlike `x` done, which rolls a
recurring task forward, or `d` dropped, which keeps it in the store). Backend
gains `tombstone`.

Tests: confirm-flow unit test against a recording fake (arm → cancel keeps it;
arm → confirm tombstones), plus a real-daemon integration test that deleting a
recurring task drops it from the view and sets the node's tombstoned flag.
186 workspace tests; clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 08:48:32 -07:00
commit 2c8d8b101f
6 changed files with 120 additions and 1 deletions

View file

@ -44,6 +44,13 @@ pub struct SearchView {
pub cursor: usize,
}
/// A pending delete awaiting y/N confirmation (the most destructive gesture).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingDelete {
pub task_id: String,
pub title: String,
}
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
pub fn next_attention(current: Option<Attention>) -> Attention {
@ -106,6 +113,8 @@ pub struct App<B: Backend> {
pub mode: Mode,
/// When `Some`, a full-text search overlays the task list.
pub search: Option<SearchView>,
/// When `Some`, a delete is awaiting y/N confirmation.
pub pending_delete: Option<PendingDelete>,
pub status: String,
pub should_quit: bool,
}
@ -143,6 +152,7 @@ impl<B: Backend> App<B> {
focus: Focus::Sidebar,
mode: Mode::Normal,
search: None,
pending_delete: None,
status: String::new(),
should_quit: false,
};
@ -353,6 +363,31 @@ impl<B: Backend> App<B> {
});
}
/// Arm a delete on the highlighted task (awaits y/N confirmation).
pub fn begin_delete(&mut self) {
if let Some(t) = self.selected_task() {
self.pending_delete = Some(PendingDelete {
task_id: t.node_id.clone(),
title: t.title.clone(),
});
}
}
/// Confirm the armed delete: tombstone the task and reload.
pub fn confirm_delete(&mut self) {
if let Some(pd) = self.pending_delete.take() {
self.mutate(format!("deleted: {}", pd.title), |b| {
b.tombstone(&pd.task_id)
});
}
}
/// Cancel the armed delete.
pub fn cancel_delete(&mut self) {
self.pending_delete = None;
self.status = "delete cancelled".into();
}
// --- input modal (T2c: guided add + reschedule) ---
fn current_project_id(&self) -> Option<String> {

View file

@ -50,6 +50,9 @@ pub trait Backend {
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>;
/// Skip a recurring task to its next occurrence (no completion logged).
fn skip(&mut self, task_id: &str) -> Result<()>;
/// Tombstone (soft-delete) a task node — removes it from every view,
/// including recurring roll-forward. Distinct from `done`/`dropped`.
fn tombstone(&mut self, node_id: &str) -> Result<()>;
/// Set a task's attention band.
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
@ -153,6 +156,11 @@ impl Backend for ClientBackend {
Ok(())
}
fn tombstone(&mut self, node_id: &str) -> Result<()> {
self.call("node.tombstone", json!({ "id": node_id }))?;
Ok(())
}
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
self.call(
"task.set_attention",

View file

@ -98,6 +98,15 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None;
}
// A pending delete confirmation captures the next key (y confirms; else cancel).
if app.pending_delete.is_some() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => app.confirm_delete(),
_ => app.cancel_delete(),
}
return None;
}
// While collecting input, all keys go to the prompt.
if matches!(app.mode, Mode::Input(_)) {
match key.code {
@ -145,6 +154,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('s') => app.skip_selected(),
KeyCode::Char('A') => app.cycle_attention_selected(),
KeyCode::Char('b') => app.push_to_blue_selected(),
KeyCode::Char('D') => app.begin_delete(),
// open the task's context doc in nvim (handled by the event loop)
KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext),
_ => {}

View file

@ -15,7 +15,7 @@ use crate::backend::Backend;
use crate::fmt::{fmt_date, today_local};
const HINTS: &str =
" j/k move Tab pane a add x done e date A attn b→blue o edit / search q quit";
" j/k move a add x done e date A attn b→blue D del o edit / search q quit";
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
@ -321,6 +321,17 @@ fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
}
fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
// A pending delete confirmation takes over the status line.
if let Some(pd) = &app.pending_delete {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" Delete \"{}\"? (y / N)", pd.title),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
))),
area,
);
return;
}
let hints = if app.search.is_some() {
SEARCH_HINTS
} else {

View file

@ -253,6 +253,32 @@ fn reschedule_sets_a_do_date_on_the_task() {
assert!(!got["do_date"].is_null(), "do_date was not set: {got}");
}
#[test]
fn deleting_a_recurring_task_tombstones_it_and_drops_it_from_the_view() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call(
"task.create",
json!({ "title": "Daily ritual", "attention": "red", "recurrence": "FREQ=DAILY" }),
)
.unwrap();
let id = task["node_id"].as_str().unwrap().to_string();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.begin_delete();
app.confirm_delete();
assert!(app.status.contains("deleted"), "status: {}", app.status);
assert!(app.tasks.is_empty(), "deleted task still listed");
// The node is tombstoned, not merely rolled forward (node.get includes
// tombstoned rows, with the flag set).
let got = c.call("node.get", json!({ "id": id })).unwrap();
assert_eq!(got["tombstoned"], true, "task node not tombstoned: {got}");
}
#[test]
fn pushing_to_blue_moves_a_task_out_of_top_of_mind() {
let (socket, _dir) = spawn_daemon();

View file

@ -27,6 +27,7 @@ type CreatedTask = (
struct Recorder {
created: Vec<CreatedTask>,
scheduled: Vec<(String, SchedulePatch)>,
tombstoned: Vec<String>,
}
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
@ -90,6 +91,10 @@ impl Backend for Fake {
fn skip(&mut self, _t: &str) -> Result<()> {
Ok(())
}
fn tombstone(&mut self, node_id: &str) -> Result<()> {
self.rec.borrow_mut().tombstoned.push(node_id.into());
Ok(())
}
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
Ok(())
}
@ -233,6 +238,30 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() {
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
}
#[test]
fn delete_requires_confirmation_then_tombstones() {
let rec = Rc::new(RefCell::new(Recorder::default()));
let mut fake = fixture();
fake.rec = rec.clone();
let mut app = App::new(fake).unwrap(); // starts on ToM, first task = t1
// Arming a delete doesn't tombstone yet.
app.begin_delete();
assert!(app.pending_delete.is_some());
assert!(rec.borrow().tombstoned.is_empty());
// Cancelling clears it without tombstoning.
app.cancel_delete();
assert!(app.pending_delete.is_none());
assert!(rec.borrow().tombstoned.is_empty());
// Arming then confirming tombstones the selected task.
app.begin_delete();
app.confirm_delete();
assert!(app.pending_delete.is_none());
assert_eq!(rec.borrow().tombstoned, vec!["t1".to_string()]);
}
#[test]
fn search_populates_results_and_task_hits_open_their_context() {
let mut fake = fixture();