diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 6ef3d12..b399dcf 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -3,10 +3,22 @@ //! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. use anyhow::Result; -use heph_core::{RankedTask, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, BUILTIN_VIEWS}; use crate::backend::{Backend, Project}; +/// 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 { + match current { + Some(Attention::White) => Attention::Orange, + Some(Attention::Orange) => Attention::Red, + Some(Attention::Red) => Attention::Blue, + Some(Attention::Blue) => Attention::White, + None => Attention::White, + } +} + /// Which pane has the keyboard. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { @@ -217,11 +229,66 @@ impl App { }; } - /// Run `f` against the backend; any error lands in the status line. Used by - /// mutation gestures (T2). - pub fn try_mutate(&mut self, f: impl FnOnce(&mut B) -> Result<()>) { - if let Err(e) = f(&mut self.backend) { - self.status = format!("error: {e}"); + // --- triage mutations (T2a: single-keypress, no input) --- + + /// Run `f` against the backend; on success set `ok` as the status and reload, + /// on error surface it in the status line. The shared shape for the gestures. + fn mutate(&mut self, ok: String, f: impl FnOnce(&mut B) -> Result<()>) { + match f(&mut self.backend) { + Ok(()) => { + self.status = ok; + self.reload(); + } + Err(e) => self.status = format!("error: {e}"), } } + + /// Mark the highlighted task done (recurring tasks roll forward). + pub fn complete_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("done: {}", t.title), |b| { + b.set_state(&t.node_id, "done") + }); + } + + /// Drop the highlighted task (let it go — the blue keep/drop review). + pub fn drop_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("dropped: {}", t.title), |b| { + b.set_state(&t.node_id, "dropped") + }); + } + + /// Skip the highlighted recurring task to its next occurrence. + pub fn skip_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id)); + } + + /// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue). + pub fn cycle_attention_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + let next = next_attention(t.attention); + self.mutate(format!("{}: {}", next.as_str(), t.title), |b| { + b.set_attention(&t.node_id, next) + }); + } + + /// Push the highlighted task to On Deck (blue) — the pressure-relief valve. + pub fn push_to_blue_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.mutate(format!("→ on deck: {}", t.title), |b| { + b.set_attention(&t.node_id, Attention::Blue) + }); + } } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 1cc1827..b1e596a 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -76,6 +76,12 @@ fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Char('k') | KeyCode::Up => move_up(app), KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + // triage mutations (act on the highlighted task) + KeyCode::Char('x') => app.complete_selected(), + KeyCode::Char('d') => app.drop_selected(), + KeyCode::Char('s') => app.skip_selected(), + KeyCode::Char('A') => app.cycle_attention_selected(), + KeyCode::Char('b') => app.push_to_blue_selected(), _ => {} } } diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 6a8e799..69c5b7d 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -14,7 +14,8 @@ use crate::app::{App, Focus, SidebarEntry}; use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; -const HINTS: &str = " j/k move Tab/h/l pane Enter open r refresh q quit"; +const HINTS: &str = + " j/k move Tab pane x done d drop s skip A attn b→blue r refresh q quit"; /// Draw the whole UI for the current frame. pub fn render(frame: &mut Frame, app: &App) { diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index c0197bf..cf82816 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -132,3 +132,44 @@ fn preview_shows_the_selected_tasks_context_body() { let s = screen(&app); assert!(s.contains("fill form"), "context body not previewed:\n{s}"); } + +#[test] +fn completing_a_task_removes_it_from_top_of_mind() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + c.call( + "task.create", + json!({ "title": "Pay the bill", "attention": "red" }), + ) + .unwrap(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert_eq!(app.tasks.len(), 1); + app.complete_selected(); + + assert!(app.status.contains("done"), "status: {}", app.status); + assert!(app.tasks.is_empty(), "completed task still listed"); + // ...and the screen reflects the empty list. + assert!(screen(&app).contains("nothing here")); +} + +#[test] +fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + c.call( + "task.create", + json!({ "title": "Cool it down", "attention": "orange" }), + ) + .unwrap(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert_eq!(app.tasks.len(), 1); + app.push_to_blue_selected(); + assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); + + // It now appears under On Deck (sidebar row 2). + app.move_sidebar(1); + assert_eq!(app.task_pane_title(), "On Deck"); + assert_eq!(app.selected_task().unwrap().title, "Cool it down"); +} diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 10c2f90..d5fb939 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -147,3 +147,13 @@ fn move_task_clamps_at_the_ends() { app.move_task(50); // past the end assert_eq!(app.task_cursor, 1); } + +#[test] +fn attention_cycles_white_orange_red_blue() { + use heph_tui::app::next_attention; + assert_eq!(next_attention(Some(Attention::White)), Attention::Orange); + assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red); + assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue); + assert_eq!(next_attention(Some(Attention::Blue)), Attention::White); + assert_eq!(next_attention(None), Attention::White); +}