From 0462a6e43b09512845824e379a99cc443d18d4e2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 2 Jun 2026 11:48:09 -0700 Subject: [PATCH] heph.nvim: interactive next/list views (add, done, refresh) + key hint The Tactical next and Organizational list buffers are now actionable: - a add a task from the list (prompt title + attention) - d mark the task under the cursor done - r refresh - open the task's context (as before) A dimmed key hint renders above the rows as a virtual line (extmark), so it's discoverable without taking a task row. e2e covers add-from-list and done-from-list via stubbed vim.ui.input/select. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/changelog.d/v1-prototype.feature.md | 2 +- docs/reference/heph-nvim.md | 4 +- heph.nvim/lua/heph/view.lua | 103 ++++++++++++++++++---- heph.nvim/tests/e2e/view_actions_spec.lua | 60 +++++++++++++ 4 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 heph.nvim/tests/e2e/view_actions_spec.lua diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 33977e2..7b5fce7 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -18,5 +18,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph.nvim` slice 11b (§8) — task views: `list` is enriched to return titled rows (the same shape as `next`, with the canonical-context id) so the Organizational survey needs no per-row `node.get`. The plugin gains the Tactical **`:Heph next`** and Organizational **`:Heph list`** views (`` opens a task's canonical-context doc), task **capture**, **set-attention**, **done/drop**, **skip**, and per-task **`log`** append — each resolving "the current task" from the buffer (a task node, or a context doc via its `canonical-context` backlink). A `vim.ui.select` picker (Telescope auto-upgrade when installed) backs `:Heph search`/`capture`/`attention`. Headless e2e adds the capture→next→context→checklist→done workflow and the recurring fresh-checklist workflow (completing a recurring task rolls it forward and the next occurrence presents an all-unchecked checklist). - `heph.nvim` slice 11c (§8) — promotion + CI: `task.promote` mints a committed task from a `- [ ]` context-item line (addressed by its 1-based index) and rewrites that line into a `[[link]]` to the new task; `:Heph promote` does this for the line under the cursor. Wiki-link resolution now excludes a task's canonical-context doc, so `[[Task Title]]` resolves to the task itself (not its identically-titled context doc). The headless e2e suite runs in CI via a Dagger function that bakes a pinned, arch-detected Neovim onto a Rust image and runs the same self-contained suite developers run natively with `mise run test-nvim`; the runner fails on a zero-spec discovery so a misconfigured path can't pass silently. - `heph.nvim` managed daemon — plug-and-play by default: `require("heph").setup({})` spawns and supervises a local `hephd` against the default paths when none is running, kills only the daemon it spawned on exit, and self-heals (respawns + reconnects if the daemon dies mid-session). A daemon you started yourself (a `server`/`client` architecture, or a service) is always respected — the plugin only spawns when nothing is serving the socket; with `autostart = false` it connects only and warns if unreachable. `$HEPH_SOCKET` / `$HEPH_DB` isolate a development Neovim onto a separate daemon + DB. -- `heph.nvim` follow-or-create: pressing `` on a `[[wiki-link]]` whose target doesn't exist yet now **creates** a doc with that title and opens it (the zettelkasten gesture), materializing the source's backlink — so you can link a journal entry to a brand-new note in one keystroke. Plus `:Heph doc ` to create a standalone wiki entry, and `:Heph home` — a single designated landing/index page (open-or-create by title, configurable via `opts.home`) to grow a map of content around. `:Heph journals` opens a recent-days picker (preview existing days, `@create` for new ones; count via `opts.journal_days`, default 7) — the dailies workflow. Pickers (Telescope) now support a preview pane. +- `heph.nvim` follow-or-create: pressing `<CR>` on a `[[wiki-link]]` whose target doesn't exist yet now **creates** a doc with that title and opens it (the zettelkasten gesture), materializing the source's backlink — so you can link a journal entry to a brand-new note in one keystroke. Plus `:Heph doc <title>` to create a standalone wiki entry, and `:Heph home` — a single designated landing/index page (open-or-create by title, configurable via `opts.home`) to grow a map of content around. `:Heph journals` opens a recent-days picker (preview existing days, `@create` for new ones; count via `opts.journal_days`, default 7) — the dailies workflow. Pickers (Telescope) now support a preview pane. The `:Heph next`/`list` views are interactive: `<CR>` opens a task's context, `a` adds a task (prompt title + attention), `d` marks the task under the cursor done, `r` refreshes — with a dimmed key hint shown above the list. - Dev/installed isolation tooling: a `mise run dev` task runs the working-tree `hephd` on isolated `.dev/` paths, and a how-to ([[install-heph]]) covers installing `heph`/`hephd` from the forge (build-from-source), the lazy.nvim plugin setup, and pointing a dev Neovim at the dev daemon via `$HEPH_SOCKET`/`$HEPH_DB` so it never touches the installed store. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 59db74e..7987101 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -77,7 +77,9 @@ edits, else adding the `wiki` link directly). "Current task" is resolved from the buffer: a `task` node, or a canonical-context doc whose owning task is followed via its `canonical-context` backlink. The `next`/`list` views render the titled rows the daemon returns (`list` enriched to -carry titles + the context id, so no N+1 `node.get`). Pickers use built-in +carry titles + the context id, so no N+1 `node.get`) and are **interactive**: +`<CR>` opens a task's context, `a` adds a task (prompt title + attention), `d` +marks the task under the cursor done, `r` refreshes. Pickers use built-in `vim.ui.select`, auto-upgrading to Telescope when installed. **Promotion** (`:Heph promote`) mints a committed task from the `- [ ]` line diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua index 82e6792..c8915fb 100644 --- a/heph.nvim/lua/heph/view.lua +++ b/heph.nvim/lua/heph/view.lua @@ -1,22 +1,38 @@ --- Task list views (tech-spec §8): Tactical `next` (the "what is next?" ranking) --- and Organizational `list` (the whole outstanding set). Both render the same ---- titled rows the daemon returns into a scratch buffer; `<CR>` opens the task ---- under the cursor's canonical-context doc (the one-keystroke jump). - +--- titled rows the daemon returns into a scratch buffer, and are interactive: +--- <CR> open the task's canonical-context doc +--- a add a new task (prompt title + attention) from the list +--- d mark the task under the cursor done +--- r refresh local rpc = require("heph.rpc") local M = {} --- buf -> { tasks = <RankedTask[]> }; line N maps to tasks[N]. +-- buf -> { tasks = <RankedTask[]>, refresh = fn }; line N maps to tasks[N]. M._views = {} +local hint_ns = vim.api.nvim_create_namespace("heph_view_hint") +local HINT = " <CR> open a add d done r refresh" + +local ATTENTIONS = { "white", "orange", "red", "blue" } + local function row(t) local tag = t.attention and ("[" .. t.attention .. "]") or "[ ]" return string.format("%s %s", tag, t.title) end --- Find or create the named scratch buffer and fill it with task rows. -local function render(name, tasks) +local function task_on_line(buf) + local view = M._views[buf] + if not view then + return nil + end + return view.tasks[vim.api.nvim_win_get_cursor(0)[1]] +end + +-- Find or create the named scratch buffer, fill it, and (re)bind its keymaps. +-- `refresh` re-runs the query+render so actions can reflect their changes. +local function render(name, tasks, refresh) local buf for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b) == name then @@ -37,16 +53,36 @@ local function render(name, tasks) lines[#lines + 1] = row(t) end if #lines == 0 then - lines = { "(nothing here)" } + lines = { "(nothing here — press 'a' to add a task)" } end vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modifiable = false - M._views[buf] = { tasks = tasks } - vim.keymap.set("n", "<CR>", function() + -- A dimmed key hint above the first row — a virtual line, so it isn't a task + -- row (cursor lines still map 1:1 to tasks). + vim.api.nvim_buf_clear_namespace(buf, hint_ns, 0, -1) + vim.api.nvim_buf_set_extmark(buf, hint_ns, 0, 0, { + virt_lines_above = true, + virt_lines = { { { HINT, "Comment" } } }, + }) + + M._views[buf] = { tasks = tasks, refresh = refresh } + local function map(lhs, fn, desc) + vim.keymap.set("n", lhs, fn, { buffer = buf, desc = desc }) + end + map("<CR>", function() M.open_under_cursor(buf) - end, { buffer = buf, desc = "heph: open task context" }) + end, "heph: open task context") + map("a", function() + M.add_from(buf) + end, "heph: add a task") + map("d", function() + M.done_under_cursor(buf) + end, "heph: mark task done") + map("r", function() + M.refresh(buf) + end, "heph: refresh") vim.api.nvim_set_current_buf(buf) return buf end @@ -54,23 +90,52 @@ end --- Open the canonical-context doc of the task on the cursor line. function M.open_under_cursor(buf) buf = buf or vim.api.nvim_get_current_buf() - local view = M._views[buf] - if not view then - return + local t = task_on_line(buf) + if t then + require("heph.node").open(t.canonical_context_id or t.node_id) end - local lnum = vim.api.nvim_win_get_cursor(0)[1] - local t = view.tasks[lnum] +end + +--- Re-run the view's query and re-render in place. +function M.refresh(buf) + local view = M._views[buf or vim.api.nvim_get_current_buf()] + if view and view.refresh then + view.refresh() + end +end + +--- Add a task from the list: prompt a title, pick an attention, capture, refresh. +function M.add_from(buf) + vim.ui.input({ prompt = "New task: " }, function(title) + if not title or #title == 0 then + return + end + require("heph.picker").select(ATTENTIONS, { prompt = "attention for: " .. title }, function(attention) + require("heph.task").capture(title, { attention = attention }) + require("heph.util").notify("captured: " .. title) + M.refresh(buf) + end) + end) +end + +--- Mark the task on the cursor line done, then refresh. +function M.done_under_cursor(buf) + local t = task_on_line(buf) if not t then return end - require("heph.node").open(t.canonical_context_id or t.node_id) + rpc.call("task.set_state", { id = t.node_id, state = "done" }) + require("heph.util").notify("done: " .. t.title) + M.refresh(buf) end --- Tactical "what is next?" — render the ranking, return the rows. function M.next(opts) opts = opts or {} local tasks = rpc.call("next", { scope = opts.scope, limit = opts.limit or 5 }) - render("heph://next", tasks) + render("heph://next", tasks, function() + M.next(opts) + end) return tasks end @@ -82,7 +147,9 @@ function M.list(opts) attention = opts.attention, include_blue = opts.include_blue ~= false, }) - render("heph://list", tasks) + render("heph://list", tasks, function() + M.list(opts) + end) return tasks end diff --git a/heph.nvim/tests/e2e/view_actions_spec.lua b/heph.nvim/tests/e2e/view_actions_spec.lua new file mode 100644 index 0000000..1f1d998 --- /dev/null +++ b/heph.nvim/tests/e2e/view_actions_spec.lua @@ -0,0 +1,60 @@ +-- Interactive task-list actions: add a task from the list, mark done from it. + +local h = require("e2e.helpers") + +describe("task list actions", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("adds a task from the list buffer", function() + require("heph.view").list() + local buf = vim.api.nvim_get_current_buf() + + vim.g.heph_force_ui_select = true + local orig_input, orig_select = vim.ui.input, vim.ui.select + vim.ui.input = function(_o, cb) + cb("Buy milk") + end + vim.ui.select = function(_items, _o, cb) + cb("orange") + end + require("heph.view").add_from(buf) + vim.ui.input, vim.ui.select, vim.g.heph_force_ui_select = orig_input, orig_select, nil + + -- The task was created with the chosen attention... + local found + for _, t in ipairs(ctx.q:call("list", {})) do + if t.title == "Buy milk" then + found = t + end + end + assert.is_truthy(found, "task not created") + assert.are.equal("orange", found.attention) + + -- ...and the list refreshed to show it. + local present = false + for _, l in ipairs(vim.api.nvim_buf_get_lines(vim.api.nvim_get_current_buf(), 0, -1, false)) do + if l:find("Buy milk", 1, true) then + present = true + end + end + assert.is_true(present, "added task missing from the refreshed list") + end) + + it("marks the task under the cursor done from the list buffer", function() + local t = ctx.q:call("task.create", { title = "Ship it", attention = "red" }) + require("heph.view").list() + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + require("heph.view").done_under_cursor(buf) + + assert.are.equal("done", ctx.q:call("task.get", { id = t.node_id }).state) + assert.are.equal(0, #ctx.q:call("list", {})) -- gone from the refreshed list + end) +end)