generated from eblume/project-template
feat(tui): <Enter> opens the context editor; reorder views (§8.1/§8.2)
All checks were successful
Build / validate (pull_request) Successful in 3m57s
All checks were successful
Build / validate (pull_request) Successful in 3m57s
- `<Enter>` now opens the selected task's context doc in nvim (App::enter: from the sidebar it drills into the task list first); the `o` binding is retired. Hint line updated. - BUILTIN_VIEWS reordered to the owner's preference — Top of Mind, Tasks, Work Tasks, Chores, On Deck — which drives the TUI sidebar and `heph view`. Tests that walked to On Deck by a fixed offset now seek it by title. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fc48a1aa9
commit
44d6847fae
10 changed files with 97 additions and 54 deletions
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -429,6 +429,17 @@ impl<B: Backend> App<B> {
|
|||
};
|
||||
}
|
||||
|
||||
/// The `<Enter>` 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<String> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -155,7 +155,9 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
|||
KeyCode::Char('j') | KeyCode::Down => 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<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
|||
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),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 <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.
|
||||
- `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`: **`<Enter>`** 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; `<CR>` 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, `<C-x>` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (`<CR>`) 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).
|
||||
|
|
|
|||
|
|
@ -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('<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`.
|
||||
|
|
@ -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"):
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue