feat(tui): heph-tui T3 — single-line NL quick-add (§8.1)

`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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 07:32:45 -07:00
commit b4624af021
6 changed files with 570 additions and 6 deletions

View file

@ -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>) -> Attention {
@ -66,6 +92,7 @@ pub struct App<B: Backend> {
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<B: Backend> App<B> {
task_cursor: 0,
preview: Preview::default(),
focus: Focus::Sidebar,
mode: Mode::Normal,
status: String::new(),
should_quit: false,
};
@ -302,4 +330,160 @@ impl<B: Backend> App<B> {
b.set_attention(&t.node_id, Attention::Blue)
});
}
// --- input modal (T2c: guided add + reschedule) ---
fn current_project_id(&self) -> Option<String> {
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<Project> {
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<Option<i64>> {
let s = s.trim();
if s.is_empty() {
Ok(None)
} else {
Ok(Some(hephd::datespec::parse_date_ms(s)?))
}
}

View file

@ -47,6 +47,7 @@ pub trait Backend {
title: &str,
attention: Option<Attention>,
do_date: Option<i64>,
recurrence: Option<&str>,
project_id: Option<&str>,
) -> Result<String>;
}
@ -142,6 +143,7 @@ impl Backend for ClientBackend {
title: &str,
attention: Option<Attention>,
do_date: Option<i64>,
recurrence: Option<&str>,
project_id: Option<&str>,
) -> Result<String> {
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,
}),
)?;

View file

@ -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};

View file

@ -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<Attention>,
pub do_date: Option<i64>,
/// An RFC-5545 RRULE, if a recurrence phrase was recognized.
pub recurrence: Option<String>,
pub project_id: Option<String>,
}
fn priority_attention(token: &str) -> Option<Attention> {
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<String> = input.split_whitespace().map(str::to_string).collect();
let mut out = Parsed::default();
extract_recurrence(&mut tokens, &mut out);
let mut title: Vec<String> = 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<String>, 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<Project> {
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);
}
}

View file

@ -153,6 +153,51 @@ fn completing_a_task_removes_it_from_top_of_mind() {
assert!(screen(&app).contains("nothing here"));
}
fn type_and_submit<B: heph_tui::Backend>(app: &mut App<B>, 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();

View file

@ -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<Attention>,
Option<i64>,
Option<String>,
Option<String>,
);
/// Records mutations the App makes, so tests can assert on them after the App
/// has consumed the backend.
#[derive(Default)]
struct Recorder {
created: Vec<CreatedTask>,
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<Project>,
by_project: HashMap<String, Vec<RankedTask>>,
bodies: HashMap<String, String>,
rec: Rc<RefCell<Recorder>>,
}
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<Attention>,
_d: Option<i64>,
_p: Option<&str>,
title: &str,
a: Option<Attention>,
d: Option<i64>,
recur: Option<&str>,
p: Option<&str>,
) -> Result<String> {
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<B: Backend>(app: &mut App<B>, 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));
}