From b4624af02114ed153d2c65824df4793ff4fb92c8 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 07:32:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20heph-tui=20T3=20=E2=80=94=20single?= =?UTF-8?q?-line=20NL=20quick-add=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a` is now Todoist-style one-line capture: parse a line like `Water plants tomorrow p2 #Camano Chores every 3 days` into title + attention (p1 red / p2 orange / p3 blue / p4 white) + do-date (today/tomorrow/+3d/fri/ISO) + recurrence (`every …`, longest suffix that parses) + project (`#Name`, greedy multi-word match against existing projects). An unresolved `#tag` stays in the title verbatim (no surprise project creation); with no `#project`, the task is filed under the selected sidebar project. The parser (`quickadd::parse`) is pure — `today` and the project list are passed in — reusing hephd::datespec for dates/recurrence, so it's exhaustively unit-tested (priority, relative/weekday dates, single + multi-word projects, recurrence extraction, unresolved tags, the all-at-once case, and the "every"-not-a-recurrence fallback). `Backend::create_task` gained a recurrence arg. The multi-step guided add it replaces is gone. 181 workspace tests; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/app.rs | 186 ++++++++++++++++++++++- crates/heph-tui/src/backend.rs | 3 + crates/heph-tui/src/lib.rs | 1 + crates/heph-tui/src/quickadd.rs | 223 ++++++++++++++++++++++++++++ crates/heph-tui/tests/agenda.rs | 45 ++++++ crates/heph-tui/tests/navigation.rs | 118 ++++++++++++++- 6 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 crates/heph-tui/src/quickadd.rs diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 89e331c..19442bc 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -3,10 +3,36 @@ //! lives in [`crate::ui`]; the terminal/event loop in `main.rs`. use anyhow::Result; -use heph_core::{Attention, RankedTask, BUILTIN_VIEWS}; +use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; use crate::backend::{Backend, Project}; +/// The interaction mode: normal navigation, or collecting a line of text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mode { + Normal, + Input(InputState), +} + +/// A single-line text prompt overlay (guided add / reschedule). `prompt` labels +/// it; `buffer` is what the user has typed; `kind` says what submit does (and, +/// for the multi-step add, carries the fields collected so far). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputState { + pub prompt: String, + pub buffer: String, + kind: InputKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InputKind { + /// Single-line natural-language capture (parsed by [`crate::quickadd`]). + QuickAdd, + Reschedule { + task_id: 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 { @@ -66,6 +92,7 @@ pub struct App { pub task_cursor: usize, pub preview: Preview, pub focus: Focus, + pub mode: Mode, pub status: String, pub should_quit: bool, } @@ -101,6 +128,7 @@ impl App { task_cursor: 0, preview: Preview::default(), focus: Focus::Sidebar, + mode: Mode::Normal, status: String::new(), should_quit: false, }; @@ -302,4 +330,160 @@ impl App { b.set_attention(&t.node_id, Attention::Blue) }); } + + // --- input modal (T2c: guided add + reschedule) --- + + fn current_project_id(&self) -> Option { + match self.sidebar.get(self.sidebar_cursor) { + Some(SidebarEntry::Project { id, .. }) => Some(id.clone()), + _ => None, + } + } + + /// The projects known to the sidebar (for quick-add `#project` resolution). + fn project_list(&self) -> Vec { + self.sidebar + .iter() + .filter_map(|e| match e { + SidebarEntry::Project { id, title } => Some(Project { + id: id.clone(), + title: title.clone(), + }), + _ => None, + }) + .collect() + } + + /// Start single-line natural-language capture. If no `#project` is given and + /// a project is the current sidebar selection, the task is filed there. + pub fn begin_add(&mut self) { + self.mode = Mode::Input(InputState { + prompt: "Add (e.g. Buy milk tomorrow p2 #Work every week)".into(), + buffer: String::new(), + kind: InputKind::QuickAdd, + }); + } + + /// Start rescheduling the highlighted task's do-date (blank = clear it). + pub fn begin_reschedule(&mut self) { + let Some(t) = self.selected_task() else { + return; + }; + self.mode = Mode::Input(InputState { + prompt: format!("Do-date for \"{}\" (blank clears)", t.title), + buffer: String::new(), + kind: InputKind::Reschedule { + task_id: t.node_id.clone(), + }, + }); + } + + /// Append a typed character to the active input. + pub fn input_push(&mut self, c: char) { + if let Mode::Input(state) = &mut self.mode { + state.buffer.push(c); + } + } + + /// Delete the last character of the active input. + pub fn input_backspace(&mut self) { + if let Mode::Input(state) = &mut self.mode { + state.buffer.pop(); + } + } + + /// Abandon the active input. + pub fn input_cancel(&mut self) { + self.mode = Mode::Normal; + self.status = "cancelled".into(); + } + + /// Re-enter an input step (used to keep state after a parse error). + fn reenter(&mut self, prompt: String, buffer: String, kind: InputKind) { + self.mode = Mode::Input(InputState { + prompt, + buffer, + kind, + }); + } + + /// Commit the active input — advancing the add flow, capturing the task, or + /// applying the reschedule. Parse errors keep the step (so typed text isn't + /// lost) and show the error in the status line. + pub fn input_submit(&mut self) { + let Mode::Input(state) = std::mem::replace(&mut self.mode, Mode::Normal) else { + return; + }; + let buf = state.buffer.trim().to_string(); + match state.kind { + InputKind::QuickAdd => { + if buf.is_empty() { + self.status = "add cancelled".into(); + return; + } + let projects = self.project_list(); + let parsed = crate::quickadd::parse(&buf, crate::fmt::today_local(), &projects); + if parsed.title.is_empty() { + self.status = "add cancelled (no title)".into(); + return; + } + // An explicit #project wins; otherwise file under the selected one. + let project = parsed.project_id.or_else(|| self.current_project_id()); + match self.backend.create_task( + &parsed.title, + parsed.attention, + parsed.do_date, + parsed.recurrence.as_deref(), + project.as_deref(), + ) { + Ok(_) => { + self.status = format!("added: {}", parsed.title); + self.reload(); + } + Err(e) => self.status = format!("error: {e}"), + } + } + InputKind::Reschedule { task_id } => { + let patch = if buf.is_empty() { + SchedulePatch { + do_date: Some(None), // clear + ..Default::default() + } + } else { + match parse_optional_date(&buf) { + Ok(ms) => SchedulePatch { + do_date: Some(ms), + ..Default::default() + }, + Err(e) => { + self.status = format!("error: {e}"); + self.reenter( + "Do-date (blank clears)".into(), + buf, + InputKind::Reschedule { task_id }, + ); + return; + } + } + }; + match self.backend.set_schedule(&task_id, patch) { + Ok(()) => { + self.status = "rescheduled".into(); + self.reload(); + } + Err(e) => self.status = format!("error: {e}"), + } + } + } + } +} + +/// Parse a do-date input to `Some(epoch_ms)`, or `None` when blank. +fn parse_optional_date(s: &str) -> Result> { + let s = s.trim(); + if s.is_empty() { + Ok(None) + } else { + Ok(Some(hephd::datespec::parse_date_ms(s)?)) + } } diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 223e9b2..9b783fc 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -47,6 +47,7 @@ pub trait Backend { title: &str, attention: Option, do_date: Option, + recurrence: Option<&str>, project_id: Option<&str>, ) -> Result; } @@ -142,6 +143,7 @@ impl Backend for ClientBackend { title: &str, attention: Option, do_date: Option, + recurrence: Option<&str>, project_id: Option<&str>, ) -> Result { let v = self.call( @@ -150,6 +152,7 @@ impl Backend for ClientBackend { "title": title, "attention": attention, "do_date": do_date, + "recurrence": recurrence, "project_id": project_id, }), )?; diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs index 83efc5c..c49e995 100644 --- a/crates/heph-tui/src/lib.rs +++ b/crates/heph-tui/src/lib.rs @@ -10,6 +10,7 @@ pub mod app; pub mod backend; pub mod editor; pub mod fmt; +pub mod quickadd; pub mod ui; pub use app::{App, Focus}; diff --git a/crates/heph-tui/src/quickadd.rs b/crates/heph-tui/src/quickadd.rs new file mode 100644 index 0000000..5153746 --- /dev/null +++ b/crates/heph-tui/src/quickadd.rs @@ -0,0 +1,223 @@ +//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style +//! capture: `Water plants tomorrow p2 #Chores every 3 days`. +//! +//! Pure and deterministic: `today` and the known projects are passed in, so the +//! whole parser is unit-testable. Recognized inline tokens are extracted and the +//! remainder is the title (order preserved). The recognized forms mirror the +//! owner's Todoist usage ([[design]] §6.2.1): +//! +//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white). +//! - **Project** `#Name` — resolved against existing projects, greedily matching +//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the +//! title verbatim (no surprise project creation). +//! - **Do-date** a `datespec` token: `today`/`tomorrow`/`+3d`/`fri`/ISO. +//! - **Recurrence** an `every …` phrase (the longest suffix that parses), e.g. +//! `every 3 days`, `every workday`, `every other wed`. + +use chrono::NaiveDate; +use heph_core::Attention; + +use crate::backend::Project; + +/// The structured result of parsing a quick-add line. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Parsed { + pub title: String, + pub attention: Option, + pub do_date: Option, + /// An RFC-5545 RRULE, if a recurrence phrase was recognized. + pub recurrence: Option, + pub project_id: Option, +} + +fn priority_attention(token: &str) -> Option { + match token.to_ascii_lowercase().as_str() { + "p1" => Some(Attention::Red), + "p2" => Some(Attention::Orange), + "p3" => Some(Attention::Blue), + "p4" => Some(Attention::White), + _ => None, + } +} + +/// Parse a quick-add line against `today` and the `projects` known to the store. +pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed { + let mut tokens: Vec = input.split_whitespace().map(str::to_string).collect(); + let mut out = Parsed::default(); + + extract_recurrence(&mut tokens, &mut out); + + let mut title: Vec = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let tok = &tokens[i]; + + if let Some(a) = priority_attention(tok) { + out.attention = Some(a); + i += 1; + continue; + } + + if let Some(stripped) = tok.strip_prefix('#') { + if let Some((id, consumed)) = match_project(stripped, &tokens[i + 1..], projects) { + out.project_id = Some(id); + i += 1 + consumed; + continue; + } + // Unresolved #tag: keep the word (with the #) in the title. + } + + if out.do_date.is_none() { + if let Ok(date) = hephd::datespec::parse_date(tok, today) { + out.do_date = Some(hephd::datespec::to_epoch_ms(date)); + i += 1; + continue; + } + } + + title.push(tok.clone()); + i += 1; + } + + out.title = title.join(" "); + out +} + +/// Find the first `every` token and consume the longest suffix starting there +/// that `datespec::parse_recurrence` accepts, recording its RRULE. +fn extract_recurrence(tokens: &mut Vec, out: &mut Parsed) { + let Some(start) = tokens.iter().position(|t| t.eq_ignore_ascii_case("every")) else { + return; + }; + for end in (start + 1..=tokens.len()).rev() { + let phrase = tokens[start..end].join(" "); + if let Ok(rrule) = hephd::datespec::parse_recurrence(&phrase) { + out.recurrence = Some(rrule); + tokens.drain(start..end); + return; + } + } +} + +/// Greedily match `first` (+ following words) against a known project title, +/// case-insensitively, longest-first. Returns `(project_id, extra_words_taken)`. +fn match_project(first: &str, rest: &[String], projects: &[Project]) -> Option<(String, usize)> { + // Try the longest candidate (up to 4 trailing words) down to just `first`. + let max_extra = rest.len().min(4); + for extra in (0..=max_extra).rev() { + let mut candidate = first.to_string(); + for w in &rest[..extra] { + candidate.push(' '); + candidate.push_str(w); + } + if let Some(p) = projects + .iter() + .find(|p| p.title.eq_ignore_ascii_case(&candidate)) + { + return Some((p.id.clone(), extra)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 6, 3).unwrap() + } + + fn projects() -> Vec { + vec![ + Project { + id: "work".into(), + title: "Work".into(), + }, + Project { + id: "camano".into(), + title: "Camano Chores".into(), + }, + ] + } + + fn p(input: &str) -> Parsed { + parse(input, today(), &projects()) + } + + fn ms(y: i32, m: u32, d: u32) -> i64 { + hephd::datespec::to_epoch_ms(NaiveDate::from_ymd_opt(y, m, d).unwrap()) + } + + #[test] + fn plain_title() { + let r = p("Buy milk"); + assert_eq!(r.title, "Buy milk"); + assert_eq!(r.attention, None); + assert_eq!(r.do_date, None); + assert_eq!(r.recurrence, None); + assert_eq!(r.project_id, None); + } + + #[test] + fn priority_maps_to_attention() { + assert_eq!(p("Email boss p1").attention, Some(Attention::Red)); + assert_eq!(p("Email boss p2").attention, Some(Attention::Orange)); + assert_eq!(p("Email boss p3").attention, Some(Attention::Blue)); + assert_eq!(p("Email boss p4").attention, Some(Attention::White)); + assert_eq!(p("Email boss p1").title, "Email boss"); + } + + #[test] + fn relative_date_is_extracted() { + let r = p("Call dentist tomorrow"); + assert_eq!(r.title, "Call dentist"); + assert_eq!(r.do_date, Some(ms(2026, 6, 4))); + } + + #[test] + fn single_word_project_resolves() { + let r = p("Standup #Work"); + assert_eq!(r.title, "Standup"); + assert_eq!(r.project_id.as_deref(), Some("work")); + } + + #[test] + fn multi_word_project_resolves_greedily() { + let r = p("Sweep deck #Camano Chores"); + assert_eq!(r.title, "Sweep deck"); + assert_eq!(r.project_id.as_deref(), Some("camano")); + } + + #[test] + fn unresolved_tag_stays_in_title() { + let r = p("Buy #groceries milk"); + assert_eq!(r.title, "Buy #groceries milk"); + assert_eq!(r.project_id, None); + } + + #[test] + fn recurrence_phrase_is_extracted() { + let r = p("Water plants every 3 days"); + assert_eq!(r.title, "Water plants"); + assert_eq!(r.recurrence.as_deref(), Some("FREQ=DAILY;INTERVAL=3")); + } + + #[test] + fn everything_at_once() { + let r = p("Plan trip p2 friday #Work every week"); + assert_eq!(r.title, "Plan trip"); + assert_eq!(r.attention, Some(Attention::Orange)); + assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday + assert_eq!(r.project_id.as_deref(), Some("work")); + assert_eq!(r.recurrence.as_deref(), Some("FREQ=WEEKLY")); + } + + #[test] + fn non_recurrence_every_stays_in_title() { + // "every report" isn't a recurrence; leave it alone. + let r = p("Review every report"); + assert_eq!(r.title, "Review every report"); + assert_eq!(r.recurrence, None); + } +} diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index cf82816..518162e 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -153,6 +153,51 @@ fn completing_a_task_removes_it_from_top_of_mind() { assert!(screen(&app).contains("nothing here")); } +fn type_and_submit(app: &mut App, s: &str) { + for ch in s.chars() { + app.input_push(ch); + } + app.input_submit(); +} + +#[test] +fn quick_add_captures_a_task_that_appears_in_the_view() { + let (socket, _dir) = spawn_daemon(); + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + assert!(app.tasks.is_empty()); + + app.begin_add(); + // Single-line NL: p1 → red, so it lands in Top of Mind (the default view). + type_and_submit(&mut app, "Call the plumber p1"); + + assert!(app.status.contains("added"), "status: {}", app.status); + assert!( + app.tasks.iter().any(|t| t.title == "Call the plumber"), + "added task missing from Top of Mind" + ); +} + +#[test] +fn reschedule_sets_a_do_date_on_the_task() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + let task = c + .call( + "task.create", + json!({ "title": "Mail the form", "attention": "red" }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + app.begin_reschedule(); + type_and_submit(&mut app, "today"); + + // Verify directly via the daemon (the view may drop it depending on clocks). + let got = c.call("task.get", json!({ "id": id })).unwrap(); + assert!(!got["do_date"].is_null(), "do_date was not set: {got}"); +} + #[test] fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index d5fb939..4d1e8cb 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -1,7 +1,9 @@ //! Navigation/selection logic against an in-memory fake backend — no terminal, //! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1). +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use anyhow::Result; use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState}; @@ -10,6 +12,23 @@ use heph_tui::{ backend::{Backend, Project}, }; +/// A recorded `create_task`: (title, attention, do_date, recurrence, project_id). +type CreatedTask = ( + String, + Option, + Option, + Option, + Option, +); + +/// Records mutations the App makes, so tests can assert on them after the App +/// has consumed the backend. +#[derive(Default)] +struct Recorder { + created: Vec, + scheduled: Vec<(String, SchedulePatch)>, +} + fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { RankedTask { node_id: id.into(), @@ -31,6 +50,7 @@ struct Fake { projects: Vec, by_project: HashMap>, bodies: HashMap, + rec: Rc>, } impl Backend for Fake { @@ -59,16 +79,25 @@ impl Backend for Fake { fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> { Ok(()) } - fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> { + fn set_schedule(&mut self, t: &str, p: SchedulePatch) -> Result<()> { + self.rec.borrow_mut().scheduled.push((t.into(), p)); Ok(()) } fn create_task( &mut self, - _title: &str, - _a: Option, - _d: Option, - _p: Option<&str>, + title: &str, + a: Option, + d: Option, + recur: Option<&str>, + p: Option<&str>, ) -> Result { + self.rec.borrow_mut().created.push(( + title.into(), + a, + d, + recur.map(str::to_string), + p.map(str::to_string), + )); Ok("new".into()) } } @@ -157,3 +186,82 @@ fn attention_cycles_white_orange_red_blue() { assert_eq!(next_attention(Some(Attention::Blue)), Attention::White); assert_eq!(next_attention(None), Attention::White); } + +fn type_and_submit(app: &mut App, s: &str) { + for c in s.chars() { + app.input_push(c); + } + app.input_submit(); +} + +#[test] +fn quick_add_files_under_the_current_project_when_no_tag_given() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + // Select the project so the new task is filed there. + for _ in 0..5 { + app.move_sidebar(1); + } + assert_eq!(app.task_pane_title(), "Camano"); + + app.begin_add(); + type_and_submit(&mut app, "Fix the dock p2"); + + let created = &rec.borrow().created; + assert_eq!(created.len(), 1); + assert_eq!(created[0].0, "Fix the dock"); + assert_eq!(created[0].1, Some(Attention::Orange)); // p2 + assert_eq!(created[0].2, None); // no do-date + assert_eq!(created[0].3, None); // no recurrence + assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) +} + +#[test] +fn quick_add_passes_inline_recurrence_and_project_through() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_add(); + // #Camano resolves to the fixture project id "p1"; "every week" → weekly. + type_and_submit(&mut app, "Water the ferns #Camano every week"); + + let created = &rec.borrow().created; + assert_eq!(created.len(), 1); + assert_eq!(created[0].0, "Water the ferns"); + assert_eq!(created[0].3.as_deref(), Some("FREQ=WEEKLY")); + assert_eq!(created[0].4.as_deref(), Some("p1")); +} + +#[test] +fn empty_title_cancels_the_add() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_add(); + type_and_submit(&mut app, ""); // empty title aborts + assert!(rec.borrow().created.is_empty()); +} + +#[test] +fn reschedule_with_blank_clears_the_do_date() { + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + app.begin_reschedule(); // on the first ToM task (t1) + type_and_submit(&mut app, ""); // blank => clear + + let scheduled = &rec.borrow().scheduled; + assert_eq!(scheduled.len(), 1); + assert_eq!(scheduled[0].0, "t1"); + // do_date present-and-null = "clear" (the double-option). + assert_eq!(scheduled[0].1.do_date, Some(None)); +}