From fcabefbb3be350ebd06fc141fcea510a800ff9b8 Mon Sep 17 00:00:00 2001 From: Jonathan Warykowski Date: Fri, 24 Jul 2026 21:31:43 +1000 Subject: [PATCH 1/3] feat: live statusline via shepherd watch Opt-in config.watch streams `shepherd watch` (NDJSON, board-scoped) and refreshes counts on every change, so TUI/external edits update the statusline without waiting for FocusGained. Re-scopes on board switch; stops on VimLeavePre. --- README.md | 8 +++++++- lua/shepherd/init.lua | 44 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b1d7cc..1b3fe39 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ Run `:checkhealth shepherd` to verify the binary is found. `require("shepherd").status()` returns a short string — the open count, plus an overdue suffix — or `""` when there's nothing open or the count hasn't loaded yet. Counts refresh after any add/done/rm you make and on `FocusGained`; call -`require("shepherd").refresh()` to force it. +`require("shepherd").refresh()` to force it. Set `watch = true` (see +[configuration](#configuration)) to also refresh live on external board changes. lualine: @@ -140,6 +141,7 @@ require("shepherd").setup({ board = nil, -- string | fun():string | nil — passed as --board float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for status(); e.g. a nerd-font glyph + watch = false, -- stream `shepherd watch` to keep the statusline live }) ``` @@ -153,6 +155,10 @@ require("shepherd").setup({ - `float` — fractions of the editor size, and the window border. - `status.icon` — prefix for `status()`. With an icon it renders ` 3`; empty renders `3 todo`. +- `watch` — when `true`, streams `shepherd watch` in the background and refreshes + the statusline counts on every board change (from the TUI or another process), + not just on `FocusGained`. Re-scopes to the active board on switch, and stops + on exit. ## development diff --git a/lua/shepherd/init.lua b/lua/shepherd/init.lua index 9728bb9..2efae8e 100644 --- a/lua/shepherd/init.lua +++ b/lua/shepherd/init.lua @@ -6,6 +6,7 @@ local defaults = { board = nil, -- string | fun():string | nil float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for M.status(), e.g. a nerd-font glyph + watch = false, -- stream `shepherd watch` to keep the statusline live } local config = vim.tbl_deep_extend("force", {}, defaults) @@ -14,6 +15,10 @@ local config = vim.tbl_deep_extend("force", {}, defaults) -- it wins over config.board; "default" means the unscoped default board. local active_board = nil +-- watch state, forward-declared so set_active_board (below) can restart the +-- stream; defined near M.status. +local watch_job, watch_start, watch_stop + -- binary returns the configured shepherd command (used by the health check). function M.binary() return config.cmd @@ -23,6 +28,9 @@ end -- config.board until changed again. function M.set_active_board(name) active_board = name + if watch_job then + watch_start() -- re-scope the stream to the new board + end end -- with_board appends "--board " to cmd when one is in effect: the @@ -300,6 +308,34 @@ function M.refresh() end) end +-- watch streams `shepherd watch` (NDJSON, board-scoped) and refreshes counts on +-- every change, so edits from the board TUI or another process update the +-- statusline without waiting for FocusGained. Opt-in via config.watch. +-- watch_job/watch_start/watch_stop are forward-declared near the top. +function watch_stop() + if watch_job then + pcall(function() + watch_job:kill(15) + end) + watch_job = nil + end +end + +function watch_start() + watch_stop() + local cmd = with_board({ config.cmd, "watch" }) + watch_job = vim.system(cmd, { + text = true, + stdout = function(_, data) + if data and data ~= "" then + vim.schedule(M.refresh) + end + end, + }, function() + watch_job = nil + end) +end + -- status returns a statusline string: "" when empty/loading, else the open -- count with the configured icon and an overdue suffix. function M.status() @@ -440,12 +476,18 @@ function M.setup(opts) end, { desc = "unarchive an archived board" }) -- keep statusline counts fresh across external edits / other tabs + local group = vim.api.nvim_create_augroup("shepherd", { clear = true }) vim.api.nvim_create_autocmd("FocusGained", { - group = vim.api.nvim_create_augroup("shepherd", { clear = true }), + group = group, callback = function() M.refresh() end, }) + + if config.watch then + watch_start() + vim.api.nvim_create_autocmd("VimLeavePre", { group = group, callback = watch_stop }) + end end -- _run exposes the CLI runner to the board-management submodule From 1c17d3f97d9ffc1a39377f9f18072c7447da6eb9 Mon Sep 17 00:00:00 2001 From: Jonathan Warykowski Date: Thu, 30 Jul 2026 10:18:18 +1000 Subject: [PATCH 2/3] fix: keep the watch stream tied to the active board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on_exit closure cleared the shared watch_job unconditionally. A restart kills the old job, starts the new one, and the old job's exit callback then lands and nils out the *new* handle — so a second board switch never restarted the stream (the statusline kept watching the previous board and served a stale count), and VimLeavePre saw no handle to kill, orphaning `shepherd watch` on every exit. Compare identities before clearing, and report a nonzero exit of the current job — an older shepherd without the `watch` verb used to fail silently. --- lua/shepherd/init.lua | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lua/shepherd/init.lua b/lua/shepherd/init.lua index 2efae8e..099e813 100644 --- a/lua/shepherd/init.lua +++ b/lua/shepherd/init.lua @@ -324,16 +324,34 @@ end function watch_start() watch_stop() local cmd = with_board({ config.cmd, "watch" }) - watch_job = vim.system(cmd, { + local job + job = vim.system(cmd, { text = true, stdout = function(_, data) if data and data ~= "" then vim.schedule(M.refresh) end end, - }, function() + }, function(r) + -- a restart's kill lands after the replacement is live, so only the + -- current job may clear the handle — and only its death is worth + -- reporting: a killed predecessor is expected, a live stream dying + -- (no `watch` verb on an older binary) would otherwise fail silently + if watch_job ~= job then + return + end watch_job = nil + if r.code ~= 0 then + local why = (r.stderr or ""):gsub("%s+$", "") + vim.schedule(function() + vim.notify( + "shepherd: watch stopped — " .. (why ~= "" and why or "exit " .. r.code), + vim.log.levels.ERROR + ) + end) + end end) + watch_job = job end -- status returns a statusline string: "" when empty/loading, else the open From 8a7c10697b2a327b824fb12ec8b2644fde88ff06 Mon Sep 17 00:00:00 2001 From: Jonathan Warykowski Date: Thu, 30 Jul 2026 10:22:11 +1000 Subject: [PATCH 3/3] feat: watch the board by default Live counts are what a statusline is for; opt-in meant most installs sat on FocusGained-only refreshes without knowing there was anything better. Gated on `vim.fn.executable`, because on by default a missing binary would otherwise throw ENOENT out of setup() on every startup instead of staying a `:checkhealth` problem. --- README.md | 19 +++++++++++-------- lua/shepherd/init.lua | 7 ++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1b3fe39..357a19a 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,10 @@ Run `:checkhealth shepherd` to verify the binary is found. `require("shepherd").status()` returns a short string — the open count, plus an overdue suffix — or `""` when there's nothing open or the count hasn't loaded -yet. Counts refresh after any add/done/rm you make and on `FocusGained`; call -`require("shepherd").refresh()` to force it. Set `watch = true` (see -[configuration](#configuration)) to also refresh live on external board changes. +yet. Counts refresh live on every board change — yours, the board TUI's, or +another process's — plus on `FocusGained`; call +`require("shepherd").refresh()` to force it. Set `watch = false` (see +[configuration](#configuration)) to drop back to `FocusGained` only. lualine: @@ -141,7 +142,7 @@ require("shepherd").setup({ board = nil, -- string | fun():string | nil — passed as --board float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for status(); e.g. a nerd-font glyph - watch = false, -- stream `shepherd watch` to keep the statusline live + watch = true, -- stream `shepherd watch` to keep the statusline live }) ``` @@ -155,10 +156,12 @@ require("shepherd").setup({ - `float` — fractions of the editor size, and the window border. - `status.icon` — prefix for `status()`. With an icon it renders ` 3`; empty renders `3 todo`. -- `watch` — when `true`, streams `shepherd watch` in the background and refreshes - the statusline counts on every board change (from the TUI or another process), - not just on `FocusGained`. Re-scopes to the active board on switch, and stops - on exit. +- `watch` — streams `shepherd watch` in the background and refreshes the + statusline counts on every board change (from the TUI or another process), not + just on `FocusGained`. Re-scopes to the active board on switch, and stops on + exit. One extra process per Neovim, polling the board file once a second; set + it to `false` to skip that. Needs shepherd ≥ 0.16 (the `watch` verb); with no + `shepherd` on `$PATH` it simply doesn't start. ## development diff --git a/lua/shepherd/init.lua b/lua/shepherd/init.lua index 099e813..8ae80fe 100644 --- a/lua/shepherd/init.lua +++ b/lua/shepherd/init.lua @@ -6,7 +6,7 @@ local defaults = { board = nil, -- string | fun():string | nil float = { width = 0.8, height = 0.8, border = "rounded" }, status = { icon = "" }, -- prefix for M.status(), e.g. a nerd-font glyph - watch = false, -- stream `shepherd watch` to keep the statusline live + watch = true, -- stream `shepherd watch` to keep the statusline live } local config = vim.tbl_deep_extend("force", {}, defaults) @@ -323,6 +323,11 @@ end function watch_start() watch_stop() + -- on by default, so a missing binary must stay a checkhealth problem rather + -- than an ENOENT thrown out of setup() on every startup + if vim.fn.executable(config.cmd) ~= 1 then + return + end local cmd = with_board({ config.cmd, "watch" }) local job job = vim.system(cmd, {