Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Use `--no-input` and structured errors for automation; see
| Search | `search <query>` `search-title <q>` `backlinks <path>` |
| Folders | `folder list\|create\|rename\|delete` |
| Tags | `tag list\|find` |
| Tasks | `task list\|toggle` |
| Tasks | `task list\|toggle\|watch` |
| Comments | `comment list\|add\|reply\|resolve`, threads on a note shared with the app and its MCP tools |
| Databases | `base list\|create\|rows\|get\|add\|set\|convert`, for `.base` folders and loose `.csv` files |
| Vaults | `setup` `init` `connect` `disconnect` `use` `vault info\|list\|mode\|add\|remove` |
Expand Down Expand Up @@ -242,6 +242,39 @@ replies and resolve/reopen operations take a thread ID. `base add` and
`base add --body` or `--page` also creates a record page. `base convert`
turns a loose CSV into a `.base` folder.

### Watching task status

`zn task watch` prints a line each time a task's status changes: a task
note's `status:` in its frontmatter, a checkbox task's `@status:`, or its
checkbox. Moving a card on a Kanban board, editing the note in any editor and
`zn task toggle` all count. Editing a task's words, or saving it with the
same status, does not. With `--json` each change is one line of JSON, so a
script can act on each one as it arrives:

```bash
zn task watch --json | while IFS= read -r change; do
if [ "$(jq -r .status <<<"$change")" = ready ]; then
./start-agent.sh "$(jq -r .path <<<"$change")"
fi
done
```

```json
{"at":"2026-10-14T15:47:24Z","id":"inbox/Parser.md#task","path":"inbox/Parser.md","link":"zennotes://open?path=inbox/Parser.md","title":"Parser","kind":"file","content":"Parser","previousStatus":"backlog","status":"ready","lineNumber":0}
```

`status` and `previousStatus` follow the Kanban board. A task note's status
is its own `status:` (`open` when it has none). A checkbox task is `done`,
`cancelled` or `forwarded` once its box says so; until then it is its
`@status:` or the status its note's frontmatter gives it, and otherwise
`waiting`, `in-progress` or `open`. `kind` is `file` for a task note.

zn only reports: whatever runs on a change is your own script, so nothing
that executes lives in the vault. A local vault is watched through its
files. A server pushes its changes; one that cannot (an older server, or a
proxy without WebSockets) is asked every `--interval` seconds instead, 5 by
default. `zn task list --json` carries each task's `status` too.

The [online command reference](https://zennotes.org/tui/docs#commands) lists
the command syntax and flags by area.

Expand Down Expand Up @@ -847,6 +880,7 @@ internal/keymaps the bindable-action catalog and overrides
internal/periodic daily, weekly and monthly note patterns
internal/templates built-in and custom templates
internal/search note search scoring
internal/fswatch batched file events under a vault: live reload, task watch
```

`go test ./...` runs the suite. The command tables, task grammar, database
Expand Down
211 changes: 211 additions & 0 deletions internal/cli/commands_task_watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"

"github.com/ZenNotes/tui/internal/backend"
"github.com/ZenNotes/tui/internal/fswatch"
"github.com/ZenNotes/tui/internal/remote"
"github.com/ZenNotes/tui/internal/vault"
)

const (
// taskWatchSettle lets a burst of server events settle into one scan, as
// the local watcher batches its file events.
taskWatchSettle = 300 * time.Millisecond
// taskWatchRetry is how long a server without a live stream is polled
// before the stream is tried again.
taskWatchRetry = 30 * time.Second
)

// taskWatchEvent is one line of `zn task watch --json`.
type taskWatchEvent struct {
At string `json:"at"`
ID string `json:"id"`
Path string `json:"path"`
Link string `json:"link"`
Title string `json:"title"`
Kind string `json:"kind,omitempty"`
Content string `json:"content"`
PreviousStatus string `json:"previousStatus"`
Status string `json:"status"`
LineNumber int `json:"lineNumber"`
}

// cmdTaskWatch prints each task whose status changes, as it changes: a task
// note's `status:`, a checkbox line's `@status:`, or its checkbox. It only
// reports. What runs on a change is the caller's own script, never ZenNotes,
// so nothing that executes lives in a synced vault (ZenNotes/zennotes#941).
// The scans use the app's grammar, whose task text leaves the fields out, so
// a status change is never mistaken for an edited task.
func cmdTaskWatch(ctx context.Context, b backend.Backend, args Args) error {
opts := vault.ParseTasksOptions{IncludeExcluded: args.Bool("include-excluded"), Dialect: vault.DialectApp}
interval := 5
if raw, given := args.String("interval"); given {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return fmt.Errorf("--interval is a whole number of seconds, 1 or more, got %q", raw)
}
interval = n
}
// Watching starts before the first scan, so a change made meanwhile is
// either in that scan or wakes the next one, never reported late.
changed, stop, err := watchVaultChanges(ctx, b, time.Duration(interval)*time.Second)
if err != nil {
return err
}
defer stop()
first, err := b.ScanTasks(ctx, opts)
if err != nil {
return err
}
asJSON := args.Bool("json")
if !asJSON {
fmt.Fprintf(stderr, "Watching %s for task status changes. Ctrl+C stops.\n", b.Label())
}
return followTaskStatuses(ctx, b, opts, first, changed, asJSON)
}

// followTaskStatuses scans again on each signal and reports every task whose
// status moved since it was last seen, until ctx ends or changed closes. A
// failed scan (a server briefly away) is reported and the watch goes on, and
// a task a scan misses keeps its last status, so no change is lost.
func followTaskStatuses(ctx context.Context, b backend.Backend, opts vault.ParseTasksOptions, first []vault.Task, changed <-chan struct{}, asJSON bool) error {
tracker := vault.NewTaskStatusTracker(first)
for {
select {
case <-ctx.Done():
return nil
case _, ok := <-changed:
if !ok {
return nil
}
}
next, err := b.ScanTasks(ctx, opts)
if err != nil {
if ctx.Err() != nil {
return nil
}
emitWatchError(err, asJSON)
continue
}
for _, change := range tracker.Update(next) {
emitTaskChange(change, asJSON)
}
}
}

// watchVaultChanges signals each time the vault may have changed, holding at
// most one signal while a scan runs. A local vault reports through its file
// events, batched 300 ms after the last one. A server pushes its changes over
// /api/watch; while that stream is down (a server or proxy without it) the
// server is asked every interval instead.
func watchVaultChanges(ctx context.Context, b backend.Backend, interval time.Duration) (<-chan struct{}, func(), error) {
changed := make(chan struct{}, 1)
signal := func() {
select {
case changed <- struct{}{}:
default:
}
}
if root := b.Root(); root != "" {
watcher, err := fswatch.Start(root)
if err != nil {
return nil, nil, err
}
go func() {
defer close(changed)
for {
if _, ok := watcher.Next(); !ok {
return
}
signal()
}
}()
return changed, watcher.Close, nil
}
server, ok := b.(*backend.Remote)
if !ok {
return nil, nil, errors.New("this vault cannot be watched")
}
ctx, cancel := context.WithCancel(ctx)
go followServerChanges(ctx, server.Client(), interval, signal)
return changed, cancel, nil
}

// followServerChanges signals on each change the server pushes, once they
// settle, until ctx ends. Every (re)connection signals too, for whatever
// changed while the stream was down. Without a stream it signals every
// interval, and tries the stream again after taskWatchRetry.
func followServerChanges(ctx context.Context, client *remote.Client, interval time.Duration, signal func()) {
for ctx.Err() == nil {
if events, err := client.WatchChanges(ctx); err == nil {
signal()
var settle <-chan time.Time
live:
for {
select {
case <-ctx.Done():
return
case event, ok := <-events:
if !ok || event.Err != nil {
break live
}
settle = time.After(taskWatchSettle)
case <-settle:
settle = nil
signal()
}
}
}
poll := time.NewTicker(interval)
retry := time.NewTimer(taskWatchRetry)
for polling := true; polling; {
select {
case <-ctx.Done():
polling = false
case <-poll.C:
signal()
case <-retry.C:
polling = false
}
}
poll.Stop()
retry.Stop()
}
}

func emitTaskChange(change vault.TaskStatusChange, asJSON bool) {
t := change.Task
if asJSON {
emitJSONLine(taskWatchEvent{
At: time.Now().UTC().Format(time.RFC3339),
ID: t.ID,
Path: t.SourcePath,
Link: t.Link,
Title: t.NoteTitle,
Kind: t.Kind,
Content: t.Content,
PreviousStatus: change.PreviousStatus,
Status: change.Status,
LineNumber: t.LineNumber,
})
return
}
emitLine(fmt.Sprintf("%s %s → %s %s %s", time.Now().Format("15:04:05"), change.PreviousStatus, change.Status, truncate(t.Content, 80), t.SourcePath))
}

// emitWatchError reports a failed scan without ending the watch, as JSON on
// stderr when the output is JSON, the way a failed command reports.
func emitWatchError(err error, asJSON bool) {
if !asJSON {
emitError(err.Error())
return
}
_ = json.NewEncoder(stderr).Encode(map[string]any{"error": map[string]string{"code": "error", "message": err.Error()}})
}
13 changes: 13 additions & 0 deletions internal/cli/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ func emitJSON(value any) {
_, _ = stdout.Write(buf.Bytes())
}

// emitJSONLine writes value as one line of JSON: the shape a stream of events
// takes, so a reader can act on each line as it arrives.
func emitJSONLine(value any) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(value); err != nil {
fmt.Fprintln(stderr, "zn: "+err.Error())
return
}
_, _ = stdout.Write(buf.Bytes())
}

func emitLine(line string) {
fmt.Fprintln(stdout, line)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ var helpSections = []helpSection{
{"TASKS", []helpRow{
{"task list", "Open checkbox tasks across all notes", "--unchecked --all --tag <t> --include-excluded --json"},
{"task toggle <id>", "Flip a task checkbox by stable id", ""},
{"task watch", "Print task status changes as they happen. A server that cannot push them is asked every --interval seconds (default 5)", "--interval <s> --include-excluded --json"},
}},
{"COMMENTS", []helpRow{
{"comment list <path>", "Comment threads on a note, with anchors and replies", "--all --json"},
Expand Down Expand Up @@ -163,6 +164,7 @@ var examples = []string{
`zn list --server home # a self-hosted ZenNotes server`,
`zn capture "from CI" --server https://notes.example.com`,
`zn task list --unchecked --tag work`,
`zn task watch --json | jq -c 'select(.status == "ready")'`,
`zn tui # the whole app, in the terminal`,
`zn open ~/Downloads/notes.md`,
`zn open ~/code/project/docs # focus a folder as a session`,
Expand Down
1 change: 1 addition & 0 deletions internal/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func dispatchTable() map[string]Handler {
"tag find": cmdTagFind,
"task list": cmdTaskList,
"task toggle": cmdTaskToggle,
"task watch": cmdTaskWatch,
"comment list": cmdCommentList,
"comment add": cmdCommentAdd,
"comment reply": cmdCommentReply,
Expand Down
Loading
Loading