diff --git a/AGENTS.md b/AGENTS.md index ea03496..1f8e52a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Agent guide +# agent guide This machine has `shepherd`, a CLI todo board backed by a markdown file. Manage the user's todos through it — never hand-edit the todo file; the binary owns the @@ -12,14 +12,14 @@ format. - `shepherd stats [--json] [--all] [--legend]` — board metrics (charts, or `--json` numbers; `--legend` explains each chart) - `shepherd add "buy milk @home !h due:tomorrow" [--json]` — add an item - `shepherd sub "" [--json]` — add a subtask to an item (same quick-add tokens) -- `shepherd edit "" [--json]` — merge tokens onto an item (or subtask); only the given fields change. Tokens: `@category`, `!prio`, `due:`, `defer:`, `link:`, `status:`, `note:`, and text. A bare key clears its field; `note:` takes the rest of the line -- `shepherd list --filter ` — list only matching items (text/note/category/due/defer/link), keeping their real indexes for done/rm +- `shepherd edit "" [--json]` — merge tokens onto an item (or subtask); only the given fields change. Tokens: `@category`, `#tag`/`tags:a,b`, `!prio`, `due:`, `defer:`, `link:`, `status:`, `note:`, and text. A bare key clears its field; `note:` takes the rest of the line +- `shepherd list --filter ` — list only matching items (text/note/category/tags/due/defer/link), keeping their real indexes for done/rm - `shepherd done ... [--json]` / `shepherd undone ...` — (un)complete one or more items/subtasks - `shepherd archive ... [--json]` — move whole items off the live board into the sibling `archive.md` (per-item counterpart to whole-board `board archive`); subtasks can't be archived alone - `shepherd rm ... [--dry-run] [--json]` — remove one or more items/subtasks (`--dry-run`/`-n` previews without writing) -`edit` is the single setter for every field — status, note, category, priority, -due, defer, link, and text all change through its tokens (`edit 2 "status:in-progress"`, +`edit` is the single setter for every field — status, note, category, tags, +priority, due, defer, link, and text all change through its tokens (`edit 2 "status:in-progress"`, `edit 2 "note:call the vendor"`); a bare `key:` clears. `done`/`undone` are the only shorthands, for the terminal state. @@ -34,12 +34,17 @@ never lose one another's writes. `--json` on any mutating verb echoes the resulting item(s) like `list --json` and reports failures as `{"error":…}` on stdout. -Quick-add tokens (shared by `add`, `sub`, `edit`): `@category`, `!h`/`!m`/`!l` -priority, `due:`, `defer:` -(start/defer date), `link:`, `status:`, +Quick-add tokens (shared by `add`, `sub`, `edit`): `@category`, `#tag`, +`tags:`, `!h`/`!m`/`!l` priority, `due:`, +`defer:` (start/defer date), `link:`, `status:`, and `note:` (holds spaces, takes the rest of the line — put it last). `list --json` reports `id` (the stable handle), `completed` (done timestamp), -`defer`, `link`, and `status` per item. +`defer`, `link`, `tags`, and `status` per item. + +Category and tags are different axes: `category` is one lowercase label per item +and drives board grouping/order; `tags` is a free-form lowercase set with no +ordering effect. `#tag` only adds, so replace the whole set with +`tags:` (or clear it with a bare `tags:`) to drop one. Subtasks nest one level under an item. `list --json` puts them in each item's `subtasks` array (1-based within the parent); address them by id or as `n.m`. Completion diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee7011a..f2cbd7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing +# contributing Go plugin, split into `internal/` packages (`cli`, `store`, `todo`, `tui`). No frameworks. @@ -15,7 +15,7 @@ meta-linter; install it from https://golangci-lint.run. CI (`.github/workflows/pull-request.yml`) runs all of the above on every pull request; keep them green. -## Git hooks +## git hooks Enable the pre-commit hook (gofmt's staged Go files, once per clone): diff --git a/README.md b/README.md index 8891f71..6e14a3e 100644 --- a/README.md +++ b/README.md @@ -73,20 +73,21 @@ herdr plugin install jwarykowski/shepherd | `tab` | cycle status (open → in-progress → done → open); see `statuses` config | | `h` / `m` / `l` | set priority high / medium / low | | `g` | set category | +| `T` | set tags (space- or comma-separated; empty clears) | | `t` | set due date — `today`, `tomorrow`, `3d`/`2w`/`5m`/`1y`, or `DD-MM-YYYY` (empty clears) | | `s` | set defer/start date (same formats as due; item shows dimmed with `starts Nd` until then) | | `L` | set a reference link (url) | | `o` | open the selected item's link in the browser | -| `a` | add item (inline syntax below) | +| `a` | add item (inline syntax below); inherits the selected item's category unless the text names one | | `S` | add a subtask to the selected item | | `u` | edit item (or subtask) text | | `d` | open detail view (shows every field) | -| `v` | cycle view: category / priority / table | +| `v` | cycle view: category / priority / tag / table | | `F` | hide / show the footer help grid (the `jwarykowski/shepherd` · version line stays); `hidefooter` config sets the default | | `A` | toggle the [global view](#global-view) across all boards | | `b` | open the board picker — every board with done/total counts; `enter` jumps, `a` creates a board, `r` renames, `A` archives, `x` deletes (confirmed), `d` shows detail (name, dir, paths, counts) for the selected board (rename/archive/delete don't apply to the default board); `e` toggles the archived-boards view where `u` unarchives the selected board | | `e` | browse the archive (read-only; all boards in the global view; `esc` to leave) | -| `/` | filter (text/note/category/due/defer/link — also greps `archive.md`) | +| `/` | filter (text/note/category/tags/due/defer/link — also greps `archive.md`) | | `U` / `ctrl+r` | undo / redo (multi-level) | | `w` | save now (the header shows `● unsaved` / `● saved`) | | `ctrl+e` | open the markdown file in `$EDITOR` | @@ -97,17 +98,30 @@ herdr plugin install jwarykowski/shepherd | `?` | full help page | | `q` | save + quit | -In the detail view: `e` edit note · `space` toggle · `o` open link · `d`/`esc`/`q` back. +The detail view is also an editor: the same field keys work there — `u` text, +`h`/`m`/`l` priority, `g` category, `T` tags, `t` due, `s` defer, `L` link, `tab` +status — and saving returns you to the detail view rather than the list. Plus +`n` edit note · `space` toggle · `o` open link · `esc`/`q` back. Its footer is +the same labelled key grid as the board's, narrowed to the keys that act on the +one item (`fields` · `dates` · `item` · `go`). + +**Rows carry two flush-right values**: the subtask progress (`1/2`) when the item +has subtasks, else the due/defer label — then, pinned far right, whichever +grouping axis the headers *don't* already name. The category view shows the +priority there, the priority view shows the category, and the tag view shows the +priority. Status needs no width at all: the box shape carries it (`○` open, +`◐` a named status, `✓` done). **Inline quick-add** — `a`, then one line: -`deploy api @work !h due:tomorrow defer:1w link:https://…`. `@word` sets -category, `!h`/`!m`/`!l` priority, `due:` the due date, `defer:` -a start/defer date, `link:` a reference, `status:` a status, and -`note:` a note (holds spaces, takes the rest of the line — put it last); -everything else is the task text. +`deploy api @work #api !h due:tomorrow defer:1w link:https://…`. `@word` sets +category, `#word` adds a tag (`tags:a,b` sets the whole set), `!h`/`!m`/`!l` +priority, `due:` the due date, `defer:` a start/defer date, +`link:` a reference, `status:` a status, and `note:` a note +(holds spaces, takes the rest of the line — put it last); everything else is the +task text. Items are ordered by **category, then priority, then soonest due**, grouped -under headers, with a coloured priority label flush right. **Overdue** open +under headers. **Overdue** open items are pinned to a `⚠ overdue` group at the top. New items get a `created` timestamp; due items show a relative label (`due 3d`, `overdue 2d` in red). Edits save on quit, autosave after a short @@ -137,7 +151,7 @@ shows a `done/total` badge: toggle, `tab` status, `u` text, `h`/`m`/`l` priority, `t` due, `s` defer, `L` link, `o` open link, `x` delete. Overdue/defer labels show on the row. - `d` opens the subtask's detail view — its own fields plus a `parent` line - naming the task it belongs to; edit its note there with `e`, same as a parent. + naming the task it belongs to; edit its note there with `n`, same as a parent. - `g` (category) is the one exception — it's parent-only, since a subtask shares its parent's board; it's dimmed in the footer on a subtask row. Set a subtask's fields at creation from the CLI too: @@ -192,7 +206,7 @@ view. Launch with `--all`, or press `A` from any board to toggle in and out shepherd --all # aggregate of every board ``` -`v` cycles four groupings: **board → category → priority → table**. In the +`v` cycles five groupings: **board → category → priority → tag → table**. In the board grouping each board is a header; in the others every row carries a `[board]` tag (the table gets a `board` column). It's read-only by design — editing stays on the focused board, so the aggregate is never written back. @@ -203,7 +217,7 @@ The command API mirrors it: `shepherd list --all` (see [command api](#command-ap ## launch filter `--board` gives a board its own file; `--filter` is a saved *view* over one -board — start it pre-filtered by text/note/category/due/defer/link: +board — start it pre-filtered by text/note/category/tags/due/defer/link: ```sh ./bin/shepherd --filter work # or: SHEPHERD_FILTER=work ./bin/shepherd @@ -211,8 +225,9 @@ board — start it pre-filtered by text/note/category/due/defer/link: When the filter names a category (one you've configured or already use), items you add while it's active inherit that category — so a task added on a -`--filter work` board lands in `work` and stays in view. An inline `@category` -still overrides; a filter that isn't a category leaves new items uncategorized. +`--filter work` board lands in `work` and stays in view. This is the fallback: +the selected item's own category is inherited first, and an inline `@category` +overrides both. A filter that isn't a category leaves new items uncategorized. The two combine: `shepherd --board web --filter '!h'`. `shepherd --version` prints the version and exits. @@ -254,6 +269,7 @@ shepherd done 2f3a…c1 --json # mark by id; echo the result as JSON shepherd undone 2.1 # reopen subtask 1 (also reopens the parent) shepherd edit 2 "status:in-progress" # set item 2's status (status:done|open recognised) shepherd edit 2 "note:waiting on infra" # set item 2's note (edit 2 "note:" clears it) +shepherd edit 2 "#api #docs" # add tags (tags:a,b replaces the set, tags: clears) shepherd rm 2 # remove item 2 (rm 2.1 removes just the subtask) shepherd rm 2 5 --dry-run # preview removing several without writing shepherd archive 2 # move item 2 off the board into archive.md (whole items only) @@ -278,12 +294,12 @@ cascade rules. `--json` on any mutating verb echoes the resulting item(s) in the `list --json` shape and reports failures as `{"error":…}` on stdout. `edit ""` sets only the fields its tokens carry — `@category`, -`!h`/`!m`/`!l`, `due:`, `defer:`, `link:`, `status:`, `note:` — and replaces the +`#tag`/`tags:`, `!h`/`!m`/`!l`, `due:`, `defer:`, `link:`, `status:`, `note:` — and replaces the text only when plain words are present. A bare key clears its field (`edit 2 "@ due:"`); `note:` holds spaces and takes the rest of the line, so put it last (`edit 2 "!h note:call the bank"`). -`list --filter ` matches text/note/category/due/defer/link and keeps each +`list --filter ` matches text/note/category/tags/due/defer/link and keeps each item's real board index, so `done`/`rm` on a filtered listing still hit the right item. @@ -302,9 +318,10 @@ shepherd add "ship v2 @work !h" --board web shepherd list --board web ``` -`add` accepts the same quick-add tokens as the board: `@category`, `!h`/`!m`/`!l` -priority, `due:`, `defer:`, `link:`, `status:`, -and `note:` (takes the rest of the line). Agents should read with +`add` accepts the same quick-add tokens as the board: `@category`, `#tag` +(`tags:a,b` replaces the set), `!h`/`!m`/`!l` priority, +`due:`, `defer:`, `link:`, `status:`, and `note:` +(takes the rest of the line). Agents should read with `list --json` (stable machine shape) and mutate with `add`/`edit`/`done`/`rm`; an open board picks up the change within ~2s. `edit` is the single setter for every field, including `status:` (any name, like a free-form `@category`; @@ -322,7 +339,8 @@ the archive. `--all` aggregates every board and adds a by-board breakdown; ```json [ { "id": "019f7390…d901", "index": 1, "done": false, "priority": "H", - "text": "buy milk", "category": "home", "created": "10-07-2026 13:40", + "text": "buy milk", "category": "home", "tags": ["errand"], + "created": "10-07-2026 13:40", "defer": "2026-07-11", "due": "2026-07-15", "link": "https://…", "note": "", "completed": "" } ] @@ -353,7 +371,7 @@ Optional `config.toml` at `$XDG_CONFIG_HOME/shepherd/config.toml` (defaults to `SHEPHERD_CONFIG`): ```toml -view = "category" # category (default) | priority | table +view = "category" # category (default) | priority | tag | table density = "compact" # compact (default) | comfort autosave = 60 # seconds idle before writing; 0 disables categories = ["work", "home", "personal"] # tab-cycles in the category prompt @@ -391,6 +409,7 @@ sub-lines: defer: 2026-07-11 due: 2026-07-15 category: work + tags: api, docs status: in-progress link: https://github.com/org/repo/pull/1 note: block on the migration first diff --git a/herdr-plugin.toml b/herdr-plugin.toml index d5dee5c..fe5a551 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "jwarykowski.herdr-shepherd" name = "Shepherd" -version = "0.18.0" +version = "0.19.0" min_herdr_version = "0.7.0" description = "Shepherd — your todos herded. Interactive todo board in a split, tab, overlay, or zoomed pane. Category/priority/due ordering, overdue pinning, notes, due dates, undo/redo, filter, archive. Backed by a markdown file." platforms = ["linux", "macos"] diff --git a/internal/cli/charts.go b/internal/cli/charts.go index 5097c13..e7bb3c5 100644 --- a/internal/cli/charts.go +++ b/internal/cli/charts.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "sort" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart" "github.com/NimbleMarkets/ntcharts/sparkline" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) // Priority colors mirror tui/view.go: H red, M yellow, L faint. Kept here (not @@ -189,7 +191,7 @@ func hbar(rows []barRow, width int) string { labelW := 0 labels := make([]string, len(rows)) for i, r := range rows { - labels[i] = clip(r.label, maxLabel) + labels[i] = ansi.Truncate(r.label, maxLabel, "…") if w := lipgloss.Width(labels[i]); w > labelW { labelW = w } @@ -210,31 +212,11 @@ func hbar(rows []barRow, width int) string { } bar := lipgloss.NewStyle().Foreground(lipgloss.Color(r.color)).Render(strings.Repeat("━", fill)) + faintStyle.Render(strings.Repeat("┄", barW-fill)) - lines[i] = fmt.Sprintf("%s %s %*d", padR(labels[i], labelW), bar, valW, r.value) + lines[i] = fmt.Sprintf("%s %s %*d", lipgloss.NewStyle().Width(labelW).Render(labels[i]), bar, valW, r.value) } return strings.Join(lines, "\n") } -// clip shortens a label to n display columns, ending with … when cut. -func clip(s string, n int) string { - if lipgloss.Width(s) <= n { - return s - } - r := []rune(s) - for len(r) > 0 && lipgloss.Width(string(r))+1 > n { - r = r[:len(r)-1] - } - return string(r) + "…" -} - -// padR right-pads s with spaces to n display columns. -func padR(s string, n int) string { - if p := n - lipgloss.Width(s); p > 0 { - return s + strings.Repeat(" ", p) - } - return s -} - func spark(vals []int, width int) string { sl := sparkline.New(width, 1) f := make([]float64, len(vals)) @@ -285,14 +267,12 @@ func boardBars(s todo.Stats, width int) string { names = append(names, n) } // stable order: most open first, name tiebreak - for i := 0; i < len(names); i++ { - for j := i + 1; j < len(names); j++ { - a, b := s.ByBoard[names[i]], s.ByBoard[names[j]] - if b.Open > a.Open || (b.Open == a.Open && names[j] < names[i]) { - names[i], names[j] = names[j], names[i] - } + sort.Slice(names, func(i, j int) bool { + if a, b := s.ByBoard[names[i]].Open, s.ByBoard[names[j]].Open; a != b { + return a > b } - } + return names[i] < names[j] + }) for _, n := range names { rows = append(rows, barRow{n, s.ByBoard[n].Open, colInfo}) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 343e23f..b6ac878 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" "time" @@ -44,8 +45,9 @@ Items (ref = an item's stable id from 'list --json', or its 1-based index n; failures as {"error":…} on stdout instead of text on stderr. Mutating verbs are safe to repeat: re-marking a done item keeps its stamp. - syntax: @category !h|!m|!l due: defer: link: - status: note: (takes the rest of the line) + syntax: @category #tag (adds) tags: (replaces) !h|!m|!l + due: defer: link: status: + note: (takes the rest of the line) Boards (the default board can't be renamed/deleted/archived): board rename @@ -55,7 +57,7 @@ Boards (the default board can't be renamed/deleted/archived): board dir [] show/set a board's working directory Global flags (any command): - --board act on a board's board (or set $SHEPHERD_BOARD) + --board act on a named board (or set $SHEPHERD_BOARD) -q, --quiet suppress state-change confirmation lines --no-input never prompt (accepted for script-compat; this API never prompts) -h, --help print a command's flags @@ -150,52 +152,35 @@ var quiet bool // --no-input is accepted for script-compat (clig) but is a no-op: the command // API reads only argv and never prompts. func extractGlobals(args []string) []string { - quiet = false - rest := make([]string, 0, len(args)) - for _, a := range args { - switch a { - case "-q", "--quiet": - quiet = true - case "--no-input": - // no-op: the command API never prompts. - default: - rest = append(rest, a) - } - } - return rest + quiet, args = extractFlag(args, "-q", "--quiet") + _, args = extractFlag(args, "--no-input") // accepted for script-compat; never prompts + return args } -// extractDryRun pulls -n/--dry-run out of args (for the destructive verbs that -// support a preview: rm and board delete). -func extractDryRun(args []string) (bool, []string) { - dry := false +// extractFlag pulls a boolean flag (spelled any of names) out of args, returning +// whether it was present and the remaining args. The mutating verbs strip their +// flags by hand rather than through a FlagSet, which would reject the global +// ones as unknown and would fight `add` joining its args into text. +func extractFlag(args []string, names ...string) (bool, []string) { + found := false rest := make([]string, 0, len(args)) for _, a := range args { - if a == "-n" || a == "--dry-run" { - dry = true + if slices.Contains(names, a) { + found = true continue } rest = append(rest, a) } - return dry, rest + return found, rest } -// extractJSON pulls --json out of args (the mutating verbs parse it by hand -// rather than through a FlagSet, like extractDryRun). In JSON mode a verb echoes -// the resulting item(s) so an agent needn't re-list to confirm, and reports -// failures as a structured object instead of free text on stderr. -func extractJSON(args []string) (bool, []string) { - asJSON := false - rest := make([]string, 0, len(args)) - for _, a := range args { - if a == "--json" { - asJSON = true - continue - } - rest = append(rest, a) - } - return asJSON, rest -} +// extractDryRun serves the destructive verbs that support a preview (rm, board +// delete). extractJSON puts a verb in JSON mode: it echoes the resulting item(s) +// so an agent needn't re-list to confirm, and reports failures as a structured +// object instead of free text on stderr. +func extractDryRun(args []string) (bool, []string) { return extractFlag(args, "-n", "--dry-run") } + +func extractJSON(args []string) (bool, []string) { return extractFlag(args, "--json") } // emitJSON marshals v and writes it to w. Returns exit 0, or 1 if v somehow // can't be marshalled (never expected for these types). @@ -314,6 +299,7 @@ type itemJSON struct { Priority string `json:"priority,omitempty"` // "H"/"M"/"L" Text string `json:"text"` Category string `json:"category,omitempty"` + Tags []string `json:"tags,omitempty"` // lowercase free-form labels Created string `json:"created,omitempty"` Completed string `json:"completed,omitempty"` Defer string `json:"defer,omitempty"` // ISO YYYY-MM-DD @@ -325,7 +311,7 @@ type itemJSON struct { } func toJSON(it todo.Item, idx int) itemJSON { - j := itemJSON{ID: it.ID, Index: idx, Done: it.Done, Status: it.Status, Text: it.Text, Category: it.Category, Created: it.Created, Completed: it.Completed, Defer: it.Defer, Due: it.Due, Link: it.Link, Note: it.Note, Board: it.Source} + j := itemJSON{ID: it.ID, Index: idx, Done: it.Done, Status: it.Status, Text: it.Text, Category: it.Category, Tags: it.Tags, Created: it.Created, Completed: it.Completed, Defer: it.Defer, Due: it.Due, Link: it.Link, Note: it.Note, Board: it.Source} if it.Prio != 0 { j.Priority = string(it.Prio) } @@ -361,7 +347,7 @@ func cmdList(args []string, board string, w io.Writer) int { fs := flag.NewFlagSet("list", flag.ContinueOnError) asJSON := fs.Bool("json", false, "machine-readable JSON output") all := fs.Bool("all", false, "aggregate items across every board (read-only)") - filter := fs.String("filter", "", "only items matching this text (text/note/category/due/defer/link)") + filter := fs.String("filter", "", "only items matching this text (text/note/category/tags/due/defer/link)") if err := fs.Parse(args); err != nil { return parseExit(err) } @@ -673,10 +659,24 @@ func cmdBoard(args []string, w io.Writer) int { } } -// The mutating verbs run their whole load→mutate→save under store.WithLock, so +// locked runs a verb's whole load→mutate→save under store.WithLock, so // concurrent shepherd processes (parallel agents) serialise and never lose one -// another's edits. Each captures its exit code from inside the locked closure; -// a returned error is an IO/lock failure (exit 1 via saveErr). +// another's edits. fn gets the board path and its freshly loaded items and +// returns the verb's exit code; a returned error is an IO/lock failure, reported +// as exit 1 via saveErr. +func locked(board string, w io.Writer, asJSON bool, fn func(path string, items []todo.Item) (int, error)) int { + path := store.TodoPathFor(board) + exit := 0 + err := store.WithLock(path, func() error { + var ferr error + exit, ferr = fn(path, store.Load(path)) + return ferr + }) + if err != nil { + return saveErr(w, asJSON, err) + } + return exit +} func cmdAdd(args []string, board string, w io.Writer) int { asJSON, args := extractJSON(args) @@ -688,25 +688,18 @@ func cmdAdd(args []string, board string, w io.Writer) int { if it.Text == "" { return usageErr(w, asJSON, "nothing to add after parsing tokens") } - path := store.TodoPathFor(board) - exit := 0 - err := store.WithLock(path, func() error { - items := append(store.Load(path), it) + return locked(board, w, asJSON, func(path string, items []todo.Item) (int, error) { + items = append(items, it) if err := store.Save(path, items); err != nil { // Save backfills the new item's id - return err + return 0, err } idx := len(items) if asJSON { - exit = emitJSON(w, toJSON(items[idx-1], idx)) - } else { - say(w, formatLine(idx, items[idx-1])) + return emitJSON(w, toJSON(items[idx-1], idx)), nil } - return nil + say(w, formatLine(idx, items[idx-1])) + return 0, nil }) - if err != nil { - return saveErr(w, asJSON, err) - } - return exit } // cmdSub adds a subtask to item : shepherd sub "". The text @@ -714,46 +707,34 @@ func cmdAdd(args []string, board string, w io.Writer) int { // parent (it's no longer all-done). must resolve to a top-level item. func cmdSub(args []string, board string, w io.Writer) int { asJSON, args := extractJSON(args) - path := store.TodoPathFor(board) - exit := 0 - err := store.WithLock(path, func() error { - items := store.Load(path) + return locked(board, w, asJSON, func(path string, items []todo.Item) (int, error) { if len(args) == 0 { - exit = usageErr(w, asJSON, `sub needs an item and text, e.g. shepherd sub 1 "parse tokens"`) - return nil + return usageErr(w, asJSON, `sub needs an item and text, e.g. shepherd sub 1 "parse tokens"`), nil } p, s, ok := resolveRef(args[0], items) if !ok || s != 0 { - exit = refErr(w, asJSON, args[0], len(items)) - return nil + return refErr(w, asJSON, args[0], len(items)), nil } text := strings.TrimSpace(strings.Join(args[1:], " ")) if text == "" { - exit = usageErr(w, asJSON, `sub needs text, e.g. shepherd sub 1 "parse tokens"`) - return nil + return usageErr(w, asJSON, `sub needs text, e.g. shepherd sub 1 "parse tokens"`), nil } sub := todo.ParseQuickAdd(text) if sub.Text == "" { - exit = usageErr(w, asJSON, "nothing to add after parsing tokens") - return nil + return usageErr(w, asJSON, "nothing to add after parsing tokens"), nil } parent := &items[p-1] parent.Subs = append(parent.Subs, sub) todo.SetDone(parent, todo.AllSubsDone(parent)) if err := store.Save(path, items); err != nil { - return err + return 0, err } if asJSON { - exit = emitJSON(w, toJSON(items[p-1], p)) - } else { - say(w, formatSub(p, len(parent.Subs), parent.Subs[len(parent.Subs)-1])) + return emitJSON(w, toJSON(items[p-1], p)), nil } - return nil + say(w, formatSub(p, len(parent.Subs), parent.Subs[len(parent.Subs)-1])) + return 0, nil }) - if err != nil { - return saveErr(w, asJSON, err) - } - return exit } // cmdEdit merges quick-add tokens onto an existing item (or subtask): @@ -762,23 +743,17 @@ func cmdSub(args []string, board string, w io.Writer) int { // is replaced only when plain words are given (see todo.ApplyEdit). func cmdEdit(args []string, board string, w io.Writer) int { asJSON, args := extractJSON(args) - path := store.TodoPathFor(board) - exit := 0 - err := store.WithLock(path, func() error { - items := store.Load(path) + return locked(board, w, asJSON, func(path string, items []todo.Item) (int, error) { if len(args) == 0 { - exit = usageErr(w, asJSON, `edit needs an item and tokens, e.g. shepherd edit 2 "@home !h due:tomorrow"`) - return nil + return usageErr(w, asJSON, `edit needs an item and tokens, e.g. shepherd edit 2 "@home !h due:tomorrow"`), nil } p, s, ok := resolveRef(args[0], items) if !ok { - exit = refErr(w, asJSON, args[0], len(items)) - return nil + return refErr(w, asJSON, args[0], len(items)), nil } text := strings.TrimSpace(strings.Join(args[1:], " ")) if text == "" { - exit = usageErr(w, asJSON, `edit needs tokens, e.g. shepherd edit 2 "@home !h due:tomorrow"`) - return nil + return usageErr(w, asJSON, `edit needs tokens, e.g. shepherd edit 2 "@home !h due:tomorrow"`), nil } if s == 0 { todo.ApplyEdit(&items[p-1], text) @@ -789,21 +764,18 @@ func cmdEdit(args []string, board string, w io.Writer) int { todo.SetDone(&items[p-1], todo.AllSubsDone(&items[p-1])) } if err := store.Save(path, items); err != nil { - return err + return 0, err } - if asJSON { - exit = emitJSON(w, toJSON(items[p-1], p)) - } else if s == 0 { + switch { + case asJSON: + return emitJSON(w, toJSON(items[p-1], p)), nil + case s == 0: say(w, formatLine(p, items[p-1])) - } else { + default: say(w, formatSub(p, s, items[p-1].Subs[s-1])) } - return nil + return 0, nil }) - if err != nil { - return saveErr(w, asJSON, err) - } - return exit } // cmdToggle marks one or more items (not) done: shepherd done|undone ... @@ -812,14 +784,10 @@ func cmdEdit(args []string, board string, w io.Writer) int { // stays valid through the loop. func cmdToggle(args []string, board string, done bool, w io.Writer) int { asJSON, args := extractJSON(args) - path := store.TodoPathFor(board) - exit := 0 - err := store.WithLock(path, func() error { - items := store.Load(path) + return locked(board, w, asJSON, func(path string, items []todo.Item) (int, error) { refs, bad, ok := parseRefs(args, items) if !ok { - exit = refErr(w, asJSON, bad, len(items)) - return nil + return refErr(w, asJSON, bad, len(items)), nil } for _, r := range refs { if r[1] == 0 { @@ -829,11 +797,10 @@ func cmdToggle(args []string, board string, done bool, w io.Writer) int { } } if err := store.Save(path, items); err != nil { - return err + return 0, err } if asJSON { - exit = emitJSON(w, affectedJSON(items, refs)) - return nil + return emitJSON(w, affectedJSON(items, refs)), nil } for _, r := range refs { say(w, formatLine(r[0], items[r[0]-1])) @@ -841,12 +808,8 @@ func cmdToggle(args []string, board string, done bool, w io.Writer) int { say(w, formatSub(r[0], r[1], items[r[0]-1].Subs[r[1]-1])) } } - return nil + return 0, nil }) - if err != nil { - return saveErr(w, asJSON, err) - } - return exit } // cmdRemove deletes one or more items/subtasks: shepherd rm ... [--dry-run]. @@ -855,14 +818,10 @@ func cmdToggle(args []string, board string, done bool, w io.Writer) int { func cmdRemove(args []string, board string, w io.Writer) int { asJSON, args := extractJSON(args) dry, args := extractDryRun(args) - path := store.TodoPathFor(board) - exit := 0 - err := store.WithLock(path, func() error { - items := store.Load(path) + return locked(board, w, asJSON, func(path string, items []todo.Item) (int, error) { refs, bad, ok := parseRefs(args, items) if !ok { - exit = refErr(w, asJSON, bad, len(items)) - return nil + return refErr(w, asJSON, bad, len(items)), nil } rmParent := map[int]bool{} // parent index -> whole item removed rmSub := map[int]map[int]bool{} // parent index -> sub indices removed @@ -882,13 +841,12 @@ func cmdRemove(args []string, board string, w io.Writer) int { } if dry { if asJSON { - exit = emitJSON(w, map[string]any{"dry_run": true, "removed": removed}) - } else { - for _, t := range removed { - emit(w, fmt.Sprintf("would remove %q", t)) - } + return emitJSON(w, map[string]any{"dry_run": true, "removed": removed}), nil } - return nil + for _, t := range removed { + emit(w, fmt.Sprintf("would remove %q", t)) + } + return 0, nil } kept := make([]todo.Item, 0, len(items)) for i := range items { @@ -912,21 +870,16 @@ func cmdRemove(args []string, board string, w io.Writer) int { kept = append(kept, items[i]) } if err := store.Save(path, kept); err != nil { - return err + return 0, err } if asJSON { - exit = emitJSON(w, map[string]any{"removed": removed}) - } else { - for _, t := range removed { - say(w, fmt.Sprintf("removed %q", t)) - } + return emitJSON(w, map[string]any{"removed": removed}), nil } - return nil + for _, t := range removed { + say(w, fmt.Sprintf("removed %q", t)) + } + return 0, nil }) - if err != nil { - return saveErr(w, asJSON, err) - } - return exit } // cmdArchive moves one or more top-level items off the live board and into the @@ -937,20 +890,15 @@ func cmdRemove(args []string, board string, w io.Writer) int { // multi-archive is index-shift-safe like rm. func cmdArchive(args []string, board string, w io.Writer) int { asJSON, args := extractJSON(args) - path := store.TodoPathFor(board) - exit := 0 - err := store.WithLock(path, func() error { - items := store.Load(path) + return locked(board, w, asJSON, func(path string, items []todo.Item) (int, error) { refs, bad, ok := parseRefs(args, items) if !ok { - exit = refErr(w, asJSON, bad, len(items)) - return nil + return refErr(w, asJSON, bad, len(items)), nil } arcParent := map[int]bool{} // distinct parent indices to archive for _, r := range refs { if r[1] != 0 { - exit = usageErr(w, asJSON, "archive whole items, not subtasks") - return nil + return usageErr(w, asJSON, "archive whole items, not subtasks"), nil } arcParent[r[0]] = true } @@ -966,24 +914,19 @@ func cmdArchive(args []string, board string, w io.Writer) int { // Append to the archive before rewriting the board: an interrupted run // re-archives (a dup in archive.md) rather than losing the item. if err := store.AppendArchive(path, archived); err != nil { - return err + return 0, err } if err := store.Save(path, kept); err != nil { - return err + return 0, err } if asJSON { - exit = emitJSON(w, affectedJSON(items, refs)) - return nil + return emitJSON(w, affectedJSON(items, refs)), nil } for _, it := range archived { say(w, fmt.Sprintf("archived %q", it.Text)) } - return nil + return 0, nil }) - if err != nil { - return saveErr(w, asJSON, err) - } - return exit } // resolveRef resolves one item ref against items — a stable id, a "n" index, or @@ -1079,6 +1022,9 @@ func formatLine(idx int, it todo.Item) string { if it.Category != "" { fmt.Fprintf(&b, " @%s", it.Category) } + if len(it.Tags) > 0 { + fmt.Fprintf(&b, " %s", tagList(it.Tags)) + } if it.Due != "" { fmt.Fprintf(&b, " due %s", it.Due) } @@ -1088,6 +1034,9 @@ func formatLine(idx int, it todo.Item) string { return b.String() } +// tagList renders tags the way they're typed back in: "#api #docs". +func tagList(tags []string) string { return "#" + strings.Join(tags, " #") } + // formatSub renders a subtask as an indented, dotted-index line under its parent. func formatSub(parent, sub int, it todo.Item) string { box := " " @@ -1103,6 +1052,9 @@ func formatSub(parent, sub int, it todo.Item) string { if !it.Done && it.Status != "" { fmt.Fprintf(&b, " ~%s", it.Status) } + if len(it.Tags) > 0 { + fmt.Fprintf(&b, " %s", tagList(it.Tags)) + } if it.Due != "" { fmt.Fprintf(&b, " due %s", it.Due) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index da5e539..29ff161 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -276,6 +276,47 @@ func TestCLIEdit(t *testing.T) { } } +// TestCLITags checks tags survive add/edit through the store, come back out of +// list --json, and that --filter finds an item by tag. +func TestCLITags(t *testing.T) { + path := filepath.Join(t.TempDir(), "todo.md") + t.Setenv("SHEPHERD_TODO_FILE", path) + + cmdAdd([]string{"wire the webhook #api"}, "", &bytes.Buffer{}) + if code := cmdEdit([]string{"1", "#docs"}, "", &bytes.Buffer{}); code != 0 { + t.Fatalf("edit #tag exit %d", code) + } + if it := store.Load(path)[0]; it.Text != "wire the webhook" || len(it.Tags) != 2 || it.Tags[1] != "docs" { + t.Fatalf("tags not persisted: %+v", it) + } + + var buf bytes.Buffer + if code := cmdList([]string{"--filter", "docs", "--json"}, "", &buf); code != 0 { + t.Fatalf("list --filter exit %d", code) + } + var got []itemJSON + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("json: %v", err) + } + if len(got) != 1 || len(got[0].Tags) != 2 || got[0].Tags[0] != "api" { + t.Fatalf("tags missing from JSON / filter: %+v", got) + } + + // tags: replaces the whole set; bare tags: clears it and leaves text alone. + if code := cmdEdit([]string{"1", "tags:ops"}, "", &bytes.Buffer{}); code != 0 { + t.Fatalf("edit tags: exit %d", code) + } + if it := store.Load(path)[0]; len(it.Tags) != 1 || it.Tags[0] != "ops" { + t.Fatalf("tags: should replace: %+v", it.Tags) + } + if code := cmdEdit([]string{"1", "tags:"}, "", &bytes.Buffer{}); code != 0 { + t.Fatalf("edit clear tags exit %d", code) + } + if it := store.Load(path)[0]; len(it.Tags) != 0 || it.Text != "wire the webhook" { + t.Fatalf("bare tags: should clear tags only: %+v", it) + } +} + // TestCLIListFilter checks --filter narrows the output while keeping each item's // real board index (so done/rm by that index still work) and reports no matches. func TestCLIListFilter(t *testing.T) { diff --git a/internal/cli/schema.go b/internal/cli/schema.go index fd3a411..2c4363f 100644 --- a/internal/cli/schema.go +++ b/internal/cli/schema.go @@ -74,7 +74,8 @@ func buildSchema() schemaDoc { "status": {Type: "string", Enum: statuses, Description: "named status; empty = default/open. done is the terminal state"}, "priority": {Type: "string", Enum: []string{"H", "M", "L"}, Description: "empty = none"}, "text": {Type: "string", MinLength: 1}, - "category": {Type: "string", Description: "single lowercase tag"}, + "category": {Type: "string", Description: "single lowercase grouping label; drives board order"}, + "tags": {Type: "array", Description: "lowercase free-form labels; many per item, no ordering effect", Items: &property{Type: "string"}}, "created": {Type: "string", ReadOnly: true}, "completed": {Type: "string", ReadOnly: true, Description: "set when done"}, "defer": {Type: "string", Format: "date", Description: "YYYY-MM-DD start/defer date"}, @@ -85,6 +86,8 @@ func buildSchema() schemaDoc { }, Tokens: []tokenSpec{ {Token: "@", Field: "category", Desc: "lowercased; bare @ clears"}, + {Token: "#", Field: "tags", Desc: "adds one tag (lowercased); bare # clears all"}, + {Token: "tags:", Field: "tags", Desc: "replaces the whole set; bare tags: clears"}, {Token: "!h|!m|!l", Field: "priority", Desc: "bare ! clears"}, {Token: "due:", Field: "due", Desc: "bare due: clears; see x-dueForms"}, {Token: "defer:", Field: "defer", Desc: "bare defer: clears"}, diff --git a/internal/store/boarddir.go b/internal/store/boarddir.go index 7c5fec5..0644ca6 100644 --- a/internal/store/boarddir.go +++ b/internal/store/boarddir.go @@ -20,28 +20,14 @@ func boardDirsPath() string { return filepath.Join(filepath.Dir(ConfigPath()), "boards.toml") } -// loadBoardDirs reads the sidecar into a name→dir map. Leniently parsed: -// `name = "dir"` lines, blanks and #-comments skipped. Missing file → empty map. +// loadBoardDirs reads the sidecar into a name→dir map (see ScanKV for the +// accepted `name = "dir"` shape). Missing file → empty map. func loadBoardDirs() map[string]string { - out := map[string]string{} - data, err := os.ReadFile(boardDirsPath()) - if err != nil { - return out + dirs := ScanKVFile(boardDirsPath()) + for name, dir := range dirs { + dirs[name] = strings.Trim(dir, `"`) } - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(ln) - if ln == "" || strings.HasPrefix(ln, "#") { - continue - } - k, v, ok := strings.Cut(ln, "=") - if !ok { - continue - } - if name := strings.TrimSpace(k); name != "" { - out[name] = strings.Trim(strings.TrimSpace(v), `"`) - } - } - return out + return dirs } // BoardDir returns the configured working directory for a board, or "" if none. diff --git a/internal/store/store.go b/internal/store/store.go index 8d52c91..d2b0cef 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,12 +16,12 @@ import ( var ( lineRE = regexp.MustCompile(`^- \[([ xX])\] (?:\(([HMLhml])\) )?(.*)$`) - metaRE = regexp.MustCompile(`^ (id|created|completed|defer|note|category|due|link|status): (.*)$`) + metaRE = regexp.MustCompile(`^ (id|created|completed|defer|note|category|tags|due|link|status): (.*)$`) // subtask lines are the same checklist syntax indented two spaces, with // their own meta indented four. They never collide with metaRE (which needs // a bare `word:` at two spaces, never `- [`). subLineRE = regexp.MustCompile(`^ - \[([ xX])\] (?:\(([HMLhml])\) )?(.*)$`) - subMetaRE = regexp.MustCompile(`^ (id|created|completed|defer|note|category|due|link|status): (.*)$`) + subMetaRE = regexp.MustCompile(`^ (id|created|completed|defer|note|category|tags|due|link|status): (.*)$`) ) // boardRE is the allowed board-name slug. Anchored and free of path @@ -81,9 +81,6 @@ func TodoPathFor(board string) string { return filepath.Join(BaseDir(), "todo.md") } -// TodoPath resolves the default todo file (no board). -func TodoPath() string { return TodoPathFor("") } - // ConfigPath resolves the shared config file: $SHEPHERD_CONFIG, else a sibling // of the whole-file override if one is set, else BaseDir/config.toml. It stays // at BaseDir for board boards so every board shares one config. @@ -97,37 +94,56 @@ func ConfigPath() string { return filepath.Join(BaseDir(), "config.toml") } -// ConfigStatusOrder reads the `statuses = [...]` line from the shared -// config.toml (the same file the TUI reads), lowercased, in declared order. -// Returns nil if unset/unreadable. This lets the CLI's stats respect the user's -// configured status order instead of only count order. -// -// a minimal read of one key, not the TUI's fuller loader in -// internal/tui — kept separate so this can't destabilize the board. If more -// config keys are ever needed CLI-side, promote the tui loader into store. -func ConfigStatusOrder() []string { - data, err := os.ReadFile(ConfigPath()) - if err != nil { - return nil - } - for _, ln := range strings.Split(string(data), "\n") { +// ScanKV parses the lenient `key = value` shape shepherd reads and writes +// (config.toml, boards.toml, the embedded plugin manifest): blanks and +// #-comments skipped, lines without an `=` ignored, values kept raw so the +// caller unquotes or splits them. Later keys win. +func ScanKV(data string) map[string]string { + out := map[string]string{} + for _, ln := range strings.Split(data, "\n") { ln = strings.TrimSpace(ln) if ln == "" || strings.HasPrefix(ln, "#") { continue } - k, v, ok := strings.Cut(ln, "=") - if !ok || strings.TrimSpace(k) != "statuses" { - continue - } - var out []string - for _, part := range strings.Split(strings.Trim(strings.TrimSpace(v), "[]"), ",") { - if p := strings.ToLower(strings.Trim(strings.TrimSpace(part), `"`)); p != "" { - out = append(out, p) + if k, v, ok := strings.Cut(ln, "="); ok { + if k = strings.TrimSpace(k); k != "" { + out[k] = strings.TrimSpace(v) } } - return out } - return nil + return out +} + +// ScanKVFile is ScanKV over a file; a missing or unreadable one is an empty map. +func ScanKVFile(path string) map[string]string { + data, err := os.ReadFile(path) + if err != nil { + return map[string]string{} + } + return ScanKV(string(data)) +} + +// ParseList splits a scanned list value — `["a", "b"]` or a bare `a, b` — into +// its entries, dropping blanks; lower lowercases each (statuses). +func ParseList(v string, lower bool) []string { + var out []string + for _, part := range strings.Split(strings.Trim(v, "[]"), ",") { + s := strings.Trim(strings.TrimSpace(part), `"`) + if lower { + s = strings.ToLower(s) + } + if s != "" { + out = append(out, s) + } + } + return out +} + +// ConfigStatusOrder reads `statuses = [...]` from the shared config.toml (the +// same file the TUI reads), lowercased, in declared order; nil if unset. It lets +// the CLI's stats respect the configured status order, not just count order. +func ConfigStatusOrder() []string { + return ParseList(ScanKVFile(ConfigPath())["statuses"], true) } // ArchivePath is the archive sibling of the todo file: todo.md -> archive.md, @@ -396,6 +412,8 @@ func applyMeta(it *todo.Item, key, val string) { it.Defer = val case "category": it.Category = strings.ToLower(val) + case "tags": + it.Tags = todo.ParseTags(val) case "due": it.Due = val case "link": @@ -480,6 +498,9 @@ func writeItem(b *strings.Builder, it todo.Item, indent string) { if it.Category != "" { fmt.Fprintf(b, "%scategory: %s\n", meta, it.Category) } + if len(it.Tags) > 0 { + fmt.Fprintf(b, "%stags: %s\n", meta, strings.Join(it.Tags, ", ")) + } if !it.Done && it.Status != "" { fmt.Fprintf(b, "%sstatus: %s\n", meta, it.Status) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index fefe126..b126ad7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -136,6 +136,29 @@ func TestStatusRoundTrip(t *testing.T) { } } +// TestTagsRoundTrip covers the tags: meta line on a parent and a subtask: the +// comma list parses into normalised tags and re-serialises byte-identically. +func TestTagsRoundTrip(t *testing.T) { + p := filepath.Join(t.TempDir(), "todo.md") + src := "- [ ] parent\n category: work\n tags: api, docs\n - [ ] child\n tags: ops\n" + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + items := Load(p) + if got := items[0].Tags; len(got) != 2 || got[0] != "api" || got[1] != "docs" { + t.Fatalf("tags not parsed: %+v", got) + } + if got := items[0].Subs[0].Tags; len(got) != 1 || got[0] != "ops" { + t.Fatalf("subtask tags not parsed: %+v", got) + } + if err := Save(p, items); err != nil { + t.Fatal(err) + } + if got := string(mustRead(t, p)); got != src { + t.Fatalf("tags round-trip mismatch:\nwant %q\ngot %q", src, got) + } +} + func TestNoteMultilineRoundTrip(t *testing.T) { p := filepath.Join(t.TempDir(), "todo.md") // a multi-line note serialises as one note: line per physical line. @@ -227,7 +250,7 @@ func TestAppendArchive(t *testing.T) { func TestTodoPathResolution(t *testing.T) { // SHEPHERD_TODO_FILE is the whole-file override. t.Setenv("SHEPHERD_TODO_FILE", "/x/y.md") - if got := TodoPath(); got != "/x/y.md" { + if got := TodoPathFor(""); got != "/x/y.md" { t.Errorf("SHEPHERD_TODO_FILE not honoured: %q", got) } _ = os.Unsetenv("SHEPHERD_TODO_FILE") @@ -235,12 +258,12 @@ func TestTodoPathResolution(t *testing.T) { // The old HERDR_ vars no longer affect paths. t.Setenv("HERDR_TODO_FILE", "/x/y.md") t.Setenv("HERDR_PLUGIN_STATE_DIR", "/state") - if got := TodoPath(); !strings.HasSuffix(got, "/.config/shepherd/todo.md") { + if got := TodoPathFor(""); !strings.HasSuffix(got, "/.config/shepherd/todo.md") { t.Errorf("HERDR_ vars should be ignored, got %q", got) } _ = os.Unsetenv("HERDR_TODO_FILE") _ = os.Unsetenv("HERDR_PLUGIN_STATE_DIR") - if got := TodoPath(); !strings.HasSuffix(got, "/.config/shepherd/todo.md") { + if got := TodoPathFor(""); !strings.HasSuffix(got, "/.config/shepherd/todo.md") { t.Errorf("default path = %q", got) } diff --git a/internal/todo/item.go b/internal/todo/item.go index 4e57729..802afd7 100644 --- a/internal/todo/item.go +++ b/internal/todo/item.go @@ -13,6 +13,7 @@ type Item struct { Prio byte // 'H', 'M', 'L', or 0 for none Text string Category string + Tags []string // free-form lowercase labels; unlike Category an item can carry many Created string Completed string // timestamp the item was marked done, or empty Defer string // YYYY-MM-DD start/defer date, or empty @@ -29,13 +30,42 @@ type Item struct { Subs []Item } +// AddTag appends tag to it.Tags unless it's already there, normalised to +// lowercase with any leading "#" stripped. A blank tag is a no-op. +func AddTag(it *Item, tag string) { + tag = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) + if tag == "" { + return + } + for _, t := range it.Tags { + if t == tag { + return + } + } + it.Tags = append(it.Tags, tag) +} + +// ParseTags splits a comma-separated list ("api, docs") into normalised, +// deduplicated tags — the value form of the tags: token and the tags: meta line. +func ParseTags(s string) []string { + var acc Item + for _, part := range strings.Split(s, ",") { + AddTag(&acc, part) + } + return acc.Tags +} + // ApplyEdit applies quick-add tokens onto an existing item, touching only the -// fields present in s: @category, !h/!m/!l priority, due:, -// defer:, link:, status:, and note:. A bare key token -// clears that field: "@", "!", "due:", "defer:", "link:", "status:" reset -// category / priority / due / defer / link / status respectively. Text is -// replaced only when s carries plain (non-token) words, so a token-only edit -// leaves the text alone. Unrecognised tokens count as plain words. +// fields present in s: @category, #tag, tags:, !h/!m/!l priority, +// due:, defer:, link:, status:, and note:. A +// bare key token clears that field: "@", "#", "!", "due:", "defer:", "link:", +// "status:", "tags:" reset category / tags / priority / due / defer / link / +// status respectively. Text is replaced only when s carries plain (non-token) +// words, so a token-only edit leaves the text alone. Unrecognised tokens count +// as plain words. +// +// #tag only ever adds; tags: replaces the whole set, so that's how a +// single tag is dropped. // // note: is special — a note may contain spaces, so once seen it consumes the // rest of the line as the note value (a bare trailing "note:" clears it). Put @@ -54,6 +84,12 @@ func ApplyEdit(it *Item, s string) { it.Category = "" case strings.HasPrefix(tok, "@") && len(tok) > 1: it.Category = strings.ToLower(tok[1:]) + case tok == "#" || tok == "tags:": + it.Tags = nil + case strings.HasPrefix(tok, "#") && len(tok) > 1: + AddTag(it, tok[1:]) + case strings.HasPrefix(tok, "tags:") && len(tok) > 5: + it.Tags = ParseTags(tok[5:]) case tok == "!": it.Prio = 0 case strings.HasPrefix(tok, "!") && len(tok) == 2 && strings.ContainsRune("hHmMlL", rune(tok[1])): @@ -106,7 +142,7 @@ func ParseQuickAdd(s string) Item { // Match reports whether an item matches filter query q, which the caller has // already lowercased. Empty q matches everything. Searches text, note, -// category, due, defer, and link. +// category, tags, due, defer, and link. func Match(it Item, q string) bool { if q == "" { return true @@ -114,6 +150,8 @@ func Match(it Item, q string) bool { return strings.Contains(strings.ToLower(it.Text), q) || strings.Contains(strings.ToLower(it.Note), q) || strings.Contains(strings.ToLower(it.Category), q) || + strings.Contains(strings.Join(it.Tags, " "), q) || // already lowercase + strings.Contains(strings.ToLower(it.Due), q) || strings.Contains(strings.ToLower(it.Defer), q) || strings.Contains(strings.ToLower(it.Link), q) diff --git a/internal/todo/sort.go b/internal/todo/sort.go index 193fb8f..b1ab979 100644 --- a/internal/todo/sort.go +++ b/internal/todo/sort.go @@ -28,6 +28,16 @@ func catKey(c string) string { return strings.ToLower(c) } +// TagKey is an item's grouping tag: its first tag, or "" when untagged. Tags are +// multi-valued but a row belongs to exactly one group, so the first one wins — +// the rest ride along on the row. Exported for the UI's group ordering. +func TagKey(it Item) string { + if len(it.Tags) == 0 { + return "" + } + return it.Tags[0] +} + // dueKey sorts soonest-first; no due date sorts last. func dueKey(d string) string { if d == "" { @@ -63,6 +73,18 @@ func Sort(items []Item, byPrio bool) { sort.SliceStable(items, func(i, j int) bool { return less(items[i], items[j], byPrio) }) } +// SortByTag orders items by their grouping tag (untagged last), then by the +// shared intra-group order, so each tag's items stay contiguous. +func SortByTag(items []Item) { + sort.SliceStable(items, func(i, j int) bool { + // catKey's "untagged/uncategorized last" trick works for tags too. + if a, b := catKey(TagKey(items[i])), catKey(TagKey(items[j])); a != b { + return a < b + } + return less(items[i], items[j], false) + }) +} + // SortBySource orders items by Source first (the global board view), then by // the shared intra-group order, so each board's items stay contiguous. func SortBySource(items []Item) { diff --git a/internal/todo/subtasks.go b/internal/todo/subtasks.go index 09d28f8..8890afe 100644 --- a/internal/todo/subtasks.go +++ b/internal/todo/subtasks.go @@ -25,17 +25,6 @@ func SetSubDone(p *Item, i int, done bool) { SetDone(p, AllSubsDone(p)) } -// SetSubStatus sets a subtask's named status (same rules as SetStatus), then -// reconciles the parent's done state — a subtask cycled to "done" can complete -// the parent, an intermediate status reopens it. -func SetSubStatus(p *Item, i int, name string) { - if i < 0 || i >= len(p.Subs) { - return - } - SetStatus(&p.Subs[i], name) - SetDone(p, AllSubsDone(p)) -} - // CycleSubStatus advances a subtask through the configured statuses, then // reconciles the parent's done state. func CycleSubStatus(p *Item, i int, statuses []string) { @@ -70,18 +59,27 @@ func SubCount(it Item) (done, total int) { return done, len(it.Subs) } -// Clone deep-copies items including their Subs, so a snapshot (undo/redo) can't -// be mutated through a shared subtask slice. Item is non-comparable, so the -// outer append([]Item, ...) shallow copy is not enough on its own. +// Clone deep-copies items including their Subs and Tags, so a snapshot +// (undo/redo) can't be mutated through a shared slice. Item is non-comparable, +// so the outer append([]Item, ...) shallow copy is not enough on its own. func Clone(items []Item) []Item { if items == nil { return nil } + cloneOne := func(it Item) Item { + if it.Tags != nil { + it.Tags = append([]string(nil), it.Tags...) + } + return it + } out := make([]Item, len(items)) for i := range items { - out[i] = items[i] + out[i] = cloneOne(items[i]) if items[i].Subs != nil { - out[i].Subs = append([]Item(nil), items[i].Subs...) + out[i].Subs = make([]Item, len(items[i].Subs)) + for j, s := range items[i].Subs { + out[i].Subs[j] = cloneOne(s) + } } } return out diff --git a/internal/todo/subtasks_test.go b/internal/todo/subtasks_test.go index 8e4624b..a6b3bf2 100644 --- a/internal/todo/subtasks_test.go +++ b/internal/todo/subtasks_test.go @@ -54,13 +54,14 @@ func TestSubStatus(t *testing.T) { p := &Item{Text: "p", Subs: []Item{{Text: "a"}, {Text: "b"}}} // an intermediate status leaves the sub open and the parent open - SetSubStatus(p, 0, "in-progress") + CycleSubStatus(p, 0, statuses) // open -> in-progress if p.Subs[0].Done || p.Subs[0].Status != "in-progress" || p.Done { t.Fatalf("in-progress sub wrong: %+v", *p) } - // status "done" on the last open sub completes it; all done -> parent done - SetSubStatus(p, 0, "done") - SetSubStatus(p, 1, "done") + // cycling every sub through to done completes them; all done -> parent done + CycleSubStatus(p, 0, statuses) // in-progress -> done + CycleSubStatus(p, 1, statuses) // open -> in-progress + CycleSubStatus(p, 1, statuses) // in-progress -> done if !p.Subs[0].Done || !p.Done { t.Fatalf("done sub status should cascade to parent: %+v", *p) } diff --git a/internal/todo/todo_test.go b/internal/todo/todo_test.go index 06f670b..3e37871 100644 --- a/internal/todo/todo_test.go +++ b/internal/todo/todo_test.go @@ -255,3 +255,64 @@ func TestApplyEdit(t *testing.T) { t.Fatalf("status:done should mark done: %+v", it) } } + +func TestTags(t *testing.T) { + // #tag adds (lowercased, deduped) and leaves the text alone. + it := ParseQuickAdd("wire the webhook #API #api #docs") + if it.Text != "wire the webhook" || len(it.Tags) != 2 || it.Tags[0] != "api" || it.Tags[1] != "docs" { + t.Fatalf("#tag parse wrong: %+v", it) + } + if !Match(it, "docs") || Match(it, "zzz") { + t.Fatal("Match should search tags") + } + + // tags: replaces the whole set — the way one tag gets dropped. + ApplyEdit(&it, "tags:Docs,ops,") + if len(it.Tags) != 2 || it.Tags[0] != "docs" || it.Tags[1] != "ops" { + t.Fatalf("tags: should replace the set: %+v", it.Tags) + } + ApplyEdit(&it, "#api") // adds without dropping what's there + if len(it.Tags) != 3 || it.Tags[2] != "api" { + t.Fatalf("#tag should append: %+v", it.Tags) + } + + // bare # and bare tags: both clear, and neither touches the text. + for _, tok := range []string{"#", "tags:"} { + it.Tags = []string{"api"} + ApplyEdit(&it, tok) + if len(it.Tags) != 0 || it.Text != "wire the webhook" { + t.Fatalf("%q should clear tags only: %+v", tok, it) + } + } + + // Clone must not alias Tags, or an undo snapshot mutates with the live item. + orig := []Item{{Text: "x", Tags: []string{"api"}, Subs: []Item{{Text: "s", Tags: []string{"sub"}}}}} + snap := Clone(orig) + AddTag(&orig[0], "docs") + orig[0].Tags[0] = "changed" + orig[0].Subs[0].Tags[0] = "changed" + if len(snap[0].Tags) != 1 || snap[0].Tags[0] != "api" || snap[0].Subs[0].Tags[0] != "sub" { + t.Fatalf("Clone aliased Tags: %+v", snap[0]) + } +} + +func TestSortByTag(t *testing.T) { + items := []Item{ + {Text: "u1"}, // untagged -> last + {Text: "b1", Tags: []string{"docs"}}, + {Text: "a2", Tags: []string{"api", "docs"}, Prio: 'H'}, + {Text: "a1", Tags: []string{"api"}, Prio: 'L'}, + } + SortByTag(items) + got := "" + for _, it := range items { + got += it.Text + " " + } + // grouped by first tag (api 0 { - c.statuses = ss - } - case "autosave": - if n, err := strconv.Atoi(strings.Trim(v, `"`)); err == nil { - c.autosave = n - } - case "hidefooter": - c.hideFooter = strings.Trim(strings.ToLower(v), `"`) == "true" - } + if val("density") == "comfort" { + c.density = comfort + } + c.categories = store.ParseList(kv["categories"], false) + if ss := store.ParseList(kv["statuses"], true); len(ss) > 0 { + c.statuses = ss } + if n, err := strconv.Atoi(strings.Trim(kv["autosave"], `"`)); err == nil { + c.autosave = n + } + c.hideFooter = val("hidefooter") == "true" c.statuses = normalizeStatuses(c.statuses) return c } @@ -157,6 +125,7 @@ const ( modeEdit modeNote modeCategory + modeTags modeDue modeDefer modeLink @@ -202,11 +171,20 @@ type viewMode int const ( viewCategory viewMode = iota // grouped under category headers viewPriority // grouped under priority headers + viewTag // grouped under the item's first tag viewTable // flat bubbles/table viewBoard // grouped by source board (global view only) ) -var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTable: "table", viewBoard: "board"} +// viewCount is how many views `v` cycles through: every view on a board, plus +// viewBoard in the read-only global view. Keep viewBoard last in the iota so the +// two counts stay a simple modulus. +const ( + viewCount = int(viewBoard) + viewCountGlobal = viewCount + 1 +) + +var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewBoard: "board"} type model struct { path string @@ -243,6 +221,7 @@ type model struct { projNotice string // transient picker error (e.g. invalid/duplicate board name) projPending string // board whose working dir is being set (modeBoardDir) projDirEdit bool // dir editor opened from the detail view (empty clears, return to detail); false = creation flow (empty skips) + fieldEdit bool // a field editor was opened from the item detail view, so save/cancel returns there instead of the list settingsCur int // cursor into the settings rows (modeSettings) hideFooter bool // hide the list footer (help grid + version line) } @@ -262,11 +241,14 @@ func (m model) currentConfig() config { // resort orders items for the active view. func (m *model) resort() { - if m.view == viewBoard { + switch m.view { + case viewBoard: todo.SortBySource(m.items) - return + case viewTag: + todo.SortByTag(m.items) + default: + todo.Sort(m.items, m.view == viewPriority) } - todo.Sort(m.items, m.view == viewPriority) } // fingerprint is an order-independent snapshot of board content, so the saved diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 966f4fa..6484767 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "shepherd/internal/store" "shepherd/internal/todo" @@ -451,6 +452,48 @@ func TestAddInheritsFilterCategory(t *testing.T) { } } +func TestAddInheritsSelectedCategory(t *testing.T) { + // no filter: the new item takes the selected row's category + m := model{input: textinput.New(), items: []todo.Item{{Text: "a", Category: "work"}}} + m = drive(m, "a") + m.input.SetValue("write the changelog") + m = drive(m, "enter") + if got := lastByText(m, "write the changelog"); got.Category != "work" { + t.Fatalf("add over a categorised item should inherit: %+v", got) + } + + // inline @category still wins over the selected row + m = drive(m, "a") + m.input.SetValue("errand @home") + m = drive(m, "enter") + if got := lastByText(m, "errand"); got.Category != "home" { + t.Fatalf("inline category should override the selected row: %+v", got) + } + + // a subtask row contributes its parent's category + m = model{input: textinput.New(), items: []todo.Item{{Text: "p", Category: "work", Subs: []todo.Item{{Text: "s"}}}}} + m = drive(m, "j") // onto the subtask row + if ref := m.selRef(); ref.sub != 0 { + t.Fatalf("cursor should be on the subtask row: %+v", ref) + } + m = drive(m, "a") + m.input.SetValue("from a sub") + m = drive(m, "enter") + if got := lastByText(m, "from a sub"); got.Category != "work" { + t.Fatalf("subtask row should inherit the parent's category: %+v", got) + } + + // uncategorised row under a category filter: the filter rule still applies + m = model{input: textinput.New(), categories: []string{"personal"}, filter: "personal", + items: []todo.Item{{Text: "personal admin"}}} // matches by text, has no category + m = drive(m, "a") + m.input.SetValue("book flights") + m = drive(m, "enter") + if got := lastByText(m, "book flights"); got.Category != "personal" { + t.Fatalf("uncategorised row should fall back to the filter: %+v", got) + } +} + func lastByText(m model, text string) todo.Item { for i := len(m.items) - 1; i >= 0; i-- { if m.items[i].Text == text { @@ -530,6 +573,181 @@ func TestSubtaskDetail(t *testing.T) { } } +// TestTagView checks the tag view groups by an item's first tag (untagged last), +// carries the item's remaining tags on the row, and that the row's right slot is +// the subtask progress when there is any, else the due label. +func TestTagView(t *testing.T) { + pinToday(t, "2026-07-10") + m := model{input: textinput.New(), note: textarea.New(), w: 70, height: 30, + view: viewTag, items: []todo.Item{ + {Text: "bare task"}, + {Text: "renew passport", Tags: []string{"admin"}, Due: "2026-07-20"}, + {Text: "wire the webhook", Tags: []string{"api", "docs"}, + Subs: []todo.Item{{Text: "parse", Done: true}, {Text: "test"}}}, + }} + m.resort() + if m.items[0].Text != "renew passport" || m.items[2].Text != "bare task" { + t.Fatalf("tag sort wrong (admin, api, untagged last): %+v", m.items) + } + id, label := m.groupOf(m.items[1]) + if id != "tapi" || label != "#api" { + t.Fatalf("tag group wrong: %q %q", id, label) + } + if id, label := m.groupOf(m.items[2]); id != "t\x01" || label != "untagged" { + t.Fatalf("untagged group wrong: %q %q", id, label) + } + + out := ansi.Strip(m.listView()) + if !strings.Contains(out, "#admin") || !strings.Contains(out, "untagged") { + t.Fatalf("tag headers missing:\n%s", out) + } + // the grouping tag lives in the header; the item's other tags ride the row + if !strings.Contains(out, "wire the webhook #docs") { + t.Fatalf("row missing its remaining tags:\n%s", out) + } + // right slot: progress for the item with subtasks, due label for the one without + if !strings.Contains(out, "1/2") { + t.Fatalf("row missing subtask progress:\n%s", out) + } + if !strings.Contains(out, "due 10d") { + t.Fatalf("row missing due label:\n%s", out) + } + // status is the box glyph, never a word; priority is the complement label here + m.items[1].Status, m.items[1].Prio = "in-progress", 'H' + out = ansi.Strip(m.listView()) + if strings.Contains(out, "in-progress") { + t.Fatalf("row should not spell out the status:\n%s", out) + } + if !strings.Contains(out, "◐") { + t.Fatalf("named status should show as ◐:\n%s", out) + } + if !strings.Contains(out, "high") { + t.Fatalf("tag view should carry the priority complement:\n%s", out) + } + + // only the tag view annotates the row; category view leaves the text alone + m.view = viewCategory + if strings.Contains(ansi.Strip(m.listView()), "wire the webhook #docs") { + t.Fatal("tags should ride the row in the tag view only") + } +} + +// TestRowComplement checks each view carries the axis its headers don't show: +// the category view shows priority on the right, the priority view the category. +func TestRowComplement(t *testing.T) { + m := model{input: textinput.New(), note: textarea.New(), w: 60, height: 20, + items: []todo.Item{{Text: "ship it", Prio: 'H', Category: "work"}}} + + m.view = viewCategory + out := ansi.Strip(m.listView()) + if !strings.Contains(out, "high") { + t.Fatalf("category view should show the priority on the row:\n%s", out) + } + m.view = viewPriority + out = ansi.Strip(m.listView()) + if !strings.Contains(out, "high priority") { // the group header + t.Fatalf("priority view lost its header:\n%s", out) + } + if !strings.Contains(m.rowContent(m.items[0], " ", "", false), "work") { + t.Fatal("priority view should show the category on the row") + } + if strings.Contains(ansi.Strip(m.rowContent(m.items[0], " ", "", false)), "high") { + t.Fatal("priority view should not repeat the priority the header already names") + } + // nothing to say = nothing rendered + bare := model{input: textinput.New(), w: 60, view: viewCategory} + if got := bare.rowComplement(todo.Item{Text: "x"}); got != "" { + t.Fatalf("no priority should render nothing, got %q", got) + } +} + +// TestTagEditor drives T from the list and from the detail view: it edits the +// whole set, accepts spaces or commas, clears on empty, and a field editor opened +// from the detail view returns there rather than to the list. +func TestTagEditor(t *testing.T) { + m := model{input: textinput.New(), note: textarea.New(), w: 60, height: 20, + items: []todo.Item{{Text: "wire the webhook"}}} + + m = drive(m, "T") + if m.mode != modeTags { + t.Fatalf("T did not open the tag editor: mode=%d", m.mode) + } + m.input.SetValue("API, docs api") + m = drive(m, "enter") + if got := m.items[0].Tags; len(got) != 2 || got[0] != "api" || got[1] != "docs" { + t.Fatalf("tags not saved/normalised: %+v", got) + } + if m.mode != modeList { + t.Fatalf("editor should return to the list: mode=%d", m.mode) + } + + // from the detail view: T opens the editor and saving lands back on detail + m = drive(m, "d") + m = drive(m, "T") + if m.mode != modeTags || !m.fieldEdit { + t.Fatalf("T from detail did not open the editor: mode=%d fieldEdit=%v", m.mode, m.fieldEdit) + } + m.input.SetValue("ops") + m = drive(m, "enter") + if m.mode != modeDetail || m.fieldEdit { + t.Fatalf("save should return to detail: mode=%d fieldEdit=%v", m.mode, m.fieldEdit) + } + if got := m.items[0].Tags; len(got) != 1 || got[0] != "ops" { + t.Fatalf("detail edit did not save: %+v", got) + } + + // while editing, the item stays on screen — prompt in the detail footer, not + // the list's + m = drive(m, "T") + m.input.SetValue("ops") + if out := ansi.Strip(m.View()); !strings.Contains(out, "(tags: enter=save esc=cancel)") || + !strings.Contains(out, "task") || strings.Contains(out, "j/k") { + t.Fatalf("field editor should render over the detail view:\n%s", out) + } + m = drive(m, "enter") + + // esc from a detail-opened editor also returns to detail, unchanged + m = drive(m, "T") + m.input.SetValue("nope") + m = drive(m, "esc") + if m.mode != modeDetail || len(m.items[0].Tags) != 1 { + t.Fatalf("esc should return to detail without saving: mode=%d %+v", m.mode, m.items[0].Tags) + } + + // an immediate mutation (priority) stays on the detail view + m = drive(m, "h") + if m.mode != modeDetail || m.items[0].Prio != 'H' { + t.Fatalf("h from detail should set priority and stay: mode=%d prio=%c", m.mode, m.items[0].Prio) + } + // and the footer is a labelled key grid like the list's + out := ansi.Strip(m.detailView()) + for _, want := range []string{"fields", "dates", "item", "prio", "tags", "status"} { + if !strings.Contains(out, want) { + t.Fatalf("detail footer grid missing %q:\n%s", want, out) + } + } + + // empty clears + m = drive(m, "esc", "T") + m.input.SetValue("") + m = drive(m, "enter") + if len(m.items[0].Tags) != 0 { + t.Fatalf("empty input should clear tags: %+v", m.items[0].Tags) + } +} + +func TestDetailShowsTags(t *testing.T) { + m := model{input: textinput.New(), note: textarea.New(), w: 60, height: 24, + mode: modeDetail, items: []todo.Item{{Text: "task", Tags: []string{"api", "docs"}}}} + if !strings.Contains(ansi.Strip(m.detailView()), "#api #docs") { + t.Fatalf("detail view missing tags:\n%s", m.detailView()) + } + m.items[0].Tags = nil + if !strings.Contains(ansi.Strip(m.detailView()), "tags") { + t.Fatal("detail view should keep the tags row when empty") + } +} + func TestDetailNoteWraps(t *testing.T) { long := "this is a long note that should wrap onto several lines in detail" m := model{input: textinput.New(), w: 30, height: 24, mode: modeDetail, @@ -718,6 +936,10 @@ func TestViewToggle(t *testing.T) { if m.view != viewPriority || m.items[0].Text != "b" { t.Fatalf("priority view sort wrong: view=%d %+v", m.view, m.items) } + m = drive(m, "v") + if m.view != viewTag { + t.Fatalf("tag view expected after priority: %d", m.view) + } m = drive(m, "v", "v") if m.view != viewCategory { t.Fatalf("view did not cycle back: %d", m.view) @@ -1032,9 +1254,21 @@ func TestGlobalReadOnly(t *testing.T) { t.Fatal("space toggled done in read-only global view") } - // v cycles through all 4 modes back to board - if got := drive(m, "v", "v", "v", "v"); got.view != viewBoard { - t.Fatalf("v cycle did not return to board after 4 steps: %v", got.view) + // v cycles through every mode back to board + if got := drive(m, "v", "v", "v", "v", "v"); got.view != viewBoard { + t.Fatalf("v cycle did not return to board after %d steps: %v", viewCountGlobal, got.view) + } + + // the footer grid shows F and A as live here, so they must actually work: F + // toggles the footer, A leaves the aggregate the same way it entered it + if got := drive(m, "F"); !got.hideFooter { + t.Fatal("F did not toggle the footer in the global view") + } + dir := t.TempDir() // toggling out rebuilds the board from disk + t.Setenv("SHEPHERD_TODO_FILE", filepath.Join(dir, "todo.md")) + t.Setenv("SHEPHERD_CONFIG", filepath.Join(dir, "config.toml")) + if got := drive(m, "A"); got.global { + t.Fatal("A should toggle back to the focused board") } // items group by source; header id/label is the board name diff --git a/internal/tui/update.go b/internal/tui/update.go index f50364b..2c49372 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -71,6 +71,18 @@ func (m model) filterCategory() string { return "" } +// addCategory is the category a new item inherits: the selected row's, so an +// item added over a categorised one joins that group instead of the +// uncategorised tail, else a category filter's (which would otherwise hide the +// new item). An explicit @category in the input beats both. idx is the selected +// row's item index, -1 when no row is selected. +func (m model) addCategory(idx int) string { + if idx >= 0 && m.items[idx].Category != "" { + return m.items[idx].Category + } + return m.filterCategory() +} + // visible returns the item indices matching the current filter, in order. func (m model) visible() []int { idx := make([]int, 0, len(m.items)) @@ -137,6 +149,15 @@ func (m *model) rowPtr(r rowRef) *todo.Item { return &m.items[r.item].Subs[r.sub] } +// prompt opens the shared text input in mode, seeded with value and showing +// placeholder — the single entry point for every field editor and picker prompt. +func (m *model) prompt(md mode, value, placeholder string) { + m.mode = md + m.input.SetValue(value) + m.input.Placeholder = placeholder + m.input.Focus() +} + // sameItem matches two items by their (near-unique) identity fields, ignoring // Subs/order, so the cursor can be re-placed after a resort. Item has a slice // field and is not comparable, so == can't be used. @@ -241,7 +262,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var res tea.Model var cmd tea.Cmd switch m.mode { - case modeAdd, modeAddSub, modeEdit, modeCategory, modeDue, modeDefer, modeLink, modeFilter, modeBoardRename, modeBoardNew, modeBoardDir: + case modeAdd, modeAddSub, modeEdit, modeCategory, modeTags, modeDue, modeDefer, modeLink, modeFilter, modeBoardRename, modeBoardNew, modeBoardDir: res, cmd = m.updateInput(msg) case modeConfirmDelete: res, cmd = m.updateConfirmDelete(msg) @@ -285,8 +306,10 @@ func (m model) updateGlobal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": return m.quit() - case "esc": + case "esc", "A": // A toggles both ways, like it does on a board m.toggleGlobal() + case "F": + m.hideFooter = !m.hideFooter case "j", "down": if m.cursor < len(rows)-1 { m.cursor++ @@ -301,7 +324,7 @@ func (m model) updateGlobal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if has { cur = m.items[m.sel()] } - m.view = (m.view + 1) % 4 + m.view = (m.view + 1) % viewMode(viewCountGlobal) m.resort() if has { m.place(cur) @@ -322,10 +345,7 @@ func (m model) updateGlobal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "?": m.mode = modeHelp case "/": - m.mode = modeFilter - m.input.SetValue(m.filter) - m.input.Placeholder = "filter" - m.input.Focus() + m.prompt(modeFilter, m.filter, "filter") } return m, nil } @@ -399,10 +419,7 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "S": if idx >= 0 { - m.mode = modeAddSub - m.input.SetValue("") - m.input.Placeholder = "subtask text !h|!m|!l due:tomorrow" - m.input.Focus() + m.prompt(modeAddSub, "", "subtask text !h|!m|!l due:tomorrow") } case "x": if idx >= 0 { @@ -492,7 +509,7 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if has { cur = m.items[idx] } - m.view = (m.view + 1) % 3 + m.view = (m.view + 1) % viewMode(viewCount) m.resort() if has { m.place(cur) @@ -505,56 +522,40 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "?": m.mode = modeHelp case "/": - m.mode = modeFilter - m.input.SetValue(m.filter) - m.input.Placeholder = "filter" - m.input.Focus() + m.prompt(modeFilter, m.filter, "filter") case "esc": m.filter = "" m.clamp() case "a": - m.mode = modeAdd - m.input.SetValue("") - m.input.Placeholder = "todo text @category !h|!m|!l due:tomorrow" - m.input.Focus() + m.prompt(modeAdd, "", "todo text @category !h|!m|!l due:tomorrow") case "u": if idx >= 0 { - m.mode = modeEdit - m.input.SetValue(m.rowText(ref)) - m.input.Placeholder = "" - m.input.Focus() + m.prompt(modeEdit, m.rowText(ref), "") } case "g": if idx >= 0 && ref.sub == -1 { // field editors are parent-level - m.mode = modeCategory - m.input.SetValue(m.items[idx].Category) - m.input.Placeholder = "category" + ph := "category" if len(m.categories) > 0 { - m.input.Placeholder = "category · tab: " + strings.Join(m.categories, "/") + ph = "category · tab: " + strings.Join(m.categories, "/") } m.catIdx = 0 - m.input.Focus() + m.prompt(modeCategory, m.items[idx].Category, ph) + } + case "T": + if idx >= 0 && ref.sub == -1 { // field editors are parent-level + m.prompt(modeTags, strings.Join(m.items[idx].Tags, " "), "tags, space- or comma-separated") } case "t": if idx >= 0 { - m.mode = modeDue - m.input.SetValue(m.rowItem(ref).Due) - m.input.Placeholder = "today · tomorrow · 3d · 2w · 5m · 1y · DD-MM-YYYY" - m.input.Focus() + m.prompt(modeDue, m.rowItem(ref).Due, "today · tomorrow · 3d · 2w · 5m · 1y · DD-MM-YYYY") } case "s": if idx >= 0 { - m.mode = modeDefer - m.input.SetValue(m.rowItem(ref).Defer) - m.input.Placeholder = "start/defer: today · tomorrow · 3d · 2w · DD-MM-YYYY" - m.input.Focus() + m.prompt(modeDefer, m.rowItem(ref).Defer, "start/defer: today · tomorrow · 3d · 2w · DD-MM-YYYY") } case "L": if idx >= 0 { - m.mode = modeLink - m.input.SetValue(m.rowItem(ref).Link) - m.input.Placeholder = "link (url)" - m.input.Focus() + m.prompt(modeLink, m.rowItem(ref).Link, "link (url)") } case "o": if idx >= 0 { @@ -593,10 +594,20 @@ func (m *model) enterBoards() { m.mode = modeBoards } +// liveOnlyKeys are the picker actions that need a board sitting in its normal +// place — inert in the archived view. +var liveOnlyKeys = map[string]bool{"enter": true, "d": true, "a": true, "r": true, "x": true, "A": true} + // updateBoards handles keys in the board picker: navigate, enter to jump to // the selected board (flushing any unsaved edits first), esc to return. func (m model) updateBoards(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { + key := msg.String() + // A stashed board has no file in place, so the archived view is unarchive-only: + // nothing to open, inspect, rename, archive or delete. + if m.projArchived && liveOnlyKeys[key] { + return m, nil + } + switch key { case "q": return m.quit() case "esc": @@ -637,9 +648,6 @@ func (m model) updateBoards(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } case "enter": - if m.projArchived { // archived boards aren't loadable; unarchive first - break - } if len(m.projRows) == 0 { m.mode = modeList return m, nil @@ -657,44 +665,23 @@ func (m model) updateBoards(msg tea.KeyMsg) (tea.Model, tea.Cmd) { nm.clamp() return nm, nil case "d": // detail view for the selected board (name, dir, paths, counts) - if m.projArchived { - break - } if m.selectedBoard() != nil { m.projNotice = "" m.mode = modeBoardDetail } case "a": // create a new board - if m.projArchived { - break - } m.projNotice = "" - m.mode = modeBoardNew - m.input.SetValue("") - m.input.Placeholder = "new board name" - m.input.Focus() + m.prompt(modeBoardNew, "", "new board name") case "r": // rename the selected board (not the default) - if m.projArchived { - break - } if b := m.selectedBoard(); b != nil && b.Name != "default" { m.projNotice = "" - m.mode = modeBoardRename - m.input.SetValue(b.Name) - m.input.Placeholder = "new board name" - m.input.Focus() + m.prompt(modeBoardRename, b.Name, "new board name") } case "x": // delete the selected board (confirmed) - if m.projArchived { - break - } if b := m.selectedBoard(); b != nil && b.Name != "default" { m.mode = modeConfirmDelete } case "A": // archive the selected board into boards/archived/ - if m.projArchived { - break - } if b := m.selectedBoard(); b != nil && b.Name != "default" { if err := store.ArchiveBoard(b.Name); err != nil { m.projNotice = err.Error() @@ -718,10 +705,7 @@ func (m model) updateBoardDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if b := m.selectedBoard(); b != nil { m.projPending = b.Name m.projDirEdit = true - m.mode = modeBoardDir - m.input.SetValue(store.BoardDir(b.Name)) - m.input.Placeholder = "working dir (empty to clear)" - m.input.Focus() + m.prompt(modeBoardDir, store.BoardDir(b.Name), "working dir (empty to clear)") } } return m, nil @@ -818,10 +802,7 @@ func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case 0, 1, 5: m.cycleSetting() // enum/bool rows cycle in place default: - m.mode = modeSettingEdit - m.input.SetValue(m.settingValue(m.settingsCur)) - m.input.Placeholder = m.settingPlaceholder(m.settingsCur) - m.input.Focus() + m.prompt(modeSettingEdit, m.settingValue(m.settingsCur), m.settingPlaceholder(m.settingsCur)) } } return m, nil @@ -830,8 +811,8 @@ func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // cycleSetting advances an enum row (view or density) and persists. func (m *model) cycleSetting() { switch m.settingsCur { - case 0: // view: category -> priority -> table - m.view = (m.view + 1) % 3 + case 0: // view: category -> priority -> tag -> table + m.view = (m.view + 1) % viewMode(viewCount) m.resort() m.clamp() m.saveSettings() @@ -902,26 +883,10 @@ func (m *model) applySettingText(idx int, v string) { m.autosaveEvery = time.Duration(n) * time.Second } case 3: - m.categories = parseCommaList(v, false) + m.categories = store.ParseList(v, false) case 4: - m.statuses = normalizeStatuses(parseCommaList(v, true)) - } -} - -// parseCommaList splits a comma-separated list, trimming blanks; lower lowercases -// each entry (used for statuses). -func parseCommaList(v string, lower bool) []string { - var out []string - for _, p := range strings.Split(v, ",") { - s := strings.TrimSpace(p) - if lower { - s = strings.ToLower(s) - } - if s != "" { - out = append(out, s) - } + m.statuses = normalizeStatuses(store.ParseList(v, true)) } - return out } // enterArchive opens the read-only archive browser. On a board board it shows @@ -979,6 +944,27 @@ func (m model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// afterFieldEdit is the mode to land in once a field editor closes: the item +// detail view when the editor was opened from there (mirroring projDirEdit for +// board dirs), else the list. It consumes the flag. +func (m *model) afterFieldEdit() mode { + if m.fieldEdit { + m.fieldEdit = false + return modeDetail + } + return modeList +} + +// detailFieldKeys are the list's field editors reused from the detail view, so +// the item can be changed while you're looking at it. They're delegated to +// updateList rather than reimplemented — it already guards parent-only fields and +// resorts/re-places the cursor. Keys the detail view handles itself (n, space, o, +// esc, q) never reach here; x/delete is deliberately list-only. +var detailFieldKeys = map[string]bool{ + "u": true, "g": true, "T": true, "t": true, "s": true, "L": true, + "h": true, "m": true, "l": true, "tab": true, +} + func (m model) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { ref := m.selRef() if ref.item < 0 { @@ -1016,6 +1002,17 @@ func (m model) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "o": return m, openLink(p.Link) + default: + if detailFieldKeys[msg.String()] && !m.global { + m.fieldEdit = true // save/cancel comes back here, not to the list + res, cmd := m.updateList(msg) + nm := res.(model) + if !nm.input.Focused() { // an immediate mutation (prio, status): stay put + nm.mode = modeDetail + nm.fieldEdit = false + } + return nm, cmd + } } return m, nil } @@ -1072,7 +1069,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = modeBoards } default: - m.mode = modeList + m.mode = m.afterFieldEdit() } return m, nil case "enter": @@ -1081,7 +1078,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case modeAdd: if it := todo.ParseQuickAdd(v); it.Text != "" { if it.Category == "" { - it.Category = m.filterCategory() + it.Category = m.addCategory(idx) } m.beforeMutate() m.items = append(m.items, it) @@ -1118,6 +1115,18 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.place(cur) } m.mode = modeList + case modeTags: + if idx >= 0 { + m.beforeMutate() + cur := m.items[idx] + // accept spaces as separators too; ParseTags lowercases, drops a + // leading #, and dedups. Empty clears. + cur.Tags = todo.ParseTags(strings.ReplaceAll(v, " ", ",")) + m.items[idx] = cur + m.resort() // tags are the grouping axis in the tag view + m.place(cur) + } + m.mode = modeList case modeDue: if idx >= 0 { m.beforeMutate() @@ -1175,10 +1184,7 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // board made — ask for its working dir before returning to the picker. m.projPending = v - m.mode = modeBoardDir - m.input.SetValue("") - m.input.Placeholder = "working dir (optional, enter to skip)" - m.input.Focus() + m.prompt(modeBoardDir, "", "working dir (optional, enter to skip)") return m, nil case modeBoardDir: m.input.Blur() @@ -1205,6 +1211,9 @@ func (m model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.input.Blur() + if m.mode == modeList { // a field editor just saved; the detail view gets it back + m.mode = m.afterFieldEdit() + } return m, nil } var cmd tea.Cmd diff --git a/internal/tui/view.go b/internal/tui/view.go index dbb4445..f43eea3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -35,6 +35,17 @@ var ( prioLabel = map[byte]string{'H': "high", 'M': "medium", 'L': "low"} ) +// spread lays left flush-left and right flush-right across w columns, padding +// the middle (always at least one space, so a too-narrow pane still separates +// them). Measured with lipgloss.Width, so styled input counts its glyphs only. +func spread(w int, left, right string) string { + gap := w - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + right +} + func (m model) View() string { var content string switch { @@ -48,7 +59,9 @@ func (m model) View() string { content = m.boardsView() case m.mode == modeSettings || m.mode == modeSettingEdit: content = m.settingsView() - case m.mode == modeDetail || m.mode == modeNote: + case m.mode == modeDetail || m.mode == modeNote || m.fieldEdit: + // fieldEdit means an editor was opened from the detail view, so the item + // stays on screen with the prompt in its footer instead of the list's. content = m.detailView() case m.view == viewTable: content = m.tableView() @@ -99,6 +112,12 @@ func (m model) groupOf(it todo.Item) (id, label string) { } return "p9", "no priority" } + if m.view == viewTag { + if tag := todo.TagKey(it); tag != "" { + return "t" + tag, "#" + tag + } + return "t\x01", "untagged" + } if it.Category == "" { return "c\x01", "uncategorized" } @@ -134,6 +153,15 @@ func (m model) groupCount(it todo.Item) (done, total int) { } } } + case m.view == viewTag: + for _, x := range m.items { + if !todo.Pinned(x) && todo.TagKey(x) == todo.TagKey(it) { + total++ + if x.Done { + done++ + } + } + } default: for _, x := range m.items { if !todo.Pinned(x) && x.Category == it.Category { @@ -147,9 +175,28 @@ func (m model) groupCount(it todo.Item) (done, total int) { return } -// rowContent renders one item's row (box + text + flush-right due/prio/status -// cluster) at the given indent, with an optional subtask-progress badge after -// the text. Cursor highlight is applied by the caller. +// rowComplement is the flush-far-right label: the grouping axis the active view +// does *not* show in its headers. The priority view groups by priority, so its +// rows carry the category; every other view carries the priority. Empty when the +// item has no value for it. +func (m model) rowComplement(it todo.Item) string { + if m.view == viewPriority { + if it.Category == "" { + return "" + } + return catStyle.Render(it.Category) + } + if lbl, ok := prioLabel[it.Prio]; ok { + return prioStyles[it.Prio].Render(lbl) + } + return "" +} + +// rowContent renders one item's row at the given indent: the box (its shape the +// status — ○ open, ◐ named status, ✓ done), the text, then up to two flush-right +// values — the subtask progress or due/defer label, and the complement axis +// (see rowComplement) pinned far right. badge is the item's subtask progress, or +// "". Cursor highlight is applied by the caller. func (m model) rowContent(it todo.Item, indent, badge string, isSub bool) string { w := m.width() box := "○" @@ -168,14 +215,23 @@ func (m model) rowContent(it todo.Item, indent, badge string, isSub bool) string if m.global && m.view != viewBoard && it.Source != "" { text += " " + dimStyle.Render("["+it.Source+"]") } - // right cluster: due (left) then priority label flush far-right. - // Overdue rows live under the ⚠ overdue group, so don't repeat "overdue" on the line. + // In the tag view the group header names the grouping tag; show the item's + // other tags after the text so a multi-tag item isn't misread as single-tag. + if m.view == viewTag && len(it.Tags) > 1 { + text += " " + dimStyle.Render("#"+strings.Join(it.Tags[1:], " #")) + } + // Progress/due slot: one value, so the column lines up row to row — subtask + // progress when the item has subtasks, else the due/defer label. Nothing + // urgent hides behind a badge: overdue parents are pinned to the ⚠ overdue group. label := "" - if deferred { + switch { + case badge != "": + label = countStyle.Render(badge) + case deferred: if lbl := todo.DeferLabel(it.Defer); lbl != "" { label = dimStyle.Render(lbl) } - } else if it.Due != "" && (isSub || !todo.Pinned(it)) { + case it.Due != "" && (isSub || !todo.Pinned(it)): // parents hide the label when pinned to the ⚠ overdue group; subs have no // such group, so always show it (red when overdue). lbl, over := todo.DueLabel(it.Due) @@ -185,32 +241,14 @@ func (m model) rowContent(it todo.Item, indent, badge string, isSub bool) string } label = st.Render(lbl) } - if badge != "" { // subtask progress, flush-right just left of priority - if label != "" { - label += " " - } - label += countStyle.Render(badge) - } - if lbl, ok := prioLabel[it.Prio]; ok { + if c := m.rowComplement(it); c != "" { // pinned far right, past the progress/due slot if label != "" { label += " " } - label += prioStyles[it.Prio].Render(lbl) - } - if it.Status != "" { // intermediate status, flush-right ahead of due/prio - s := progStyle.Render(it.Status) - if label != "" { - label = s + " " + label - } else { - label = s - } + label += c } left := fmt.Sprintf("%s%s %s", indent, boxSt.Render(box), text) - gap := w - lipgloss.Width(left) - lipgloss.Width(label) - if gap < 1 { - gap = 1 - } - return left + strings.Repeat(" ", gap) + label + return spread(w, left, label) } func (m model) listView() string { @@ -237,12 +275,7 @@ func (m model) listView() string { } done, total := m.groupCount(parent) cnt := countStyle.Render(fmt.Sprintf("%d/%d", done, total)) - left := catStyle.Render(label) - gap := w - lipgloss.Width(left) - lipgloss.Width(cnt) - if gap < 1 { - gap = 1 - } - out = append(out, left+strings.Repeat(" ", gap)+cnt) + out = append(out, spread(w, catStyle.Render(label), cnt)) lastGroup = gid } if d, t := todo.SubCount(it); t > 0 { @@ -311,12 +344,7 @@ func (m model) archiveView() string { left := " " + boxStyle.Render("✓") + " " + dimStyle.Render(it.Text) row := left if m.global && it.Source != "" { - tag := catStyle.Render("[" + it.Source + "]") - gap := w - lipgloss.Width(left) - lipgloss.Width(tag) - if gap < 1 { - gap = 1 - } - row = left + strings.Repeat(" ", gap) + tag + row = spread(w, left, catStyle.Render("["+it.Source+"]")) } if i == m.arcCur { cursorLine = len(out) @@ -355,11 +383,7 @@ func (m model) boardsView() string { left = boxStyle.Render("▸ ") + b.Name } cnt := countStyle.Render(fmt.Sprintf("%d/%d", total-open, total)) - gap := w - lipgloss.Width(left) - lipgloss.Width(cnt) - if gap < 1 { - gap = 1 - } - row := left + strings.Repeat(" ", gap) + cnt + row := spread(w, left, cnt) if i == m.projCur { cursorLine = len(out) row = cursorStyle.Width(w).Render(ansi.Strip(row)) @@ -490,11 +514,7 @@ func (m model) settingsView() string { // header without the done/total count that headerWith always appends. left := titleLeft() right := dimStyle.Render("settings") - gap := w - lipgloss.Width(left) - lipgloss.Width(right) - if gap < 1 { - gap = 1 - } - header := left + strings.Repeat(" ", gap) + right + "\n" + ruleStyle.Render(strings.Repeat("┈", w)) + header := spread(w, left, right) + "\n" + ruleStyle.Render(strings.Repeat("┈", w)) rule := ruleStyle.Render(strings.Repeat("┈", w)) footer := rule + "\n" @@ -580,12 +600,7 @@ func (m model) headerWith(context string, done, total int) string { } right = right + " " + save } - gap := w - lipgloss.Width(left) - lipgloss.Width(right) - if gap < 1 { - gap = 1 - } - - return left + strings.Repeat(" ", gap) + right + "\n" + + return spread(w, left, right) + "\n" + ruleStyle.Render(strings.Repeat("┈", w)) } @@ -609,11 +624,13 @@ func osc8(text, url string) string { func (m model) bottomBar() string { left := dimStyle.Render(repoName) right := dimStyle.Render("v" + Version) + // Link the version to its GitHub release; skip when unbuilt ("dev"/"unknown"). + // The link is added after spreading so the invisible OSC 8 bytes don't count + // toward the gap. gap := m.width() - lipgloss.Width(left) - lipgloss.Width(right) if gap < 1 { gap = 1 } - // Link the version to its GitHub release; skip when unbuilt ("dev"/"unknown"). if Version != "dev" && Version != "unknown" && Version != "" { right = osc8(right, repoURL+"/releases/tag/v"+Version) } @@ -627,9 +644,8 @@ func (m model) listFooter() string { switch m.mode { case modeFilter: return rule + "\n" + m.input.View() + " " + dimStyle.Render("(filter: enter=apply esc=clear)") - case modeAdd, modeAddSub, modeEdit, modeCategory, modeDue, modeDefer, modeLink: - verb := map[mode]string{modeAdd: "add", modeAddSub: "subtask", modeEdit: "edit", modeCategory: "category", modeDue: "due", modeDefer: "defer", modeLink: "link"}[m.mode] - return rule + "\n" + m.input.View() + " " + dimStyle.Render("("+verb+": enter=save esc=cancel)") + case modeAdd, modeAddSub, modeEdit, modeCategory, modeTags, modeDue, modeDefer, modeLink: + return rule + "\n" + m.inputPrompt() default: if m.hideFooter { // keep the repo/version line; drop the help grid return rule + "\n" + m.bottomBar() @@ -638,19 +654,28 @@ func (m model) listFooter() string { } } -// helpGrid renders the key hints as labelled sections spread across the full -// width: one column per section, each a header over "key label" rows, with the -// leftover width shared as gaps so the block spans the whole pane. +// inputPrompt is the active field editor's line: the text input and what enter +// and esc will do. Shared by the list and detail footers, so an editor opened +// from either place reads the same. +func (m model) inputPrompt() string { + verb := map[mode]string{modeAdd: "add", modeAddSub: "subtask", modeEdit: "edit", modeCategory: "category", modeTags: "tags", modeDue: "due", modeDefer: "defer", modeLink: "link"}[m.mode] + return m.input.View() + " " + dimStyle.Render("("+verb+": enter=save esc=cancel)") +} + +// keyCol is one labelled column of a footer key grid: a header over its +// {key, label} hints. +type keyCol struct { + head string + entries [][2]string +} + +// helpGrid is the list footer's key hints. func (m model) helpGrid() string { - type entry struct{ key, label string } - cols := []struct { - head string - entries []entry - }{ - {"move", []entry{{"j/k", "move"}, {"space", "toggle"}, {"d", "detail"}, {"v", "view"}, {"A", "global"}, {"e", "archive"}, {"b", "boards"}, {"F", "footer"}}}, - {"edit", []entry{{"a", "add"}, {"S", "sub"}, {"u", "edit"}, {"tab", "status"}, {"x", "del"}, {"c", "sweep"}, {"C", "arch"}}}, - {"fields", []entry{{"h/m/l", "prio"}, {"g", "cat"}, {"t", "due"}, {"s", "defer"}, {"L", "link"}, {"o", "open"}}}, - {"board", []entry{{"w", "save"}, {"^e", "editor"}, {"U", "undo"}, {"^r", "redo"}, {"/", "filter"}, {",", "settings"}, {"?", "help"}, {"q", "quit"}}}, + cols := []keyCol{ + {"move", [][2]string{{"j/k", "move"}, {"space", "toggle"}, {"d", "detail"}, {"v", "view"}, {"A", "global"}, {"e", "archive"}, {"b", "boards"}, {"F", "footer"}}}, + {"edit", [][2]string{{"a", "add"}, {"S", "sub"}, {"u", "edit"}, {"tab", "status"}, {"x", "del"}, {"c", "sweep"}, {"C", "arch"}}}, + {"fields", [][2]string{{"h/m/l", "prio"}, {"g", "cat"}, {"T", "tags"}, {"t", "due"}, {"s", "defer"}, {"L", "link"}, {"o", "open"}}}, + {"board", [][2]string{{"w", "save"}, {"^e", "editor"}, {"U", "undo"}, {"^r", "redo"}, {"/", "filter"}, {",", "settings"}, {"?", "help"}, {"q", "quit"}}}, } // In the read-only global view most actions are inert; dim them so only the @@ -662,29 +687,58 @@ func (m model) helpGrid() string { // Due / defer / link / status all work on subtasks. `o` opens the link, so dim // it too when this subtask has none. onSub := !m.global && m.selRef().sub >= 0 - subInert := map[string]bool{"g": true, "C": true} + subInert := map[string]bool{"g": true, "T": true, "C": true} if onSub && m.rowItem(m.selRef()).Link == "" { subInert["o"] = true } + return m.keyGrid(cols, func(key string) bool { + return (m.global && !globalActive[key]) || (onSub && subInert[key]) + }) +} + +// detailGrid is the detail view's footer, laid out like the list's: the keys +// that act on the one item on screen, grouped under the same kind of headers. +func (m model) detailGrid() string { + cols := []keyCol{ + {"fields", [][2]string{{"u", "text"}, {"h/m/l", "prio"}, {"g", "cat"}, {"T", "tags"}}}, + {"dates", [][2]string{{"t", "due"}, {"s", "defer"}}}, + {"item", [][2]string{{"n", "note"}, {"L", "link"}, {"tab", "status"}, {"space", "toggle"}}}, + {"go", [][2]string{{"o", "open link"}, {"esc", "back"}, {"q", "quit"}}}, + } + // Same inert rules as the list footer: the global aggregate is read-only, and + // category/tags are parent-only on a subtask. + globalActive := map[string]bool{"o": true, "esc": true, "q": true} + onSub := !m.global && m.selRef().sub >= 0 + subInert := map[string]bool{"g": true, "T": true} + return m.keyGrid(cols, func(key string) bool { + return (m.global && !globalActive[key]) || (onSub && subInert[key]) + }) +} + +// keyGrid renders labelled columns of key hints spread across the full width: +// one column per section, each a header over "key label" rows, with the leftover +// width shared as gaps so the block spans the whole pane. dim reports the keys +// that do nothing in the current context, which render faint. +func (m model) keyGrid(cols []keyCol, dim func(key string) bool) string { rows := 0 rendered := make([][]string, len(cols)) widths := make([]int, len(cols)) for i, c := range cols { keyW := 0 for _, e := range c.entries { - if len(e.key) > keyW { - keyW = len(e.key) + if len(e[0]) > keyW { + keyW = len(e[0]) } } lines := []string{catStyle.Render(c.head)} w := lipgloss.Width(lines[0]) for _, e := range c.entries { - key := fmt.Sprintf("%-*s", keyW, e.key) - if (m.global && !globalActive[e.key]) || (onSub && subInert[e.key]) { + key := fmt.Sprintf("%-*s", keyW, e[0]) + if dim(e[0]) { key = dimStyle.Render(key) } - line := key + " " + dimStyle.Render(e.label) + line := key + " " + dimStyle.Render(e[1]) if lw := lipgloss.Width(line); lw > w { w = lw } @@ -815,18 +869,20 @@ func (m model) helpBody() []string { } blank := func() { out = append(out, "") } - line("An interactive todo board in a herdr pane, backed by a plain markdown file. Changes save on quit, autosave after a short idle pause, or on demand with w; the header shows ● unsaved / ● saved. The board reloads external edits automatically when you have nothing unsaved.") + line("An interactive todo board backed by a plain markdown file. Runs standalone in any terminal, or as a herdr plugin pane. Changes save on quit, autosave after a short idle pause, or on demand with w; the header shows ● unsaved / ● saved. The board reloads external edits automatically when you have nothing unsaved.") blank() sec("adding") - line("a — add. Inline syntax: text @category !h|!m|!l due:tomorrow defer:3d link:https://…") + line("a — add. Inline syntax: text @category #tag (tags:a,b replaces the set) !h|!m|!l due:tomorrow defer:3d link:https://… status:name note:the rest of the line") + line("a new item inherits the selected item's category (then a category filter's), so adding under a group stays in it; an inline @category overrides") line("u — edit the selected item's (or subtask's) text") line("S — add a subtask to the selected item (same !prio / due: syntax)") blank() sec("organise") line("h/m/l — set priority high/medium/low (same key again clears; works on subtasks too)") - line("g — set category · t — set due date · s — set defer/start date") + line("g — set category · T — set tags (space- or comma-separated; empty clears) · t — set due date · s — set defer/start date") line("L — set link · o — open the link in the browser") line("space — toggle done · tab — cycle status · x — delete") + line("rows carry two flush-right values: subtask progress (else the due/defer label), then whichever grouping axis the headers don't already name — priority in the category and tag views, category in the priority view. The box shape is the status (○ open, ◐ named status, ✓ done)") line("c — archive all done items · C — archive the selected item (whole items only, not subtasks)") line("subtasks: completing a parent completes its subtasks; completing the last subtask completes the parent") blank() @@ -834,11 +890,11 @@ func (m model) helpBody() []string { line("today · tomorrow · Nd/Nw/Nm/Ny (e.g. 3d, 2w) · DD-MM-YYYY. Anything unrecognised clears the date. Overdue items are pinned to a group at the top.") blank() sec("view & find") - line("v — cycle view: category / priority / table") - line("/ — filter text, note, category, due (also greps the archive)") + line("v — cycle view: category / priority / tag / table (the global view adds a board grouping). The tag view groups by an item's first tag (untagged last) and shows its other tags on the row") + line("/ — filter text, note, category, tags, due (also greps the archive)") line("A — toggle the read-only global view across all boards (esc to leave)") line("e — browse the archive (read-only; all boards in the global view; esc to leave)") - line("d — detail view · ? — this help") + line("d — detail view; the same field keys work there (u, h/m/l, g, T, t, s, L, tab) and return to it · ? — this help") line("F — hide/show the footer help grid (the repo/version line stays; config: hidefooter = true starts hidden)") blank() sec("history & files") @@ -966,6 +1022,11 @@ func (m model) detailView() string { b.WriteString(field("status", status)) b.WriteString(field("priority", prio)) b.WriteString(field("category", category)) + tags := dimStyle.Render("—") + if len(it.Tags) > 0 { + tags = catStyle.Render("#" + strings.Join(it.Tags, " #")) + } + b.WriteString(field("tags", tags)) if m.global && it.Source != "" { b.WriteString(field("board", catStyle.Render(it.Source))) } @@ -999,15 +1060,20 @@ func (m model) detailView() string { } else if it.Note != "" { b.WriteString(lipgloss.NewStyle().Width(m.width()).Render(it.Note) + "\n") } else { - b.WriteString(dimStyle.Render("(none — press e to add)") + "\n") + b.WriteString(dimStyle.Render("(none — press n to add)") + "\n") } rule := dimStyle.Render(strings.Repeat("─", m.width())) var help string - if m.mode == modeNote { + switch { + case m.mode == modeNote: help = rule + "\n" + dimStyle.Render("note: enter newline · esc done (saves as you type)") - } else { - help = rule + "\n" + dimStyle.Render("n edit note space toggle o open link esc back q quit") + case m.fieldEdit: // editing a field from here: the prompt replaces the grid + help = rule + "\n" + m.inputPrompt() + default: + // the list's field editors work here too and come back here when done, so + // the footer is the list's grid narrowed to one item. + help = rule + "\n" + m.detailGrid() } return m.frame(b.String(), help) } diff --git a/main.go b/main.go index 85c4858..7cdc388 100644 --- a/main.go +++ b/main.go @@ -22,10 +22,8 @@ var pluginManifest string // version reads `version = "x.y.z"` out of the embedded manifest so the binary // and the plugin manifest never drift. func version() string { - for _, ln := range strings.Split(pluginManifest, "\n") { - if k, v, ok := strings.Cut(ln, "="); ok && strings.TrimSpace(k) == "version" { - return strings.Trim(strings.TrimSpace(v), `"`) - } + if v := store.ScanKV(pluginManifest)["version"]; v != "" { + return strings.Trim(v, `"`) } return "unknown" } diff --git a/skills/shepherd/SKILL.md b/skills/shepherd/SKILL.md index 400b68f..930aa18 100644 --- a/skills/shepherd/SKILL.md +++ b/skills/shepherd/SKILL.md @@ -9,7 +9,7 @@ description: > never by hand-editing the file. --- -# Shepherd todo board +# shepherd todo board Shepherd is an installed CLI (`shepherd`) backed by a markdown file. Manage it through the command API — the binary owns the format, so never hand-edit the @@ -28,7 +28,7 @@ Run `shepherd help` for the authoritative command list. Summary: | `shepherd stats --json [--all]` | board metrics (JSON numbers; drop `--json` for charts; `--legend` explains them) | | `shepherd add "" [--json]` | add an item | | `shepherd sub "" [--json]` | add a subtask to an item | -| `shepherd edit "" [--json]` | the single setter — merge @category/!prio/due:/defer:/link:/status:/note:/text onto an item (or subtask); bare key clears, note: takes the rest | +| `shepherd edit "" [--json]` | the single setter — merge @category/#tag/tags:/!prio/due:/defer:/link:/status:/note:/text onto an item (or subtask); bare key clears, note: takes the rest | | `shepherd done ... [--json]` / `undone ...` | (un)complete one or more items/subtasks (shorthand for `edit … status:done`/`status:open`) | | `shepherd rm ... [--dry-run] [--json]` | remove one or more items/subtasks (`--dry-run`/`-n` previews) | | `shepherd archive ... [--json]` | move whole items off the board into `archive.md` (subtasks can't be archived alone) | @@ -53,7 +53,7 @@ Exit codes: `0` success · `2` usage/input error (bad flag, unknown command, unknown ref) · `1` runtime/IO failure. `-q`/`--quiet` drops a mutation's confirmation line, never the requested data. -## Watching +## watching `shepherd watch [--interval ] [--board ]` streams a board's changes as NDJSON (one JSON object per line) until the process is killed — so a @@ -69,7 +69,7 @@ that, each change is one line keyed by the item's stable `id`: Detection is mtime polling (`--interval`, default `1s`); it's read-only, so it never blocks a writer. -## Subtasks +## subtasks Items can hold one level of subtasks. `shepherd sub ""` adds one (same quick-add tokens as `add`). Address a subtask by its own id, or as `n.m` @@ -78,7 +78,7 @@ parent completes its subtasks, and completing the last subtask completes the parent. `list --json` nests them under each item's `subtasks` array (each with a 1-based `index` within the parent). `stats` counts top-level items only. -## Boards +## boards Each board has its own file: `--board ` (or `$SHEPHERD_BOARD`) targets `~/.config/shepherd/boards/.md`; with no board you're on the @@ -90,22 +90,29 @@ indexes are aggregate, **not** valid for `done`/`rm`. To act on an item you found via `--all`, mutate with the same `--board` as its board — the item's `id` works directly, or re-list that board for its local index. -## Adding +## adding `add` accepts quick-add tokens in the text: `shepherd add "renew passport @home !h due:+2w defer:1w link:https://gov.uk"` -- `@category` · `!h`/`!m`/`!l` priority · `due:` +- `@category` (one grouping label) · `#tag` adds a tag, `tags:` replaces the + whole set (bare `tags:` or `#` clears) · `!h`/`!m`/`!l` priority · + `due:` - `defer:` — start/defer date (item shown but not "started" until then) · `link:` — a reference URL - `status:` — set a status · `note:` — a note (holds spaces, takes the rest of the line, so put it last) `list --json` includes `id` (the stable handle — use it to address the item), -`completed` (timestamp set when an item is marked done), `defer`, `link`, and -`status` per item. +`completed` (timestamp set when an item is marked done), `defer`, `link`, `tags`, +and `status` per item. -## Statuses +`category` and `tags` are separate axes: one category per item (it drives board +grouping and order), any number of tags (no ordering effect). `#tag` only adds — +drop one by re-setting the set with `tags:`. `list --filter ` matches +tags too. + +## statuses Items carry a status. `done` is terminal (`done`/`undone`, or `[x]` on disk). Between open and done there can be named intermediate statuses (e.g. @@ -121,7 +128,7 @@ Set a status with `shepherd edit "status:"` — any name is accepted the two terminal ends. In the interactive board, `tab` cycles through the configured list. -## Notes +## notes - Data file: `todo.md` under `$XDG_CONFIG_HOME/shepherd/` (defaults to `~/.config/shepherd/`), or `boards/.md` there when a board is