feat(tui): m move-to-project picker (§8.1)
Some checks failed
Build / validate (pull_request) Failing after 3m48s

`m` opens a list-pick overlay on the highlighted task — "(Unfile)" then
every project — and re-files it via `task.set_project` (cursor starts on
the task's current project). j/k navigate, Enter applies, Esc cancels.
Adds `Backend::set_project`, a `Mode::MoveToProject` overlay, and its
render. Navigation tests cover refile + cancel.

Closes the last Todoist-parity capture gap (§14 item 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 10:40:31 -07:00
commit 288e902573
7 changed files with 210 additions and 8 deletions

View file

@ -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<String>,
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<MoveOption>,
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>) -> Attention {
@ -388,6 +407,72 @@ impl<B: Backend> App<B> {
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<String> {

View file

@ -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,

View file

@ -119,6 +119,18 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None;
}
// The move-to-project picker captures navigation/select/cancel.
if matches!(app.mode, Mode::MoveToProject(_)) {
match key.code {
KeyCode::Esc => 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<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('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),

View file

@ -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<B: Backend>(frame: &mut Frame, app: &App<B>) {
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<ListItem> = 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();

View file

@ -28,6 +28,7 @@ struct Recorder {
created: Vec<CreatedTask>,
scheduled: Vec<(String, SchedulePatch)>,
tombstoned: Vec<String>,
refiled: Vec<(String, Option<String>)>,
}
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<_>>(),
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()));

View file

@ -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 <name>` 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 <name>` 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 (p1p4) + 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 <task> --project <name>` 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 <task> --project <name>` 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.

View file

@ -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('<ctx-id>')"` (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 <name>|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.