generated from eblume/project-template
feat(tui): m move-to-project picker (§8.1)
Some checks failed
Build / validate (pull_request) Failing after 3m48s
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:
parent
df7f43788b
commit
288e902573
7 changed files with 210 additions and 8 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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 (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 <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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue