From e8603f3ffacd0b0367d13f9129ab2d4d5d308185 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Tue, 29 Sep 2026 14:23:30 -0400 Subject: [PATCH 01/11] Act on board items from the viewer: tick, answer, send to Done The viewer selects an item with ] and [, then writes one line change back: x ticks a checklist item, 1-9/y/n/d answer an Ask: prompt, and d moves an item to Done. M turns on mouse mode for clicks. Writes locate the item by exact text and swap the file in by rename. sidecar diff names a tick or an answer so the agent reads the decision on its next turn. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 3 +- board.go | 10 +- ...6-09-29-interactive-line-updates-design.md | 37 +++ edit.go | 237 ++++++++++++++++ edit_test.go | 165 ++++++++++++ interact.go | 253 ++++++++++++++++++ interact_test.go | 140 ++++++++++ main.go | 5 + sections.go | 1 + semdiff.go | 23 +- ui.go | 55 +++- 11 files changed, 924 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-09-29-interactive-line-updates-design.md create mode 100644 edit.go create mode 100644 edit_test.go create mode 100644 interact.go create mode 100644 interact_test.go diff --git a/README.md b/README.md index ca20fcc..72308fb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Boards are per-directory. `sidecar init` refuses a board that is a symlink — t ## What it does - Renders Markdown with [glamour] and re-renders the moment the file changes. Sidecar watches the parent directory with fsnotify and a 100-millisecond debounce, so it handles atomic rename-swaps, deletes, and recreates — and waits quietly when the file doesn’t exist yet. -- Scrolls with `j` and `k`, the arrow keys, `PgUp` and `PgDn`, and `g` and `G` for top and bottom. Sidecar keeps your scroll position across reloads. It doesn’t capture the mouse, so your terminal’s text selection and clickable links keep working. +- Scrolls with `j` and `k`, the arrow keys, `PgUp` and `PgDn`, and `g` and `G` for top and bottom. Sidecar keeps your scroll position across reloads. It doesn’t capture the mouse by default, so your terminal’s text selection and clickable links keep working. +- Acts on items from the keyboard. `]` and `[` select an item; `x` ticks a `- [ ]` item, `1`–`9` (or `y`, `n`, `d`) answer an item's `Ask: yes | no` line, and `d` sends any other item to Done. Each action writes one line change to the board, and the agent's next-turn hook reports it — `edited 🧠: "Ship it" — ticked`, or `— answered "no"`. `M` turns on mouse mode for clicks; press it again to get native text selection back. - `Tab` and `Shift+Tab` move a cursor between sections; `Enter` or `Space` collapses or expands the section under it. `✅ Done` and `📦 Shipped` start collapsed — every heading shows its item count, e.g. `✅ Done (12)`. - Reloads on demand with `r`, and quits with `q`. - Re-renders when you resize the terminal, at the pane width minus 2. It never renders wider than the pane. diff --git a/board.go b/board.go index e76be7d..d2fde92 100644 --- a/board.go +++ b/board.go @@ -113,9 +113,17 @@ func parseBoard(raw string) (Board, bool) { return b, len(b.Sections) > 0 } -// normalizeItem strips the bullet and collapses whitespace on the first line. +// normalizeItem strips the bullet and task-list marker and collapses whitespace on the first line. func normalizeItem(line string) string { s := strings.TrimSpace(line) s = strings.TrimPrefix(s, "- ") + // A task-list marker is state, not identity: ticking an item must read + // as the same item edited, not one removed and another added. + for _, m := range []string{"[ ] ", "[x] ", "[X] "} { + if strings.HasPrefix(s, m) { + s = strings.TrimPrefix(s, m) + break + } + } return strings.Join(strings.Fields(s), " ") } diff --git a/docs/superpowers/specs/2026-09-29-interactive-line-updates-design.md b/docs/superpowers/specs/2026-09-29-interactive-line-updates-design.md new file mode 100644 index 0000000..f14838d --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-interactive-line-updates-design.md @@ -0,0 +1,37 @@ +# Interactive line updates + +The viewer gains one write path: the human acts on a board item and sidecar writes that one line change back. The per-turn hook already diffs the board, so the agent reads the decision on its next turn. + +## Intent + +- The human ticks a checklist item, answers a yes/no/and/or/done prompt, or sends an item to ✅ Done without typing in chat. +- Keyboard first. Native text selection and clickable links stay the default; mouse clicks work only while mouse mode (`M`) is on. + +## Board syntax + +Plain markdown, nothing new to parse beyond two line prefixes. + +- `- [ ]` / `- [x]` — a checklist item. `x` toggles it. +- ` Ask: yes | no | done` — an indented line under an item; options are separated by `|` and can be any words (`and`, `or`, …). +- ` Answer: no` — written directly under the `Ask:` line. A new answer replaces the old one, so an item carries at most one. + +## Keys + +`]` / `[` select the next or previous item. With one selected: `x` ticks; `1`–`9` answer by position; `y` / `n` / `d` answer `yes` / `no` / `done` when offered; `d` otherwise moves the item to ✅ Done; `esc` deselects. `M` toggles mouse mode: a click selects an item, a click on its box ticks it. + +## Writes + +- `editItem` re-reads the file, locates the item by section label and exact item text, applies a pure text transform, and swaps the file in by rename, keeping its mode. +- An edit by the agent elsewhere in the file survives. If the item itself changed since the viewer rendered it, nothing is written and the status bar says so. +- Only three mutations exist: toggle a checkbox, set an answer, move an item to Done. Free-form editing is out of scope. + +## Agent side + +- A task-list marker is state, not identity, so `normalizeItem` strips it: ticking reads as `edited`, not remove-plus-add. +- `sidecar diff` appends `— ticked`, `— unticked`, or `— answered "x"` to the edited line. +- The CLAUDE.md note teaches the `Ask:` / `Answer:` convention through `entryStyleRules`. + +## Known limits + +- The item cursor maps items to rendered lines by their column-0 marker (`•`, `□`, `✓`). A section whose rendered count differs from the parsed count, such as a collapsed section, is skipped rather than mis-selected. +- The rename swap leaves a small window in which a concurrent write by the agent to the same file is lost; the exact-text match narrows it to the same item. diff --git a/edit.go b/edit.go new file mode 100644 index 0000000..c73b520 --- /dev/null +++ b/edit.go @@ -0,0 +1,237 @@ +// edit.go — the viewer's only writes to the board: tick a checklist item, +// answer an Ask: prompt, or move an item to ✅ Done. Every edit is a pure +// text transform on one item, located by section label and exact item text +// in the file as it is on disk right now, so an unrelated edit by the agent +// between render and keypress is preserved and a conflicting one is refused. +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +var errBoardChanged = errors.New("board changed under the cursor — nothing written") + +// checkboxPrefixes are the task-list markers on an item's first line. +const ( + unticked = "- [ ] " + ticked = "- [x] " +) + +// isCheckbox reports whether an item's first line is a task-list item. +func isCheckbox(it BoardItem) bool { + first := firstLine(it.Raw) + return strings.HasPrefix(first, unticked) || strings.HasPrefix(strings.ToLower(first), ticked) +} + +func firstLine(raw string) string { + first, _, _ := strings.Cut(raw, "\n") + return first +} + +// toggleCheckbox flips "- [ ]" and "- [x]" on the item's first line. +func toggleCheckbox(lines []string) ([]string, error) { + out := append([]string(nil), lines...) + switch { + case strings.HasPrefix(out[0], unticked): + out[0] = ticked + strings.TrimPrefix(out[0], unticked) + case strings.HasPrefix(strings.ToLower(out[0]), ticked): + out[0] = unticked + out[0][len(ticked):] + default: + return nil, errors.New("not a checklist item") + } + return out, nil +} + +// askOptions returns the options of an item's "Ask:" line — "Ask: yes | no" +// yields ["yes", "no"] — or nil when the item asks nothing. +func askOptions(it BoardItem) []string { + _, rest, _ := strings.Cut(it.Raw, "\n") + for _, ln := range strings.Split(rest, "\n") { + if v, ok := strings.CutPrefix(strings.TrimSpace(ln), "Ask:"); ok { + var opts []string + for _, o := range strings.Split(v, "|") { + if o = strings.TrimSpace(o); o != "" { + opts = append(opts, o) + } + } + return opts + } + } + return nil +} + +// answerOf returns the item's current "Answer:" value, "" when unanswered. +func answerOf(it BoardItem) string { + _, rest, _ := strings.Cut(it.Raw, "\n") + for _, ln := range strings.Split(rest, "\n") { + if v, ok := strings.CutPrefix(strings.TrimSpace(ln), "Answer:"); ok { + return strings.TrimSpace(v) + } + } + return "" +} + +// setAnswer writes "Answer: