diff --git a/crates/heph-core/src/filter.rs b/crates/heph-core/src/filter.rs index 745be6b..0f16f9d 100644 --- a/crates/heph-core/src/filter.rs +++ b/crates/heph-core/src/filter.rs @@ -3,8 +3,8 @@ //! A view is a **predicate expressed as data** (mirroring §7's "order as //! data"): the engine [`Store::list`](crate::store::Store::list) takes a //! [`ListFilter`] and returns the matching outstanding tasks as -//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / On Deck -//! / Chores / Work Tasks / Tasks) are derived from the owner's Todoist filter +//! [`RankedTask`] rows. The five built-in [`ViewSpec`]s (Top of Mind / Tasks / +//! Work Tasks / Chores / On Deck) are derived from the owner's Todoist filter //! queries (see `docs/explanation/design.md` §6.2.1) and realized in heph terms //! (attention: p1→red, p2→orange, p4→white, p3→blue). @@ -98,6 +98,8 @@ pub struct ViewSpec { /// The five built-in views (tech-spec §8.2), each realized from the verbatim /// Todoist query in design §6.2.1. +// Sidebar / `heph view` order (owner's preference): Top of Mind, Tasks, +// Work Tasks, Chores, On Deck. pub const BUILTIN_VIEWS: &[ViewSpec] = &[ // (p1|p2) & (no date|today|overdue) ViewSpec { @@ -109,36 +111,6 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ exclude_names: &[], actionable: true, }, - // p3 & (no date|overdue|today) - ViewSpec { - name: "ondeck", - title: "On Deck", - attention_in: &[Attention::Blue], - attention_not: &[], - scope_names: &[], - exclude_names: &[], - actionable: true, - }, - // (today|overdue|no date) & (#Chores|#Camano Chores) - ViewSpec { - name: "chores", - title: "Chores", - attention_in: &[], - attention_not: &[], - scope_names: &["Chores", "Camano Chores"], - exclude_names: &[], - actionable: true, - }, - // #Work & !p3 & (…) & !subtask - ViewSpec { - name: "work", - title: "Work Tasks", - attention_in: &[], - attention_not: &[Attention::Blue], - scope_names: &["Work"], - exclude_names: &[], - actionable: true, - }, // !p3 & (…) & !(#Daily Routine|#Work Routine|#Chores|#Camano Chores|#Work|…) & !subtask ViewSpec { name: "tasks", @@ -155,6 +127,36 @@ pub const BUILTIN_VIEWS: &[ViewSpec] = &[ ], actionable: true, }, + // #Work & !p3 & (…) & !subtask + ViewSpec { + name: "work", + title: "Work Tasks", + attention_in: &[], + attention_not: &[Attention::Blue], + scope_names: &["Work"], + exclude_names: &[], + actionable: true, + }, + // (today|overdue|no date) & (#Chores|#Camano Chores) + ViewSpec { + name: "chores", + title: "Chores", + attention_in: &[], + attention_not: &[], + scope_names: &["Chores", "Camano Chores"], + exclude_names: &[], + actionable: true, + }, + // p3 & (no date|overdue|today) + ViewSpec { + name: "ondeck", + title: "On Deck", + attention_in: &[Attention::Blue], + attention_not: &[], + scope_names: &[], + exclude_names: &[], + actionable: true, + }, ]; /// Look up a built-in view by its short name (`tom|ondeck|chores|work|tasks`). diff --git a/crates/heph-core/tests/wikilinks.rs b/crates/heph-core/tests/wikilinks.rs index 7ab1915..b5d4a2d 100644 --- a/crates/heph-core/tests/wikilinks.rs +++ b/crates/heph-core/tests/wikilinks.rs @@ -18,7 +18,11 @@ fn update_collapses_name_matching_labels_and_materializes_by_id() { // The buffer the user saves carries the expanded label `[[id|Roof]]`. let updated = s - .update_node(&src.id, None, Some(format!("see [[{}|Roof]] here", target.id))) + .update_node( + &src.id, + None, + Some(format!("see [[{}|Roof]] here", target.id)), + ) .unwrap(); // Stored body collapsed the auto-label back to the canonical bare id. assert_eq!( diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 9b1af11..6e50a0c 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -429,6 +429,17 @@ impl App { }; } + /// The `` gesture: from the sidebar, drill into the task list; on a + /// task, open its context doc in the editor (returns the node id to open). + pub fn enter(&mut self) -> Option { + if self.focus == Focus::Sidebar { + self.focus_tasks(); + None + } else { + self.selected_context_id() + } + } + // --- triage mutations (T2a: single-keypress, no input) --- /// Run `f` against the backend; on success set `ok` as the status and reload, diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 89ee6e6..b0da4e9 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -155,7 +155,9 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option move_down(app), KeyCode::Char('k') | KeyCode::Up => move_up(app), KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), - KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(), + // Enter: drill sidebar→tasks, or open the selected task's context in nvim. + KeyCode::Enter => return app.enter().map(Action::EditContext), // capture + reschedule + search (open an input prompt) KeyCode::Char('a') => app.begin_add(), KeyCode::Char('e') => app.begin_reschedule(), @@ -169,8 +171,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option 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), _ => {} } None diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index acf5f35..09dd1c8 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -18,7 +18,7 @@ use crate::backend::Backend; use crate::fmt::{fmt_date, project_color, today_local}; const HINTS: &str = - " j/k move a add x done S skip e date A attn b→blue m move D del s sort o edit / search q quit"; + " j/k move ⏎ edit a add x done S skip e date A attn b→blue m move D del s sort / search q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 5625c8a..89399e4 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -296,8 +296,9 @@ fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { app.push_to_blue_selected(); assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); - // It now appears under On Deck (sidebar row 2). - app.move_sidebar(1); - assert_eq!(app.task_pane_title(), "On Deck"); + // It now appears under On Deck (the last of the five views). + while app.task_pane_title() != "On Deck" { + app.move_sidebar(1); + } assert_eq!(app.selected_task().unwrap().title, "Cool it down"); } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index b056f90..2355a0c 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -168,8 +168,10 @@ fn starts_on_the_first_view_with_its_tasks() { #[test] fn moving_the_sidebar_switches_the_task_list() { let mut app = App::new(fixture()).unwrap(); - app.move_sidebar(1); // Top of Mind -> On Deck - assert_eq!(app.task_pane_title(), "On Deck"); + // Step to On Deck (the last of the five views) and confirm the list switched. + while app.task_pane_title() != "On Deck" { + app.move_sidebar(1); + } assert_eq!(app.tasks.len(), 1); assert_eq!(app.selected_task().unwrap().title, "blue one"); } diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 1e751cd..9debf4a 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -280,20 +280,36 @@ fn node_linkable_excludes_context_docs_logs_and_tags() { let mut c = client(&socket); // task.create → a task node + a same-titled canonical-context doc. - let task = c.call("task.create", json!({ "title": "Fix roof" })).unwrap(); + let task = c + .call("task.create", json!({ "title": "Fix roof" })) + .unwrap(); let task_id = task["node_id"].as_str().unwrap().to_string(); // a standalone doc, a tag node, and a log doc (first append). - c.call("node.create", json!({ "kind": "doc", "title": "Notes" })).unwrap(); - c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })).unwrap(); - c.call("log.append", json!({ "task_id": task_id, "text": "started" })).unwrap(); + c.call("node.create", json!({ "kind": "doc", "title": "Notes" })) + .unwrap(); + c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })) + .unwrap(); + c.call( + "log.append", + json!({ "task_id": task_id, "text": "started" }), + ) + .unwrap(); // Only first-class targets: the task and the standalone doc — not the // context doc, the log doc, or the tag (5 nodes total ⇒ 2 linkable). let nodes = c.call("node.linkable", json!({})).unwrap(); let arr = nodes.as_array().unwrap(); - assert_eq!(arr.len(), 2, "expected just the task + standalone doc:\n{arr:#?}"); - assert!(arr.iter().any(|n| n["title"] == "Fix roof" && n["kind"] == "task")); - assert!(arr.iter().any(|n| n["title"] == "Notes" && n["kind"] == "doc")); + assert_eq!( + arr.len(), + 2, + "expected just the task + standalone doc:\n{arr:#?}" + ); + assert!(arr + .iter() + .any(|n| n["title"] == "Fix roof" && n["kind"] == "task")); + assert!(arr + .iter() + .any(|n| n["title"] == "Notes" && n["kind"] == "doc")); } #[test] @@ -311,8 +327,11 @@ fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { let sid = src["id"].as_str().unwrap().to_string(); // Store a canonical bare link (as the `[[` picker inserts it). - c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}]]") })) - .unwrap(); + c.call( + "node.update", + json!({ "id": sid, "body": format!("see [[{tid}]]") }), + ) + .unwrap(); // On read, the bare id is expanded to a readable, current-name label. let got = c.call("node.get", json!({ "id": sid })).unwrap(); @@ -320,8 +339,11 @@ fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() { // Saving that expanded buffer back collapses it to the bare id again — a // no-op round-trip — and the wiki link is materialized by id. - c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}|Roof]]") })) - .unwrap(); + c.call( + "node.update", + json!({ "id": sid, "body": format!("see [[{tid}|Roof]]") }), + ) + .unwrap(); let again = c.call("node.get", json!({ "id": sid })).unwrap(); assert_eq!(again["body"], json!(format!("see [[{tid}|Roof]]"))); let links = c.call("links.outgoing", json!({ "id": sid })).unwrap(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 8e433a1..fb2854f 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -26,6 +26,7 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `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. 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. - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. +- `heph-tui`: **``** opens the selected task's context editor in nvim (from the sidebar it first drills into the task list); the old `o` binding is retired. The view sidebar / `heph view` order is now **Top of Mind, Tasks, Work Tasks, Chores, On Deck**. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `` still jumps to a task's context doc. - Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over your linkable nodes with a preview pane** (the node's body, or a task's context doc) — the list shows first-class targets only (a task appears once; its internal context/log docs and tag nodes are hidden, via the new `node.linkable` query) — type to narrow, Enter to insert, `` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 26e7576..4a2c8fa 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -254,7 +254,7 @@ 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** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **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) · `m` **move-to-project** (a list-pick overlay — "(Unfile)" then every project; backed by `task.set_project`) · `s` **sort toggle** (default ↔ project-grouped) · `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.)* +- **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`) · `s` **sort toggle** (default ↔ project-grouped) · `⏎` **edit context in nvim** (from a task; from the sidebar it drills into the list) · `/` **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`. @@ -274,10 +274,10 @@ Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-ba | View | Todoist query (origin) | heph realization | |---|---|---| | **Top of Mind** | `(p1\|p2) & (no date\|today\|overdue)` | `attention ∈ {red,orange}` ∧ actionable | -| **On Deck** | `p3 & (no date\|overdue\|today)` | `attention = blue` ∧ actionable | -| **Chores** | `(today\|overdue\|no date) & (#Chores\|#Camano Chores)` | scope ∈ {Chores, Camano Chores} ∧ actionable | -| **Work Tasks** | `#Work & !p3 & (…) & !subtask` | scope = Work subtree ∧ `attention ≠ blue` ∧ actionable | | **Tasks** | `!p3 & (…) & !(#Daily Routine\|#Work Routine\|#Chores\|#Camano Chores\|#Work\|##Culture\|#Camano Info) & !subtask` | `attention ≠ blue` ∧ actionable ∧ **not in** the routine/work/chore projects | +| **Work Tasks** | `#Work & !p3 & (…) & !subtask` | scope = Work subtree ∧ `attention ≠ blue` ∧ actionable | +| **Chores** | `(today\|overdue\|no date) & (#Chores\|#Camano Chores)` | scope ∈ {Chores, Camano Chores} ∧ actionable | +| **On Deck** | `p3 & (no date\|overdue\|today)` | `attention = blue` ∧ actionable | **Engine work — extend `list` (§6) so a view is a *predicate expressed as data*** (mirroring §7's "order as data"):