diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index c6446ff..5673ae0 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -12,6 +12,8 @@ use crate::backend::{Backend, Project, SearchHit}; pub enum Mode { Normal, Input(InputState), + /// A list-pick overlay for re-filing the highlighted task to a project. + MoveToProject(MoveState), } /// A single-line text prompt overlay (guided add / reschedule). `prompt` labels @@ -51,6 +53,23 @@ pub struct PendingDelete { pub title: String, } +/// One choice in the move-to-project picker: a project (or `None` = unfile). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveOption { + pub project_id: Option, + pub label: String, +} + +/// The move-to-project picker state: which task is being re-filed, the choices +/// (an "(Unfile)" entry then every project), and the highlighted row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveState { + pub task_id: String, + pub task_title: String, + pub options: Vec, + pub cursor: usize, +} + /// 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 { @@ -388,6 +407,72 @@ impl App { self.status = "delete cancelled".into(); } + // --- move-to-project picker (§8.1) --- + + /// Open the move-to-project picker for the highlighted task. Choices are an + /// "(Unfile)" entry followed by every project; the cursor starts on the + /// task's current project when it has one. + pub fn begin_move(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + let mut options = vec![MoveOption { + project_id: None, + label: "(Unfile)".into(), + }]; + for p in self.project_list() { + options.push(MoveOption { + project_id: Some(p.id), + label: p.title, + }); + } + let cursor = t + .project_id + .as_deref() + .and_then(|pid| { + options + .iter() + .position(|o| o.project_id.as_deref() == Some(pid)) + }) + .unwrap_or(0); + self.mode = Mode::MoveToProject(MoveState { + task_id: t.node_id, + task_title: t.title, + options, + cursor, + }); + } + + /// Move the picker cursor by `delta` (clamped). + pub fn move_picker_move(&mut self, delta: isize) { + if let Mode::MoveToProject(m) = &mut self.mode { + let max = m.options.len() as isize - 1; + m.cursor = (m.cursor as isize + delta).clamp(0, max) as usize; + } + } + + /// Apply the highlighted choice: re-file (or unfile) the task and reload. + pub fn move_picker_submit(&mut self) { + let Mode::MoveToProject(m) = &self.mode else { + return; + }; + let Some(choice) = m.options.get(m.cursor).cloned() else { + return; + }; + let task_id = m.task_id.clone(); + let ok = format!("→ {}: {}", choice.label, m.task_title); + self.mode = Mode::Normal; + self.mutate(ok, |b| { + b.set_project(&task_id, choice.project_id.as_deref()) + }); + } + + /// Dismiss the picker without re-filing. + pub fn move_picker_cancel(&mut self) { + self.mode = Mode::Normal; + self.status = "move cancelled".into(); + } + // --- input modal (T2c: guided add + reschedule) --- fn current_project_id(&self) -> Option { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 60d9e80..9db073b 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -57,6 +57,8 @@ pub trait Backend { fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>; /// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option. fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>; + /// Re-file a task under a project (or unfile it when `project_id` is `None`). + fn set_project(&mut self, task_id: &str, project_id: Option<&str>) -> Result<()>; /// Capture a committed task; returns its node id. fn create_task( &mut self, @@ -181,6 +183,14 @@ impl Backend for ClientBackend { Ok(()) } + fn set_project(&mut self, task_id: &str, project_id: Option<&str>) -> Result<()> { + self.call( + "task.set_project", + json!({ "id": task_id, "project_id": project_id }), + )?; + Ok(()) + } + fn create_task( &mut self, title: &str, diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 7fa989a..c48a42c 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,6 +119,18 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.move_picker_cancel(), + KeyCode::Enter => app.move_picker_submit(), + KeyCode::Char('j') | KeyCode::Down => app.move_picker_move(1), + KeyCode::Char('k') | KeyCode::Up => app.move_picker_move(-1), + _ => {} + } + return None; + } + // While search results are shown, the center pane navigates them. if app.search.is_some() { app.status.clear(); @@ -154,6 +166,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.skip_selected(), KeyCode::Char('A') => app.cycle_attention_selected(), KeyCode::Char('b') => app.push_to_blue_selected(), + KeyCode::Char('m') => app.begin_move(), 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), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 8ede817..6151c27 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -10,7 +10,7 @@ use ratatui::{ Frame, }; -use crate::app::{App, Focus, InputState, Mode, SidebarEntry}; +use crate::app::{App, Focus, InputState, Mode, MoveState, SidebarEntry}; use crate::backend::Backend; use crate::fmt::{fmt_date, today_local}; @@ -44,11 +44,47 @@ pub fn render(frame: &mut Frame, app: &App) { render_preview(frame, app, panes[2]); render_status(frame, app, outer[1]); - if let Mode::Input(state) = &app.mode { - render_input(frame, state); + match &app.mode { + Mode::Input(state) => render_input(frame, state), + Mode::MoveToProject(state) => render_move(frame, state), + Mode::Normal => {} } } +/// A centered list popup for re-filing the highlighted task to a project. +fn render_move(frame: &mut Frame, state: &MoveState) { + let area = frame.area(); + let width = area.width.saturating_sub(8).clamp(24, 50); + let rows = state.options.len() as u16; + let height = (rows + 2).min(area.height.saturating_sub(2)).max(3); + let popup = Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + area.height.saturating_sub(height) / 3, + width, + height, + }; + frame.render_widget(Clear, popup); + let items: Vec = state + .options + .iter() + .enumerate() + .map(|(i, o)| { + let mut style = Style::default(); + if i == state.cursor { + style = style.fg(Color::Black).bg(Color::Cyan); + } + ListItem::new(Line::from(Span::styled(o.label.clone(), style))) + }) + .collect(); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" Move \"{}\" to ", state.task_title)), + ); + frame.render_widget(list, popup); +} + /// A centered single-line input popup (guided add / reschedule). fn render_input(frame: &mut Frame, state: &InputState) { let area = frame.area(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index effcc8c..db85c8d 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -28,6 +28,7 @@ struct Recorder { created: Vec, scheduled: Vec<(String, SchedulePatch)>, tombstoned: Vec, + refiled: Vec<(String, Option)>, } fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask { @@ -102,6 +103,13 @@ impl Backend for Fake { self.rec.borrow_mut().scheduled.push((t.into(), p)); Ok(()) } + fn set_project(&mut self, t: &str, project_id: Option<&str>) -> Result<()> { + self.rec + .borrow_mut() + .refiled + .push((t.into(), project_id.map(str::to_string))); + Ok(()) + } fn create_task( &mut self, title: &str, @@ -323,6 +331,56 @@ fn empty_title_cancels_the_add() { assert!(rec.borrow().created.is_empty()); } +#[test] +fn move_to_project_picker_refiles_the_selected_task() { + use heph_tui::app::Mode; + let rec = Rc::new(RefCell::new(Recorder::default())); + let mut fake = fixture(); + fake.rec = rec.clone(); + let mut app = App::new(fake).unwrap(); + + // Open the picker on the first ToM task (t1). Options: (Unfile) + Camano. + app.begin_move(); + match &app.mode { + Mode::MoveToProject(m) => { + assert_eq!(m.task_id, "t1"); + assert_eq!( + m.options + .iter() + .map(|o| o.label.as_str()) + .collect::>(), + vec!["(Unfile)", "Camano"] + ); + assert_eq!(m.cursor, 0, "t1 has no project → cursor starts at (Unfile)"); + } + _ => panic!("expected the move picker"), + } + + // Pick Camano (the second option) and submit. + app.move_picker_move(1); + app.move_picker_submit(); + + assert!(matches!(app.mode, Mode::Normal)); + let refiled = &rec.borrow().refiled; + assert_eq!(refiled.len(), 1); + assert_eq!(refiled[0], ("t1".into(), Some("p1".into()))); +} + +#[test] +fn move_to_project_cancel_refiles_nothing() { + use heph_tui::app::Mode; + 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_move(); + app.move_picker_cancel(); + + assert!(matches!(app.mode, Mode::Normal)); + assert!(rec.borrow().refiled.is_empty()); +} + #[test] fn reschedule_with_blank_clears_the_do_date() { let rec = Rc::new(RefCell::new(Recorder::default())); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index eef0a10..baa9155 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -24,4 +24,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Daemon lifecycle is now an explicit OS service, and all surfaces are connect-only (no more auto-spawn). `heph daemon start/stop/restart/status/uninstall` idempotently manages a launchd agent (macOS) or systemd user service (Linux) that runs `hephd` on your default store; `heph.nvim` no longer spawns or supervises a daemon — it just connects and points you at `heph daemon start` if none is running. Rationale: once the CLI became a first-class surface, a daemon owned by one surface couldn't be shared (see [[run-the-daemon]], [[design]] §4). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view ` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view ` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). - `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. -- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. `heph edit --project ` now routes through it (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. +- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. In `heph-tui`, **`m`** opens a list-pick overlay ("(Unfile)" then every project) on the highlighted task. `heph edit --project ` now routes through the same RPC (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task. This closes the last Todoist-parity capture gap. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index c9bd9ee..b72cb7f 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,12 +254,12 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba - **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure. - **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (attention-colored rows with compact human do/late) · **preview** (canonical-context doc body + `log.tail`). -- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: move-to-project — needs a new `task.set_project` RPC, no link-remove RPC yet; humanizing the displayed RRULE is later polish.)* +- **Gestures** ✅ — `j/k` move · `Tab`/`h`/`l` focus · `a` **single-line NL quick-add** (Todoist-style: `Buy milk tomorrow p2 #Work every week` → title + attention `p1`..`p4` + do-date + `every …` recurrence + `#project`; no `#project` files it under the selected one) · `x` done · `s` skip · `A` cycle attention · `e` reschedule do-date · `b` push-to-blue · `d` drop · `D` **delete/tombstone** (y/N confirm — true soft-delete, recurring included) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `o` edit context in nvim · `/` **FTS search** (overlay; Enter opens a hit — a task at its context doc — in nvim) · `r` refresh · `q` quit. The sidebar lists the **§8.2 named filter views** — [[design]] §6.2 "filters = saved views" made interactive. Recurring tasks show a **`↻` marker**, and the **selected row expands inline** with a dimmed detail block (project · recurrence rule · do/late). *(Remaining: humanizing the displayed RRULE is later polish.)* - **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)* - **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend. - **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`. -- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows except move-to-project: - - **`m` move-to-project** — re-file the selected task via the new `task.set_project` RPC (the last Todoist-parity gap). +- **`m` move-to-project** ✅ — re-file the selected task via the `task.set_project` RPC (a list-pick overlay), closing the last Todoist-parity gap. +- **Planned UX wave** (§14 roadmap, 2026-06-03) — all client-side over the existing `RankedTask` rows: - **flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (HSL→truecolor, 256-color fallback; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement. - **sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with **non-selectable `──── Name ────` separators** (`j`/`k` skip them). The view filter always runs before the sort. - **scrollbar** — a `ratatui` `Scrollbar` on the task list when it overflows. @@ -446,7 +446,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi > > The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order: -1. ⏳ **`heph-tui` — move-to-project (§8.1) — last Todoist-parity gap:** NL quick-add and `/` FTS search are **done**; re-filing a task's project needs a new **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — there's no link-remove RPC today; OR-set link semantics, no task-scalar op). Small backend slice + a TUI gesture (`m`). Also **unblocks** the project-edit path of the frontmatter surface (§8.3). +1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project |none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3). 2. ⏳ **`heph-tui` task-list UX wave (§8.1) — no backend (`RankedTask` already carries every field):** - **(a) flag column + project-colored bullets** — a leading **flag glyph** colored by attention (red/orange/blue; **blank for white**), and the **bullet `●` colored by its project** via a stable `hash(project_id) → hue` (HSL→truecolor `Color::Rgb`, 256-color fallback). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change). - **(b) sort toggle `s`** — **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with **non-selectable `──── Name ────` separator rows** between groups (`j`/`k` skip them). View filtering always runs **before** the sort.