diff --git a/README.md b/README.md index ca20fcc..98f1f88 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ 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 the arrow keys, `PgUp` and `PgDn`, and `g` and `G` for top and bottom, or with the mouse wheel (`j` and `k` move between items; they scroll a board that has none). Sidecar keeps your scroll position across reloads. +- Talks back to the agent. The bullet is the control: click a `•` and it becomes `✓` (the file gets `- [x]`), click again to undo. When the agent asks a question — an indented `Ask:` line, drawn as an indented `? …` block — click it, or press `enter`, and type your answer under the item; Enter writes it as an `Answer:` line, and `sidecar`'s hook hands it to the agent after its next tool call, no chat message needed. Nothing you do moves an item between sections; that stays the agent's job. `u`, or a click on the status-bar message, undoes the last change and refuses if the agent has edited the board since. +- Clicks work by default: bullets, questions, section headers, links, and the wheel. Whatever is clickable turns solid under the pointer, and a link shows its full address in the status bar. Because capture stops the terminal from opening links itself, the viewer opens them — web and mail links only. Mouse capture costs the terminal's plain drag-to-select — hold Shift (Option in iTerm2) to select anyway, press `M` to switch capture off and on, or start with `--no-mouse`. Everything also has a key: `j` and `k` move between items (the wheel and arrow keys scroll), `space` ticks the selected item, `enter` replies, `}` and `{` jump between open questions, and `esc` deselects. `tab` still moves between sections. The status bar counts the questions still waiting on you. - `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/diffcmd.go b/diffcmd.go index eec0462..e7458d9 100644 --- a/diffcmd.go +++ b/diffcmd.go @@ -5,6 +5,7 @@ package main import ( "bytes" + "encoding/json" "fmt" "hash/fnv" "os" @@ -13,23 +14,35 @@ import ( ) func runDiff(args []string) int { - if len(args) > 1 { + operands := 0 + for _, a := range args { + if a != "--mid-turn" { + operands++ + } + } + if operands > 1 { fmt.Fprintln(os.Stderr, "sidecar diff: too many arguments") return 2 } path := defaultBoardPath() - if len(args) > 0 { - switch args[0] { - case "-h", "--help": - fmt.Println("usage: sidecar diff [file.md]") + midTurn, havePath := false, false + for _, a := range args { + switch { + case a == "-h" || a == "--help": + fmt.Println("usage: sidecar diff [--mid-turn] [file.md]") fmt.Println("Prints board changes since the last run.") + fmt.Println("--mid-turn prints only the human's replies and ticks, as PostToolUse hook JSON, and stays silent otherwise.") return 0 + case a == "--mid-turn": + midTurn = true + case strings.HasPrefix(a, "-"): + fmt.Fprintf(os.Stderr, "sidecar diff: unknown flag %q\n", a) + return 2 + case havePath: + fmt.Fprintln(os.Stderr, "sidecar diff: too many arguments") + return 2 default: - if strings.HasPrefix(args[0], "-") { - fmt.Fprintf(os.Stderr, "sidecar diff: unknown flag %q\n", args[0]) - return 2 - } - path = args[0] + path, havePath = a, true } } abs, err := filepath.Abs(expandTilde(path)) @@ -62,6 +75,9 @@ func runDiff(args []string) int { if bytes.Equal(prev, raw) { return 0 } + if midTurn { + return midTurnReport(path, snap, string(prev), string(raw)) + } for _, line := range cappedDiffLines(diffLines(string(prev), string(raw))) { fmt.Println(line) @@ -193,3 +209,34 @@ func closingReminder(rel, raw string) string { } return reconcileMessageLabels(rel, labels) } + +// midTurnReport is the PostToolUse half of the hook cycle: it surfaces only +// what the human did in the viewer — a reply, a tick — as hook JSON that adds +// context to the running turn. Anything else stays silent and leaves the +// snapshot alone, so the per-prompt diff still reports it. The snapshot moves +// only when something was reported, so the same reply is never delivered twice. +func midTurnReport(path, snap, prev, raw string) int { + ob, ok1 := parseBoard(prev) + nb, ok2 := parseBoard(raw) + if !ok1 || !ok2 { + return 0 + } + var lines []string + for _, l := range semanticDiff(ob, nb) { + if strings.Contains(l, " — replied ") || strings.Contains(l, " — ticked") || strings.Contains(l, " — unticked") { + lines = append(lines, l) + } + } + if len(lines) == 0 { + return 0 + } + msg := "The human answered on the sidecar board while you worked:\n" + strings.Join(cappedDiffLines(lines), "\n") + + "\nRead " + path + ", act on the reply, then clear the item's Ask: and Answer: lines or move it." + out, _ := json.Marshal(map[string]any{"hookSpecificOutput": map[string]any{ + "hookEventName": "PostToolUse", + "additionalContext": msg, + }}) + fmt.Println(string(out)) + writeSnapshot(snap, []byte(raw)) + return 0 +} diff --git a/diffcmd_test.go b/diffcmd_test.go index 57e9b89..4e274ad 100644 --- a/diffcmd_test.go +++ b/diffcmd_test.go @@ -434,3 +434,33 @@ func TestDefaultBoardPathFreshDefault(t *testing.T) { } }) } + +func TestMidTurnReportsRepliesAndStaysSilentOtherwise(t *testing.T) { + dir := t.TempDir() + withWorkDir(t, dir, func() { + board := filepath.Join(dir, ".sidecar", "sidecar.md") + os.MkdirAll(filepath.Dir(board), 0o755) + base := "## 🧠 Needs you\n\n- Ship\n Ask: What changed?\n" + os.WriteFile(board, []byte(base), 0o644) + run := func() string { + var out string + out = captureStdout(t, func() { runDiff([]string{"--mid-turn"}) }) + return out + } + if run() != "" { + t.Fatal("first run seeds silently") + } + os.WriteFile(board, []byte(base+" Next: x\n"), 0o644) + if got := run(); got != "" { + t.Fatalf("an agent-side edit must stay silent mid-turn, got %q", got) + } + os.WriteFile(board, []byte(base+" Answer: I fixed the key\n Next: x\n"), 0o644) + got := run() + if !strings.Contains(got, `"hookEventName":"PostToolUse"`) || !strings.Contains(got, "replied") || !strings.Contains(got, "I fixed the key") { + t.Fatalf("reply not reported as hook JSON: %q", got) + } + if again := run(); again != "" { + t.Fatalf("a reply must be delivered once, got %q", again) + } + }) +} 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..685e89e --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-interactive-line-updates-design.md @@ -0,0 +1,50 @@ +# Interactive line updates + +The viewer is the human's channel back to the agent. The agent owns the board and every move on it; the human only says "I did this" and answers questions, and the hook cycle hands both to the agent mid-turn. + +## Intent + +- An agent leaves the result of a turn as an entry. The human, who may be in another tab, says what they did in between, in their own words. +- The agent asks a narrative question, not a yes/no. +- Always clickable: mouse capture is on by default (`--no-mouse` and `M` release it). Shift-drag (Option in iTerm2) still selects text. +- No emoji, no buttons. The characters already on the board are the controls. + +## What the human sees and does + +- **The bullet is the control.** Click within the first four columns of an item's first line and `•` turns into `✓`; the file gets `- [x]`. Click again and it is a plain bullet. `space` does the same on the selected item. +- **`? question`** — an `Ask:` line is drawn as an indented `? …` block in one accent color, wrapped with a hanging indent. Click any line of it, or press `enter` on the item, and a reply line opens under the item; Enter writes it, esc abandons it. The reply shows as `↳ …`. The reply line is pre-filled with the current reply so it can be edited, and shows `type your reply… ⏎ send · esc cancel` when empty. +- **Links** open on a click. Capture stops the terminal handling OSC 8 links, so the viewer finds the link under the pointer in the rendered line and opens it (web and mail schemes only; never `file:` or an app's scheme). The full address shows in the status bar on hover. +- Whatever is clickable — bullet, question, or link — turns solid under the pointer; nothing else changes on hover. +- One blank line separates items. +- The viewer never moves an item between sections. Filing stays with the agent. + +## Board syntax + +Plain markdown; the file never contains a control. + +- ` Ask: what did you change?` — the agent's question. +- ` Answer: …` — the human's reply. It is written under `Ask:` when there is one and last otherwise, so an unprompted reply works on any item. +- `- [x] …` — ticked. `[ ]` and plain bullets both tick to `[x]`; unticking returns to a plain bullet. + +## Delivery + +- `sidecar diff` (UserPromptSubmit) is unchanged and reports everything since the last prompt. +- `sidecar diff --mid-turn` (PostToolUse) prints hook JSON only when the human replied or ticked, and stays silent for anything else, including the agent's own edits. It advances the snapshot only when it reported, so a reply arrives once. +- `sidecar init` installs both hooks; the mid-turn one carries the same sentinel, so a re-run replaces it. +- The diff line reads `edited 🧠: "Title" — replied "…"`; a tick reads `— ticked`, an untick `— unticked`. + +## Writes and undo + +- `editItem` re-reads the file, finds the item by section label and exact text, applies a pure text transform, and swaps the file in by rename, keeping its mode. An agent edit elsewhere survives; if the item itself changed since it was rendered, nothing is written and the status bar says so. +- Every write is remembered with the file before and after. `u`, or a click on the status message, restores the earlier file — but only while the file still reads exactly as that write left it, so undo never overwrites the agent. +- Every click reports its outcome in the status bar, so none looks ignored. + +## Keyboard + +`j` / `k` move between items (they scroll when the board has none; arrows and the wheel always scroll), `space` ticks, `enter` replies, `}` / `{` jump between unanswered questions, `u` undoes, `esc` deselects, `tab` moves between sections. The status bar counts open questions. + +## Known limits + +- Items are mapped to rendered lines by their column-0 marker (`•`, `□`, `✓`); a section whose rendered count differs from the parsed count (collapsed) is skipped rather than mis-selected. +- When `--mid-turn` reports, the snapshot advances whole, so an agent edit made in the same window is not repeated in the next prompt's diff. +- A click is held while a reply is being typed, so it cannot act on a line the typing row has shifted. diff --git a/edit.go b/edit.go new file mode 100644 index 0000000..4c4da4c --- /dev/null +++ b/edit.go @@ -0,0 +1,196 @@ +// edit.go — the viewer's only writes to the board, each a pure text transform +// on one item: tick it (• ↔ ✓) or set its reply. The viewer never moves an +// item between sections; that is the agent's job. Every edit locates the item +// by section label and exact text in the file as it is on disk right now, so +// an unrelated edit by the agent between render and click is preserved and a +// conflicting one is refused. +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +var errBoardChanged = errors.New("board changed under the cursor — nothing written") + +const ( + unticked = "- [ ] " + ticked = "- [x] " +) + +func firstLine(raw string) string { + first, _, _ := strings.Cut(raw, "\n") + return first +} + +// isTicked reports an item whose first line is "- [x] …" — drawn ✓. +func isTicked(it BoardItem) bool { + return strings.HasPrefix(strings.ToLower(firstLine(it.Raw)), ticked) +} + +// toggleTick turns a plain or "- [ ]" item into "- [x]", and a ticked one back +// into a plain bullet. Ticking is the human saying "I did this"; unticking +// takes it back. +func toggleTick(lines []string) ([]string, error) { + out := append([]string(nil), lines...) + first := out[0] + switch { + case strings.HasPrefix(strings.ToLower(first), ticked): + out[0] = "- " + first[len(ticked):] + case strings.HasPrefix(first, unticked): + out[0] = ticked + strings.TrimPrefix(first, unticked) + case strings.HasPrefix(first, "- "): + out[0] = ticked + strings.TrimPrefix(first, "- ") + default: + return nil, errors.New("not a list item") + } + return out, nil +} + +// hasAsk reports whether the item carries an "Ask:" line — the agent's +// question, which the human answers in their own words. +func hasAsk(it BoardItem) bool { + _, rest, _ := strings.Cut(it.Raw, "\n") + for _, ln := range strings.Split(rest, "\n") { + if strings.HasPrefix(strings.TrimSpace(ln), "Ask:") { + return true + } + } + return false +} + +// askText is the question on the item's "Ask:" line, "" when it has none. +func askText(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 { + return strings.TrimSpace(v) + } + } + return "" +} + +// isUnanswered reports a question still waiting on the human. +func isUnanswered(it BoardItem) bool { return hasAsk(it) && answerOf(it) == "" } + +// cleanAnswer reduces typed text to one plain line: control characters and +// newlines become spaces, runs of space collapse, and the ends are trimmed. +func cleanAnswer(s string) string { + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, s) + return strings.Join(strings.Fields(s), " ") +} + +// 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: " directly under the Ask: line — or last, +// when the item asked nothing — replacing an earlier answer so the item never +// carries two. +func setAnswer(lines []string, text string) ([]string, error) { + askAt := -1 + for i, ln := range lines[1:] { + if strings.HasPrefix(strings.TrimSpace(ln), "Ask:") { + askAt = i + 1 + break + } + } + var out []string + for i, ln := range lines { + if i > 0 && strings.HasPrefix(strings.TrimSpace(ln), "Answer:") { + continue + } + out = append(out, ln) + if i == askAt { + indent := ln[:len(ln)-len(strings.TrimLeft(ln, " \t"))] + out = append(out, indent+"Answer: "+text) + } + } + if askAt < 0 { + out = append(out, " Answer: "+text) + } + return out, nil +} + +// editItem re-reads the board, finds the item by section label and exact +// text, and lets fn produce the new file content. errBoardChanged means the +// item is gone or was edited since the viewer rendered it. +func editItem(path, label, oldRaw string, fn func(raw string, b Board, si, ii int) (string, error)) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + raw := string(data) + b, ok := parseBoard(raw) + if !ok { + return errBoardChanged + } + for si, s := range b.Sections { + if s.Label != label { + continue + } + for ii, it := range s.Items { + if it.Raw == oldRaw { + next, err := fn(raw, b, si, ii) + if err != nil { + return err + } + return writeAtomic(path, next) + } + } + } + return errBoardChanged +} + +// replaceLines swaps one item's lines for the transform of them. +func replaceLines(t func([]string) ([]string, error)) func(string, Board, int, int) (string, error) { + return func(raw string, b Board, si, ii int) (string, error) { + it := b.Sections[si].Items[ii] + lines := strings.Split(raw, "\n") + repl, err := t(lines[it.StartLine : it.EndLine+1]) + if err != nil { + return "", err + } + out := append(append(append([]string(nil), lines[:it.StartLine]...), repl...), lines[it.EndLine+1:]...) + return strings.Join(out, "\n"), nil + } +} + +// writeAtomic swaps the file in via rename so a watcher or the agent never +// reads a half-written board, keeping the original file mode. +func writeAtomic(path, content string) error { + mode := os.FileMode(0o644) + if st, err := os.Stat(path); err == nil { + mode = st.Mode().Perm() + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".sidecar-edit-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.WriteString(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmp.Name(), mode); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/edit_test.go b/edit_test.go new file mode 100644 index 0000000..acf67e6 --- /dev/null +++ b/edit_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const editBoard = `# Board + +## 🧠 Needs you + +- Ship it +- Which name? + Ask: What should we call it? + Next: answer + +## 🚧 In progress + +- ` + emptySectionPlaceholder + ` + +## ✅ Done + +- old +` + +func writeBoardFile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "sidecar.md") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func readFile(t *testing.T, p string) string { + t.Helper() + d, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(d) +} + +func TestToggleTickBothWays(t *testing.T) { + on, err := toggleTick([]string{"- plain", " more"}) + if err != nil || on[0] != "- [x] plain" || on[1] != " more" { + t.Fatalf("tick: %v %v", on, err) + } + off, _ := toggleTick([]string{"- [X] plain"}) + if off[0] != "- plain" { + t.Fatalf("untick returns to a plain bullet: %v", off) + } + box, _ := toggleTick([]string{"- [ ] task"}) + if box[0] != "- [x] task" { + t.Fatalf("an unchecked box ticks: %v", box) + } + if _, err := toggleTick([]string{"1. numbered"}); err == nil { + t.Fatal("only dash bullets tick") + } +} + +func TestSetAnswerGoesUnderAskAndReplaces(t *testing.T) { + b, _ := parseBoard(editBoard) + lines := strings.Split(b.Sections[0].Items[1].Raw, "\n") + a, err := setAnswer(lines, "Sidecar") + if err != nil { + t.Fatal(err) + } + if a[2] != " Answer: Sidecar" || len(a) != 4 || a[3] != " Next: answer" { + t.Fatalf("placement: %q", a) + } + a2, _ := setAnswer(a, "Wingman") + if len(a2) != 4 || a2[2] != " Answer: Wingman" { + t.Fatalf("a new reply must replace the old one: %q", a2) + } +} + +func TestSetAnswerOnAnItemThatAskedNothingGoesLast(t *testing.T) { + a, _ := setAnswer([]string{"- Create the key", " Next: create it"}, "made it") + if strings.Join(a, "|") != "- Create the key| Next: create it| Answer: made it" { + t.Fatalf("%q", a) + } +} + +func TestEditItemWritesOnlyThatItem(t *testing.T) { + p := writeBoardFile(t, editBoard) + b, _ := parseBoard(editBoard) + if err := editItem(p, "🧠 Needs you", b.Sections[0].Items[0].Raw, replaceLines(toggleTick)); err != nil { + t.Fatal(err) + } + if got, want := readFile(t, p), strings.Replace(editBoard, "- Ship it", "- [x] Ship it", 1); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestEditItemRefusesWhenItemChanged(t *testing.T) { + p := writeBoardFile(t, strings.Replace(editBoard, "Ship it", "Ship it now", 1)) + b, _ := parseBoard(editBoard) + if err := editItem(p, "🧠 Needs you", b.Sections[0].Items[0].Raw, replaceLines(toggleTick)); err != errBoardChanged { + t.Fatalf("want errBoardChanged, got %v", err) + } +} + +func TestEditItemKeepsConcurrentEditsElsewhere(t *testing.T) { + p := writeBoardFile(t, editBoard+"- added by agent\n") + b, _ := parseBoard(editBoard) + if err := editItem(p, "🧠 Needs you", b.Sections[0].Items[0].Raw, replaceLines(toggleTick)); err != nil { + t.Fatal(err) + } + if got := readFile(t, p); !strings.Contains(got, "- added by agent") || !strings.Contains(got, "- [x] Ship it") { + t.Fatalf("lost an edit:\n%s", got) + } +} + +func TestDiffNamesTickAndReply(t *testing.T) { + old := "## 🧠 Needs you\n\n- Ship it\n- Pick\n Ask: which?\n" + next := "## 🧠 Needs you\n\n- [x] Ship it\n- Pick\n Ask: which?\n Answer: the first\n" + ob, _ := parseBoard(old) + nb, _ := parseBoard(next) + got := strings.Join(semanticDiff(ob, nb), "\n") + for _, want := range []string{`edited 🧠: "Ship it" — ticked`, `edited 🧠: "Pick" — replied "the first"`} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in:\n%s", want, got) + } + } + back, _ := parseBoard(old) + if got := strings.Join(semanticDiff(nb, back), "\n"); !strings.Contains(got, `"Ship it" — unticked`) { + t.Fatalf("untick to a plain bullet must still be reported:\n%s", got) + } +} diff --git a/init.go b/init.go index 479dc32..179a380 100644 --- a/init.go +++ b/init.go @@ -521,6 +521,35 @@ func reconcileHookEntry(rel string, sections []Section) map[string]any { } } +// midTurnHookEntry is the PostToolUse half of the cycle: after every tool call +// it asks `sidecar diff --mid-turn` whether the human replied in the viewer, +// so an answer reaches the agent mid-turn instead of waiting for the next +// prompt. It stays silent otherwise, fails open on an older binary, and +// carries the sentinel so a re-run of init replaces it rather than stacking. +func midTurnHookEntry(rel string) map[string]any { + diffCmd := "sidecar diff --mid-turn" + if !isDefaultBoardRel(rel) { + diffCmd += " " + shSingleQuote(rel) + } + cmd := "sidecar diff --help /dev/null 2>&1 && " + diffCmd + " 2>/dev/null; true # " + hookSentinel + return map[string]any{ + "hooks": []any{ + map[string]any{"type": "command", "command": cmd}, + }, + } +} + +// addMidTurnHook appends entry under PostToolUse. Call it after +// mergeReconcileHook, which has already removed any prior sidecar entry. +func addMidTurnHook(settings, entry map[string]any) { + hooks, ok := settings["hooks"].(map[string]any) + if !ok { + return + } + ptu, _ := hooks["PostToolUse"].([]any) + hooks["PostToolUse"] = append(ptu, entry) +} + // writeReconcileHook installs a UserPromptSubmit hook — the only hook type // that fires every turn, so the queue actually stays current. It merges into // an existing .claude/settings.json, replacing any prior sidecar hook @@ -537,6 +566,7 @@ func writeReconcileHook(root, rel string, sections []Section) (needsAttention bo data, err := os.ReadFile(path) if os.IsNotExist(err) { settings := map[string]any{"hooks": map[string]any{"UserPromptSubmit": []any{entry}}} + addMidTurnHook(settings, midTurnHookEntry(rel)) if err := writeSettings(path, settings); err != nil { fmt.Fprintln(os.Stderr, "sidecar init:", err) return true @@ -558,6 +588,7 @@ func writeReconcileHook(root, rel string, sections []Section) (needsAttention bo fmt.Printf(".claude/settings.json has an unexpected shape — add this hook yourself:\n%s\n", snippetJSON(entry)) return true } + addMidTurnHook(settings, midTurnHookEntry(rel)) if err := writeSettings(path, settings); err != nil { fmt.Fprintln(os.Stderr, "sidecar init:", err) return true diff --git a/init_test.go b/init_test.go index 3bd1fa4..5c310ba 100644 --- a/init_test.go +++ b/init_test.go @@ -159,9 +159,13 @@ func TestReconcileHookMergesAndUpgrades(t *testing.T) { if !ok || len(ups) != 1 { t.Fatalf("want exactly 1 UserPromptSubmit entry, got:\n%s", data) } - // The old sidecar SessionStart hook should be gone (only keep-me remains). - if n := strings.Count(string(data), hookSentinel); n != 1 { - t.Errorf("sentinel appears %d times, want 1 (old hook not replaced):\n%s", n, data) + // The old sidecar SessionStart hook should be gone (only keep-me remains); + // what's left is the per-prompt hook plus the mid-turn PostToolUse hook. + if n := strings.Count(string(data), hookSentinel); n != 2 { + t.Errorf("sentinel appears %d times, want 2 (old hook not replaced, or a duplicate):\n%s", n, data) + } + if ptu, ok := hooks["PostToolUse"].([]any); !ok || len(ptu) != 1 { + t.Errorf("want exactly 1 PostToolUse entry after two runs, got:\n%s", data) } } diff --git a/interact.go b/interact.go new file mode 100644 index 0000000..b69af96 --- /dev/null +++ b/interact.go @@ -0,0 +1,451 @@ +// interact.go — the viewer's channel back to the agent. Select an item, tick +// its bullet, or reply to the agent's question; each is one line written to +// the board, and the agent's hook reads it. Mouse clicks are on by default; +// every action has a key too. Nothing here moves an item between sections. +package main + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// undoEntry is one write made from the viewer: the file before and after, so +// undo can restore it only while the file still reads as we left it. +type undoEntry struct{ before, after string } + +// itemStartLines maps each board item to the rendered line it starts on: a +// top-level "• ", "□ ", or "✓ " line at column 0 between a section's header +// and the next. A section whose rendered count disagrees with the parsed +// count (collapsed, or a shape the renderer lays out differently) maps to +// nil, so the cursor skips it rather than land on the wrong line. +func itemStartLines(lines []string, headers []int, board Board) [][]int { + out := make([][]int, len(board.Sections)) + if len(headers) != len(board.Sections) { + return out + } + for si := range board.Sections { + end := len(lines) + if si+1 < len(headers) { + end = headers[si+1] + } + var starts []int + for i := headers[si] + 1; i < end; i++ { + t := stripANSI(lines[i]) + if strings.HasPrefix(t, "• ") || strings.HasPrefix(t, "□ ") || strings.HasPrefix(t, "✓ ") { + starts = append(starts, i) + } + } + if len(starts) == len(board.Sections[si].Items) { + out[si] = starts + } + } + return out +} + +// applyItemHighlight tints the selected item's rendered lines. +func applyItemHighlight(display string, starts [][]int, si, ii, width int) string { + if si < 0 || si >= len(starts) || ii < 0 || ii >= len(starts[si]) { + return display + } + lines := strings.Split(display, "\n") + end := itemEnd(lines, starts[si], ii) + for i := starts[si][ii]; i < end && i < len(lines); i++ { + if strings.TrimSpace(stripANSI(lines[i])) != "" { + lines[i] = applyLineBg(lines[i], colorCursorBg, width) + } + } + return strings.Join(lines, "\n") +} + +func (m model) selected() (BoardItem, string, bool) { + if m.itemSec < 0 || m.itemSec >= len(m.board.Sections) { + return BoardItem{}, "", false + } + s := m.board.Sections[m.itemSec] + if m.itemIdx < 0 || m.itemIdx >= len(s.Items) { + return BoardItem{}, "", false + } + return s.Items[m.itemIdx], s.Label, true +} + +// itemPositions lists every mapped, real item in reading order. +func (m model) itemPositions() (all [][2]int, cur int) { + cur = -1 + for si, starts := range m.itemStarts { + for ii := range starts { + if m.board.Sections[si].Items[ii].Key == emptySectionPlaceholder { + continue + } + if si == m.itemSec && ii == m.itemIdx { + cur = len(all) + } + all = append(all, [2]int{si, ii}) + } + } + return all, cur +} + +func (m *model) selectItem(si, ii int) { + m.itemSec, m.itemIdx, m.cursor = si, ii, si + m.recompose() + m.scrollTo(m.itemStarts[si][ii]) +} + +// moveItemCursor steps to the next (+1) or previous (-1) item, wrapping. +func (m *model) moveItemCursor(delta int) bool { + all, cur := m.itemPositions() + if len(all) == 0 { + return false + } + next := 0 + switch { + case cur >= 0: + next = ((cur+delta)%len(all) + len(all)) % len(all) + case delta < 0: + next = len(all) - 1 + } + m.selectItem(all[next][0], all[next][1]) + return true +} + +// moveQuestionCursor selects the next (+1) or previous (-1) unanswered +// question that is visible, wrapping, and says so when none is waiting. +func (m *model) moveQuestionCursor(delta int) { + all, cur := m.itemPositions() + n := len(all) + for step := 1; step <= n; step++ { + var idx int + switch { + case cur >= 0: + idx = ((cur+delta*step)%n + n) % n + case delta > 0: + idx = step - 1 + default: + idx = n - step + } + if isUnanswered(m.board.Sections[all[idx][0]].Items[all[idx][1]]) { + m.selectItem(all[idx][0], all[idx][1]) + return + } + } + m.notice = "no questions waiting" +} + +func (m *model) clearItemCursor() { + m.itemSec, m.itemIdx = -1, -1 + m.recompose() +} + +// scrollTo brings a rendered line into view with a 1-line margin. +func (m *model) scrollTo(target int) { + const margin = 1 + top, bottom := m.vp.YOffset, m.vp.YOffset+m.vp.Height-1 + switch { + case target < top+margin: + m.vp.SetYOffset(max(0, target-margin)) + case target > bottom-margin: + m.vp.SetYOffset(target - m.vp.Height + 1 + margin) + } +} + +// itemKey handles a keypress while an item is selected. It reports whether +// the key was consumed. +func (m *model) itemKey(key string) bool { + it, label, ok := m.selected() + if !ok { + return false + } + switch key { + case "esc": + m.clearItemCursor() + case "x", " ": + m.tick(label, it) + case "a", "enter": + m.startReply(it) + default: + return false + } + return true +} + +func (m *model) tick(label string, it BoardItem) { + note := "ticked" + if isTicked(it) { + note = "unticked" + } + m.apply(label, it, replaceLines(toggleTick), note) +} + +func (m *model) startReply(it BoardItem) { + m.typing, m.input = true, answerOf(it) + m.recompose() +} + +// typeKey handles a keypress while a reply is being typed under an item: +// text appends, backspace deletes, enter writes it, esc (or ctrl+c) abandons +// it. Every key is consumed, so typing "q" or "x" into a reply never quits or +// ticks anything. +func (m *model) typeKey(msg tea.KeyMsg) { + switch msg.Type { + case tea.KeyEsc, tea.KeyCtrlC: + m.typing, m.input = false, "" + case tea.KeyEnter: + text := cleanAnswer(m.input) + it, label, ok := m.selected() + m.typing, m.input = false, "" + if ok && text != "" { + m.apply(label, it, replaceLines(func(l []string) ([]string, error) { return setAnswer(l, text) }), "reply sent") + } + case tea.KeyBackspace: + if r := []rune(m.input); len(r) > 0 { + m.input = string(r[:len(r)-1]) + } + case tea.KeySpace: + m.input += " " + case tea.KeyRunes: + m.input += string(msg.Runes) + } + m.recompose() // the reply is drawn under its item, so redraw on every key +} + +// apply writes one edit, remembers it for undo, then reloads so the pane shows +// the file as it now is. A refused or failed write surfaces in the status bar. +func (m *model) apply(label string, it BoardItem, fn func(string, Board, int, int) (string, error), note string) { + before, _ := os.ReadFile(m.path) + if err := editItem(m.path, label, it.Raw, fn); err != nil { + m.notice = err.Error() + if err == errBoardChanged { + m.reload(true) + } + return + } + after, _ := os.ReadFile(m.path) + if string(before) != string(after) { + m.undo = append(m.undo, undoEntry{string(before), string(after)}) + note += " — u to undo" + } + m.notice = note + m.reload(true) +} + +// undoLast restores the file as it was before the viewer's last write — but +// only while the file still reads exactly as that write left it, so undo can +// never overwrite something the agent wrote since. +func (m *model) undoLast() { + if len(m.undo) == 0 { + m.notice = "nothing to undo" + return + } + e := m.undo[len(m.undo)-1] + cur, err := os.ReadFile(m.path) + if err != nil || string(cur) != e.after { + m.undo = nil + m.notice = "board changed since — nothing undone" + return + } + if err := writeAtomic(m.path, e.before); err != nil { + m.notice = err.Error() + return + } + m.undo = m.undo[:len(m.undo)-1] + m.itemSec, m.itemIdx = -1, -1 + m.notice = "undone" + if len(m.undo) > 0 { + m.notice += " — u to undo again" + } + m.reload(true) +} + +// pendingQuestions counts unanswered questions outside the finished sections +// — what is waiting on the human. +func (m model) pendingQuestions() int { + n := 0 + for _, s := range m.board.Sections { + if finishedLabel(s.Label) { + continue + } + for _, it := range s.Items { + if isUnanswered(it) { + n++ + } + } + } + return n +} + +// hint is the status-bar line shown while an item is selected. +func (m model) hint() string { + it, _, ok := m.selected() + if !ok { + return "" + } + tick := "x tick" + if isTicked(it) { + tick = "x untick" + } + reply := "a reply" + if answerOf(it) != "" { + reply = "a edit reply" + } + return strings.Join([]string{"j k move", "space " + strings.TrimPrefix(tick, "x "), "enter " + strings.TrimPrefix(reply, "a "), "esc"}, " · ") +} + +// itemAtLine finds the mapped, real item whose rendered lines include line. +func (m model) itemAtLine(line int) (si, ii, start int, ok bool) { + for si, starts := range m.itemStarts { + for ii, start := range starts { + if m.board.Sections[si].Items[ii].Key == emptySectionPlaceholder { + continue + } + if line >= start && line < itemEnd(m.renderedLines, starts, ii) { + return si, ii, start, true + } + } + } + return 0, 0, 0, false +} + +// targetAt classifies what is under a click or the pointer: the bullet (the +// first few columns of an item's first line) or any line of the item's +// question. last is the question's final line. +func (m model) targetAt(x, line int) (si, ii, last int, kind hoverKind) { + si, ii, start, ok := m.itemAtLine(line) + if !ok { + return 0, 0, 0, hoverNone + } + if line == start && x < bulletCells { + return si, ii, line, hoverBullet + } + it := m.board.Sections[si].Items[ii] + if q := plainMark(askText(it)); q != "" { + end := itemEnd(m.renderedLines, m.itemStarts[si], ii) + if f, l, ok := markBlock(m.renderedLines, start+1, end, questionPrefix, q); ok && line >= f && line <= l { + return si, ii, l, hoverQuestion + } + } + return si, ii, line, hoverNone +} + +// mouseClick acts on a left click: a section header collapses, a bullet +// ticks, a question opens a reply, and any other spot on an item selects it. +// Every outcome says so in the status bar, so a click never looks ignored. +func (m *model) mouseClick(x, y int) { + if y == m.vp.Height && len(m.undo) > 0 { + m.undoLast() // the status bar carries "u to undo" + return + } + if y < 0 || y >= m.vp.Height { + m.notice = fmt.Sprintf("click %d,%d — the status bar is not clickable", x, y) + return + } + if m.typing { + m.notice = "finish the reply first — enter sends, esc cancels" + return + } + line := y + m.vp.YOffset + if line < len(m.renderedLines) { + if s, ok := linkAt(m.renderedLines[line], x); ok { + m.openLink(s.url) + return + } + } + for si, h := range m.headerLines { + if h == line { + m.cursor = si + m.toggleCursor() + return + } + } + si, ii, _, kind := m.targetAt(x, line) + if _, _, _, ok := m.itemAtLine(line); !ok { + m.notice = fmt.Sprintf("click %d,%d — nothing to click on that line", x, y) + return + } + m.selectItem(si, ii) + it, label, _ := m.selected() + switch kind { + case hoverBullet: + m.tick(label, it) + case hoverQuestion: + m.startReply(it) + default: + m.notice = "item selected — click its bullet to tick, its ? question to reply" + } +} + +// setHover tracks what is under the pointer so it can be painted; it +// re-renders only when the target changes. +func (m *model) setHover(x, y int) { + next := hoverTarget{line: -1} + if y >= 0 && y < m.vp.Height && !m.typing { + line := y + m.vp.YOffset + if line < len(m.renderedLines) { + if s, ok := linkAt(m.renderedLines[line], x); ok { + next = hoverTarget{line: line, last: line, kind: hoverLink, link: s} + } + } + if next.line >= 0 { + // a link wins over the item it sits in + } else if si, ii, last, kind := m.targetAt(x, line); kind != hoverNone { + first := line + if kind == hoverQuestion { + start := m.itemStarts[si][ii] + first, _, _ = markBlock(m.renderedLines, start+1, itemEnd(m.renderedLines, m.itemStarts[si], ii), questionPrefix, plainMark(askText(m.board.Sections[si].Items[ii]))) + } + next = hoverTarget{line: first, last: last, kind: kind} + } + } + if next != m.hover { + m.hover = next + m.recompose() + } +} + +// typingRow finds where the reply being typed is drawn: over the item's +// reply block when it has one (first through last), otherwise as a new line +// after its last line (insert). text is the styled row. +func (m model) typingRow(width int) (first, last int, text string, insert, ok bool) { + if !m.typing || m.itemSec < 0 || m.itemSec >= len(m.itemStarts) || m.itemIdx >= len(m.itemStarts[m.itemSec]) { + return 0, 0, "", false, false + } + ss := m.itemStarts[m.itemSec] + end := itemEnd(m.renderedLines, ss, m.itemIdx) + const hint = "⏎ send · esc cancel" + showHint := width >= 44 + room := max(4, width-8) + if showHint { + room = max(4, width-8-visibleWidth(hint)-2) + } + shown := m.input + if shown == "" { + shown = "type your reply…" + } + for visibleWidth(shown) > room && shown != "" { + shown = string([]rune(shown)[1:]) + } + row := " " + typingStyle.Render(" "+replyPrefix+shown+"▌ ") + if showHint { + row += " " + hintStyle.Render(hint) + } + it := m.board.Sections[m.itemSec].Items[m.itemIdx] + if r := plainMark(answerOf(it)); r != "" { + if f, l, found := markBlock(m.renderedLines, ss[m.itemIdx]+1, end, replyPrefix, r); found { + return f, l, row, false, true + } + } + return end, end, row, true, true +} + +func (m *model) toggleMouse() tea.Cmd { + m.mouse = !m.mouse + m.hover = hoverTarget{line: -1} + if m.mouse { + m.notice = "mouse on — click a bullet to tick, a ? question to reply (M to release)" + return tea.EnableMouseAllMotion + } + m.notice = "mouse off — native text selection restored (M to click again)" + return tea.DisableMouse +} diff --git a/interact_test.go b/interact_test.go new file mode 100644 index 0000000..74dc32a --- /dev/null +++ b/interact_test.go @@ -0,0 +1,227 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func press(t *testing.T, m model, keys ...string) model { + t.Helper() + for _, k := range keys { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} + if k == "esc" { + msg = tea.KeyMsg{Type: tea.KeyEsc} + } + next, _ := m.Update(msg) + m = next.(model) + } + return m +} + +func typeText(t *testing.T, m model, s string) model { + t.Helper() + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}) + return next.(model) +} + +func special(t *testing.T, m model, k tea.KeyType) model { + t.Helper() + next, _ := m.Update(tea.KeyMsg{Type: k}) + return next.(model) +} + +func interactModel(t *testing.T) (model, string) { + t.Helper() + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, editBoard) + return testModel(t, p), p +} + +func TestItemStartLinesMapEveryOpenItem(t *testing.T) { + m, _ := interactModel(t) + got := 0 + for si, starts := range m.itemStarts { + if len(starts) != len(m.board.Sections[si].Items) && !m.collapsed[m.board.Sections[si].Label] { + t.Fatalf("section %d mapped %d of %d", si, len(starts), len(m.board.Sections[si].Items)) + } + got += len(starts) + } + if got == 0 { + t.Fatal("no items mapped") + } +} + +func TestTickFromKeyboardBothWays(t *testing.T) { + m, p := interactModel(t) + m = press(t, m, "]", "x") + if !strings.Contains(readFile(t, p), "- [x] Ship it") { + t.Fatalf("not ticked:\n%s", readFile(t, p)) + } + if !strings.Contains(m.notice, "ticked") { + t.Fatalf("notice %q", m.notice) + } + press(t, m, "x") + if got := readFile(t, p); strings.Contains(got, "[x]") || !strings.Contains(got, "- Ship it") { + t.Fatalf("not unticked:\n%s", got) + } +} + +func TestReplyFromKeyboard(t *testing.T) { + m, p := interactModel(t) + m = press(t, m, "]", "]", "a") + if !m.typing { + t.Fatal("a should open the reply line") + } + // q, x, and ] are text while typing — they must not quit, tick, or select. + m = typeText(t, m, "qx] nap") + m = special(t, m, tea.KeySpace) + m = typeText(t, m, "time") + m = special(t, m, tea.KeyBackspace) + m = special(t, m, tea.KeyEnter) + got := readFile(t, p) + if !strings.Contains(got, " Ask: What should we call it?\n Answer: qx] nap tim\n Next: answer") { + t.Fatalf("reply not written under the question:\n%s", got) + } + if m.typing || m.input != "" { + t.Fatal("typing state should clear after enter") + } +} + +func TestReplyEscAbandonsAndBlankWritesNothing(t *testing.T) { + m, p := interactModel(t) + m = press(t, m, "]", "a") + m = typeText(t, m, "nope") + m = special(t, m, tea.KeyEsc) + if m.typing || strings.Contains(readFile(t, p), "nope") { + t.Fatal("esc must abandon the reply") + } + m = press(t, m, "a") + m = typeText(t, m, " ") + special(t, m, tea.KeyEnter) + if readFile(t, p) != editBoard { + t.Fatal("a blank reply must not write") + } +} + +func TestReplyPrefillsTheCurrentReply(t *testing.T) { + m, _ := interactModel(t) + m = press(t, m, "]", "]", "a") + m = typeText(t, m, "first") + m = special(t, m, tea.KeyEnter) + m = press(t, m, "]", "]", "a") // reselect and edit + if m.input != "first" { + t.Fatalf("edit should prefill, got %q", m.input) + } +} + +func TestEditRefusedWhenAgentChangedItem(t *testing.T) { + m, p := interactModel(t) + m = press(t, m, "]") + writeFile(t, p, strings.Replace(editBoard, "Ship it", "Ship it now", 1)) + m = press(t, m, "x") + if !strings.Contains(m.notice, "changed") { + t.Fatalf("notice %q", m.notice) + } + if strings.Contains(readFile(t, p), "[x]") { + t.Fatal("stale edit was written") + } +} + +func TestViewerNeverMovesItems(t *testing.T) { + m, p := interactModel(t) + m.mouse = true + press(t, m, "]", "d", "x", "a", "esc") + got := readFile(t, p) + b, _ := parseBoard(got) + if len(b.Sections[0].Items) != 2 || len(b.Sections[2].Items) != 1 { + t.Fatalf("no key or click may move an item between sections:\n%s", got) + } +} + +func TestEscClearsItemCursor(t *testing.T) { + m, _ := interactModel(t) + m = press(t, m, "]", "esc") + if m.itemSec != -1 { + t.Fatal("esc should clear") + } +} + +func TestEditPreservesFileMode(t *testing.T) { + m, p := interactModel(t) + os.Chmod(p, 0o600) + press(t, m, "]", "x") + if st, _ := os.Stat(p); st.Mode().Perm() != 0o600 { + t.Fatalf("mode %v", st.Mode().Perm()) + } +} + +func TestCleanAnswerIsOneLine(t *testing.T) { + if got := cleanAnswer(" a\nb\t\x1b[31mc d "); got != "a b [31mc d" { + t.Fatalf("%q", got) + } +} + +const questionBoard = `# Board + +## 🧠 Needs you + +- Which name? + Ask: What should we call it? + Next: answer +- Merge now? + Ask: Anything blocking the merge? + Next: answer +- Old question + Ask: Fine? + Answer: yes + +## ✅ Done + +- shipped + Ask: Was it fine? +` + +func questionModel(t *testing.T) (model, string) { + t.Helper() + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, questionBoard) + return testModel(t, p), p +} + +func TestPendingQuestionsCountsOpenOnesOutsideDone(t *testing.T) { + m, _ := questionModel(t) + if got := m.pendingQuestions(); got != 2 { + t.Fatalf("pending = %d, want 2 (answered and Done ones excluded)", got) + } + if bar := stripANSI(m.statusBar()); !strings.Contains(bar, "2 awaiting you") { + t.Fatalf("status bar %q", bar) + } +} + +func TestQuestionNavigationSkipsAnsweredAndWraps(t *testing.T) { + m, _ := questionModel(t) + want := []string{"Which name?", "Merge now?", "Which name?"} + for i, w := range want { + m = press(t, m, "}") + if it, _, _ := m.selected(); it.Key != w { + t.Fatalf("step %d: %q, want %q", i, it.Key, w) + } + } + m = press(t, m, "{") + if it, _, _ := m.selected(); it.Key != "Merge now?" { + t.Fatalf("back: %q", it.Key) + } +} + +func TestQuestionNavigationNoneWaiting(t *testing.T) { + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, "## 🧠 Needs you\n\n- just a note\n") + m := press(t, testModel(t, p), "}") + if !strings.Contains(m.notice, "no questions") { + t.Fatalf("notice %q", m.notice) + } +} diff --git a/links.go b/links.go new file mode 100644 index 0000000..964d99a --- /dev/null +++ b/links.go @@ -0,0 +1,133 @@ +// links.go — clickable links. With mouse capture on, the terminal no longer +// handles a click on an OSC 8 hyperlink, so the viewer does: it finds the +// link under the pointer in the rendered line, paints it on hover, shows the +// full address in the status bar, and opens it on a click. +package main + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +var linkHotStyle = lipgloss.NewStyle().Bold(true).Underline(true). + Foreground(lipgloss.Color("#101010")).Background(lipgloss.Color("#61AFEF")) + +// linkSpan is a hyperlink on a rendered line: the display columns it covers +// (from inclusive, to exclusive) and its target. +type linkSpan struct { + from, to int + url string +} + +// linkSpans finds the OSC 8 hyperlinks on a rendered line, in display +// columns. It understands BEL and ESC \ terminators and skips styling +// sequences, so the columns match what the terminal draws. +func linkSpans(line string) []linkSpan { + var spans []linkSpan + col, open, target := 0, -1, "" + for i := 0; i < len(line); { + if line[i] == 0x1b && i+1 < len(line) { + switch line[i+1] { + case ']': + end, next := -1, -1 + for j := i + 2; j < len(line); j++ { + if line[j] == 0x07 { + end, next = j, j+1 + break + } + if line[j] == 0x1b && j+1 < len(line) && line[j+1] == '\\' { + end, next = j, j+2 + break + } + } + if end < 0 { + return spans + } + if payload := line[i+2 : end]; strings.HasPrefix(payload, "8;") { + parts := strings.SplitN(payload, ";", 3) + switch { + case len(parts) == 3 && parts[2] != "": + open, target = col, parts[2] + case open >= 0: + spans = append(spans, linkSpan{open, col, target}) + open = -1 + } + } + i = next + continue + case '[': + j := i + 2 + for j < len(line) && !(line[j] >= 0x40 && line[j] <= 0x7e) { + j++ + } + i = j + 1 + continue + } + } + r, size := utf8.DecodeRuneInString(line[i:]) + col += ansi.StringWidth(string(r)) + i += size + } + return spans +} + +// linkAt returns the link covering display column x on a rendered line. +func linkAt(line string, x int) (linkSpan, bool) { + for _, s := range linkSpans(line) { + if x >= s.from && x < s.to { + return s, true + } + } + return linkSpan{}, false +} + +// linkAllowed limits what a click may open. A board is written by an agent +// pasting arbitrary text, so only web and mail links are opened — never a +// file: path or an app's custom scheme. +func linkAllowed(target string) bool { + u, err := url.Parse(target) + if err != nil { + return false + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "mailto": + return true + } + return false +} + +// openURL hands a link to the OS. A variable so tests do not launch a browser. +var openURL = func(target string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", target).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Start() + } + return exec.Command("xdg-open", target).Start() +} + +func (m *model) openLink(target string) { + if !linkAllowed(target) { + m.notice = "won't open that kind of link" + return + } + if err := openURL(target); err != nil { + m.notice = fmt.Sprintf("couldn't open link: %v", err) + return + } + m.notice = "opened " + target +} + +// paintLink turns the link's cells solid on a rendered line. +func paintLink(line string, s linkSpan) string { + w := visibleWidth(line) + return ansi.Cut(line, 0, s.from) + linkHotStyle.Render(stripANSI(ansi.Cut(line, s.from, s.to))) + ansi.Cut(line, s.to, w) +} diff --git a/links_test.go b/links_test.go new file mode 100644 index 0000000..a74b95c --- /dev/null +++ b/links_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +const linkBoard = `# Board + +## 🧠 Needs you + +- Fixed it, see https://example.com/pull/7/files for details. + https://github.com/than/sidecar/pull/30 + Ask: Did the deploy behave? + Next: review. +` + +func linkModel(t *testing.T) (model, *[]string) { + t.Helper() + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, linkBoard) + m := testModel(t, p) + m.mouse = true + var opened []string + prev := openURL + openURL = func(u string) error { opened = append(opened, u); return nil } + t.Cleanup(func() { openURL = prev }) + return m, &opened +} + +func TestLinkSpansFindLabelAndBareURLColumns(t *testing.T) { + m, _ := linkModel(t) + found := map[string]linkSpan{} + for _, ln := range m.renderedLines { + for _, s := range linkSpans(ln) { + found[s.url] = s + plain := stripANSI(ln) + if s.from >= s.to || s.to > visibleWidth(plain)+1 { + t.Fatalf("span %+v outside the line %q", s, plain) + } + } + } + if _, ok := found["https://example.com/pull/7/files"]; !ok { + t.Fatalf("inline link not found: %v", found) + } + if _, ok := found["https://github.com/than/sidecar/pull/30"]; !ok { + t.Fatalf("bare URL not found: %v", found) + } +} + +func TestLinkSpanColumnsCoverTheDisplayText(t *testing.T) { + line := "ab " + hyperlink("https://x.test/a", "label") + " tail" + spans := linkSpans(line) + if len(spans) != 1 || spans[0].from != 3 || spans[0].to != 8 || spans[0].url != "https://x.test/a" { + t.Fatalf("spans %+v", spans) + } + // ST-terminated links and styling sequences before the link count the same. + st := "\x1b[31mab\x1b[0m \x1b]8;;https://y.test\x1b\\go\x1b]8;;\x1b\\" + if s := linkSpans(st); len(s) != 1 || s[0].from != 3 || s[0].to != 5 || s[0].url != "https://y.test" { + t.Fatalf("ST spans %+v", s) + } +} + +func TestClickingALinkOpensItInsteadOfTheItem(t *testing.T) { + m, opened := linkModel(t) + m = clickAt(t, m, "https://example.com/pull/7", 1) + if len(*opened) != 1 || (*opened)[0] != "https://example.com/pull/7/files" { + t.Fatalf("opened %v", *opened) + } + if !strings.HasPrefix(m.notice, "opened ") { + t.Fatalf("notice %q", m.notice) + } + if m.itemSec != -1 { + t.Fatal("a link click should not also select or tick the item") + } +} + +func TestClickingABareURLLineOpensIt(t *testing.T) { + m, opened := linkModel(t) + clickAt(t, m, "https://github.com/than", 3) + if len(*opened) != 1 || (*opened)[0] != "https://github.com/than/sidecar/pull/30" { + t.Fatalf("opened %v", *opened) + } +} + +func TestClickNextToALinkStillActsOnTheItem(t *testing.T) { + m, opened := linkModel(t) + m = clickAt(t, m, "• Fixed it", 0) // the bullet, on the same line as a link + if len(*opened) != 0 || !strings.Contains(readFile(t, m.path), "[x] Fixed it") { + t.Fatalf("bullet click should tick, opened %v", *opened) + } +} + +func TestOnlyWebAndMailLinksOpen(t *testing.T) { + for u, want := range map[string]bool{ + "https://a.test": true, "http://a.test": true, "mailto:a@b.test": true, + "file:///etc/passwd": false, "javascript:alert(1)": false, "vscode://x": false, "ssh://h": false, + } { + if linkAllowed(u) != want { + t.Fatalf("linkAllowed(%q) = %v, want %v", u, !want, want) + } + } + m, opened := linkModel(t) + m.openLink("file:///etc/passwd") + if len(*opened) != 0 || !strings.Contains(m.notice, "won't open") { + t.Fatalf("opened %v, notice %q", *opened, m.notice) + } +} + +func TestHoveringALinkPaintsItAndShowsTheAddress(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + m, _ := linkModel(t) + rest := m.vp.View() + m = mouseAt(t, m, "https://example.com/pull/7", 1, tea.MouseActionMotion, tea.MouseButtonNone) + if m.hover.kind != hoverLink || m.vp.View() == rest || !strings.Contains(m.vp.View(), linkHotStyle.Render("https://example.com/pull/7/files")) { + t.Fatalf("hovering a link should paint it (hover %+v)", m.hover) + } + if bar := stripANSI(m.statusBar()); !strings.Contains(bar, "https://example.com/pull/7/files") { + t.Fatalf("status bar should show the full address: %q", bar) + } + m = mouseAt(t, m, "Next: review", 0, tea.MouseActionMotion, tea.MouseButtonNone) + if m.hover.line != -1 || m.vp.View() != rest { + t.Fatal("moving off the link should restore it") + } +} + +func TestClicksAreHeldWhileTyping(t *testing.T) { + m, opened := linkModel(t) + m = press(t, m, "j", "a") + if !m.typing { + t.Fatal("setup: reply should be open") + } + m = clickAt(t, m, "https://example.com/pull/7", 1) + if len(*opened) != 0 || !m.typing || !strings.Contains(m.notice, "finish the reply") { + t.Fatalf("a click while typing must not act (opened %v, typing %v, notice %q)", *opened, m.typing, m.notice) + } +} diff --git a/main.go b/main.go index 8eca149..4410b5d 100644 --- a/main.go +++ b/main.go @@ -29,12 +29,20 @@ usage: sidecar [file.md] (default: .sidecar/sidecar.md) sidecar diff [file.md] print board changes since the last run sidecar --static [file] render once to stdout and exit (no TUI) sidecar --no-flash [file] disable the subtle change-flash (▸ still shows) + sidecar --no-mouse [file] start without mouse capture (native text selection) -keys: j/k, arrows, PgUp/PgDn scroll +keys: arrows, PgUp/PgDn, wheel scroll + j / k move between items (scrolls when there are none) g / G top / bottom tab / shift+tab move between sections enter / space collapse / expand section + click a • bullet, or space tick it ✓ (again to untick) + click a ? question, or enter answer the agent, in your own words + } / { next / previous open question + u undo the last change made from the viewer + esc deselect r force reload + M mouse on/off. On by default; Shift-drag selects text. q quit The file doesn't have to exist yet — sidecar waits for it and renders the @@ -61,7 +69,7 @@ func main() { if code != 0 || open == "" { os.Exit(code) } - os.Exit(runViewer(open, false)) + os.Exit(runViewer(open, false, false)) case "diff": os.Exit(runDiff(os.Args[2:])) } @@ -69,11 +77,13 @@ func main() { // Viewer mode: an optional file path plus the --no-flash flag, any order. path := defaultBoardPath() - noFlash := false + noFlash, noMouse := false, false for _, a := range os.Args[1:] { switch { case a == "--no-flash": noFlash = true + case a == "--no-mouse": + noMouse = true case strings.HasPrefix(a, "-"): fmt.Fprintf(os.Stderr, "sidecar: unknown flag %q\n\n", a) fmt.Print(help) @@ -91,21 +101,29 @@ func main() { offerCreate(abs) // if missing and interactive, offer to scaffold before opening - os.Exit(runViewer(abs, noFlash)) + os.Exit(runViewer(abs, noFlash, noMouse)) } // runViewer opens the live viewer on abs and blocks until the human quits. // Shared by viewer mode and by `sidecar init`, which opens the board it just // created rather than printing a command to run. -func runViewer(abs string, noFlash bool) int { - p := tea.NewProgram(newModel(abs, noFlash), +func runViewer(abs string, noFlash, noMouse bool) int { + model := newModel(abs, noFlash) + opts := []tea.ProgramOption{ tea.WithAltScreen(), // The renderer wakes every frame even when idle; the default 60fps // costs ~0.5% CPU per open board. 10fps keeps scrolling responsive. tea.WithFPS(10), - // No mouse capture: keeps the terminal's native text selection and - // clickable links working. Scroll with the keyboard (see keys below). - ) + } + // Mouse capture is on by default so buttons, checkboxes, and section + // headers respond to clicks. It costs the terminal's plain drag-to-select + // (hold Shift, or Option in iTerm2, to select anyway); --no-mouse starts + // without it and M toggles it while running. + if !noMouse { + model.mouse = true + opts = append(opts, tea.WithMouseAllMotion()) + } + p := tea.NewProgram(model, opts...) go watchFile(abs, p.Send) if _, err := p.Run(); err != nil { diff --git a/marks.go b/marks.go new file mode 100644 index 0000000..d8a9a06 --- /dev/null +++ b/marks.go @@ -0,0 +1,283 @@ +// marks.go — how the human's side of the board is drawn. The file stays plain +// markdown; at display time the viewer: +// +// - shows an item's "Ask: …" line as "? …" in the accent color — a question +// you can click, or answer with a; +// - shows the "Answer: …" line as "↳ …"; +// - puts one blank line between items; +// - paints the bullet, or the question, solid under the pointer. +// +// The bullet is the control: a click turns • into ✓ (the file gets "- [x]") +// and a second click turns it back. The viewer never moves an item between +// sections — the agent owns that; this is only the channel back to it. +package main + +import ( + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +var ( + questionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")) + questionHotStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.Color("#E5C07B")) + replyLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#98C379")) + bulletHotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#101010")).Background(lipgloss.Color("#E5C07B")) + typingStyle = lipgloss.NewStyle().Bold(true). + Foreground(lipgloss.Color("#101010")).Background(lipgloss.Color("#E5C07B")) + hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B93A5")) +) + +const ( + questionPrefix = "? " + replyPrefix = "↳ " +) + +// finishedLabel reports a ✅ Done or 📦 Shipped heading, with or without the +// " (N)" item count the display rewrite appends. +func finishedLabel(label string) bool { + label = strings.TrimSpace(label) + if i := strings.LastIndex(label, " ("); i >= 0 && strings.HasSuffix(label, ")") { + label = label[:i] + } + return strings.HasSuffix(label, "Done") || strings.HasSuffix(label, "Shipped") +} + +// displayText is the board as rendered: collapsed sections dropped and counts +// added, then each item's Ask: line redrawn as a question and its Answer: line +// as a reply. The line count never changes. +func displayText(raw string, board Board, collapsed map[string]bool) string { + collapsedRaw := applyCollapse(raw, board, collapsed) + b, ok := parseBoard(collapsedRaw) + if !ok { + return collapsedRaw + } + lines := strings.Split(collapsedRaw, "\n") + for _, s := range b.Sections { + for _, it := range s.Items { + for i := it.StartLine + 1; i <= it.EndLine && i < len(lines); i++ { + t := strings.TrimSpace(lines[i]) + indent := lines[i][:len(lines[i])-len(strings.TrimLeft(lines[i], " \t"))] + switch { + case strings.HasPrefix(t, "Ask:"): + lines[i] = indent + questionPrefix + plainMark(strings.TrimSpace(strings.TrimPrefix(t, "Ask:"))) + case strings.HasPrefix(t, "Answer:"): + lines[i] = indent + replyPrefix + plainMark(strings.TrimSpace(strings.TrimPrefix(t, "Answer:"))) + } + } + } + } + return strings.Join(lines, "\n") +} + +// renderBoardLines renders the display markdown, colors the question and +// reply lines, and spaces the items. board must be the parse of the file the +// display text came from, so items line up with the rendered bullets. +func renderBoardLines(display string, board Board, width int) ([]string, error) { + rendered, err := renderMarkdown(display, width, true) + if err != nil { + return nil, err + } + lines := strings.Split(rendered, "\n") + if len(board.Sections) == 0 { + return lines, nil + } + starts := itemStartLines(lines, sectionHeaderLines(lines), board) + lines = rewriteMarks(lines, starts, board, width) + starts = itemStartLines(lines, sectionHeaderLines(lines), board) // the reflow can change line counts + return spaceItems(lines, starts), nil +} + +// plainMark is question or reply text as glamour will show it: the markdown +// emphasis and code marks are dropped, so the words can be found again in the +// rendered lines. +func plainMark(s string) string { return strings.NewReplacer("`", "", "*", "").Replace(s) } + +// markBlock finds the rendered lines of a question or reply inside an item's +// lines [from, to): the line that begins with prefix, plus the lines glamour +// wrapped it onto — followed by matching the words of text one by one. ok is +// false when no line begins with prefix. +func markBlock(lines []string, from, to int, prefix, text string) (first, last int, ok bool) { + words := strings.Fields(text) + for l := from; l < to && l < len(lines); l++ { + t := strings.TrimLeft(strings.TrimRight(stripANSI(lines[l]), " "), " ") + if !strings.HasPrefix(t, prefix) { + continue + } + got := strings.Fields(strings.TrimPrefix(t, prefix)) + last = l + for last+1 < to && last+1 < len(lines) && len(got) < len(words) { + more := strings.Fields(stripANSI(lines[last+1])) + if len(more) == 0 || len(got)+len(more) > len(words) || !equalWords(words[len(got):len(got)+len(more)], more) { + break + } + got = append(got, more...) + last++ + } + return l, last, true + } + return 0, 0, false +} + +func equalWords(a, b []string) bool { + for i := range b { + if a[i] != b[i] { + return false + } + } + return true +} + +// reflow lays "prefix text" out under an item: indented two, wrapped to width, +// continuation lines hanging under the text, every line in style. +func reflow(prefix, text string, width int, style lipgloss.Style) []string { + const indent = " " + hang := strings.Repeat(" ", len(indent)+visibleWidth(prefix)) + cur, curW, fresh := indent+prefix, len(indent)+visibleWidth(prefix), true + var rows []string + for _, w := range strings.Fields(text) { + ww := visibleWidth(w) + if !fresh && curW+1+ww > width { + rows = append(rows, style.Render(cur)) + cur, curW, fresh = hang, len(hang), true + } + if !fresh { + cur += " " + curW++ + } + cur += w + curW += ww + fresh = false + } + return append(rows, style.Render(cur)) +} + +// rewriteMarks redraws each mapped item's question and reply as an indented, +// wrapped block in one color — glamour wraps them at column 0 and leaves the +// wrapped lines uncolored. +func rewriteMarks(lines []string, starts [][]int, board Board, width int) []string { + type swap struct { + first, last int + rows []string + } + var swaps []swap + for si, ss := range starts { + for ii, start := range ss { + it := board.Sections[si].Items[ii] + end := itemEnd(lines, ss, ii) + for _, mk := range []struct { + prefix, text string + style lipgloss.Style + }{ + {questionPrefix, plainMark(askText(it)), questionStyle}, + {replyPrefix, plainMark(answerOf(it)), replyLineStyle}, + } { + if mk.text == "" { + continue + } + if f, l, ok := markBlock(lines, start+1, end, mk.prefix, mk.text); ok { + swaps = append(swaps, swap{f, l, reflow(mk.prefix, mk.text, width, mk.style)}) + } + } + } + } + sort.Slice(swaps, func(i, j int) bool { return swaps[i].first < swaps[j].first }) + var out []string + next := 0 + for i := 0; i < len(lines); i++ { + if next < len(swaps) && i == swaps[next].first { + out = append(out, swaps[next].rows...) + i = swaps[next].last + next++ + continue + } + out = append(out, lines[i]) + } + return out +} + +// itemEnd is the index one past an item's last rendered line: the next +// item's start, or the first blank line, backed up over trailing blanks. +func itemEnd(lines []string, starts []int, ii int) int { + start := starts[ii] + end := start + 1 + if ii+1 < len(starts) { + end = starts[ii+1] + } else { + for end < len(lines) && strings.TrimSpace(stripANSI(lines[end])) != "" { + end++ + } + } + for end-1 > start && strings.TrimSpace(stripANSI(lines[end-1])) == "" { + end-- + } + return end +} + +// spaceItems puts one blank line after every item whose next line is not +// already blank, so a long list does not read as a wall. +func spaceItems(lines []string, starts [][]int) []string { + insert := map[int]bool{} + for _, ss := range starts { + for ii := range ss { + end := itemEnd(lines, ss, ii) + if end < len(lines) && strings.TrimSpace(stripANSI(lines[end])) != "" { + insert[end] = true + } + } + } + var out []string + for i, ln := range lines { + if insert[i] { + out = append(out, "") + } + out = append(out, ln) + } + return out +} + +// hoverKind is what the pointer is over. +type hoverKind int + +const ( + hoverNone hoverKind = iota + hoverBullet + hoverQuestion + hoverLink +) + +// hoverTarget is the clickable thing under the pointer: the rendered lines +// it covers (line through last) and what it is. +type hoverTarget struct { + line, last int + kind hoverKind + link linkSpan // for hoverLink +} + +// bulletCells is how many columns from the left edge tick an item — the +// bullet, its space, and a little slack, so the target is easy to hit. +const bulletCells = 4 + +// isQuestionLine reports a rendered line that starts a question. +func isQuestionLine(rendered string) bool { + return strings.HasPrefix(strings.TrimLeft(strings.TrimRight(stripANSI(rendered), " "), " "), questionPrefix) +} + +// paintHover repaints what the pointer is over, on lines the caller has +// already copied: the leading cells of a bullet turn solid, and every line of +// a question goes bold and underlined. +func paintHover(lines []string, h hoverTarget) { + switch h.kind { + case hoverLink: + lines[h.line] = paintLink(lines[h.line], h.link) + case hoverBullet: + l := lines[h.line] + lines[h.line] = bulletHotStyle.Render(stripANSI(ansi.Cut(l, 0, bulletCells))) + ansi.Cut(l, bulletCells, visibleWidth(l)) + case hoverQuestion: + for i := h.line; i <= h.last && i < len(lines); i++ { + lines[i] = questionHotStyle.Render(strings.TrimRight(stripANSI(lines[i]), " ")) + } + } +} diff --git a/marks_test.go b/marks_test.go new file mode 100644 index 0000000..b4d3c31 --- /dev/null +++ b/marks_test.go @@ -0,0 +1,416 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +const marksBoard = `# Board + +## 🧠 Needs you + +- Config rewritten — the deploy script now reads the new keys. + Ask: What did you change on your side in the meantime? + Next: reply + +## 🤖 Agent queue + +- Second item. + +## ✅ Done + +- old +` + +func marksModel(t *testing.T) (model, string) { + t.Helper() + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, marksBoard) + m := testModel(t, p) + m.mouse = true + return m, p +} + +// plainLines renders a board the way the viewer does and strips the color. +func plainLines(t *testing.T, raw string, collapsed map[string]bool, width int) []string { + t.Helper() + b, _ := parseBoard(raw) + lines, err := renderBoardLines(displayText(raw, b, collapsed), b, width) + if err != nil { + t.Fatal(err) + } + for i := range lines { + lines[i] = strings.TrimRight(stripANSI(lines[i]), " ") + } + return lines +} + +func lineWith(m model, text string) (int, string) { + for i, ln := range m.renderedLines { + if plain := stripANSI(ln); strings.Contains(plain, text) { + return i, plain + } + } + return -1, "" +} + +func mouseAt(t *testing.T, m model, text string, x int, action tea.MouseAction, b tea.MouseButton) model { + t.Helper() + i, plain := lineWith(m, text) + if i < 0 { + t.Fatalf("no rendered line contains %q", text) + } + col := visibleWidth(plain[:strings.Index(plain, text)]) + x + next, _ := m.Update(tea.MouseMsg{X: col, Y: i - m.vp.YOffset, Action: action, Button: b}) + return next.(model) +} + +func clickAt(t *testing.T, m model, text string, x int) model { + return mouseAt(t, m, text, x, tea.MouseActionPress, tea.MouseButtonLeft) +} + +func TestAskAndAnswerLinesAreRedrawn(t *testing.T) { + raw := marksBoard + "" + raw = strings.Replace(raw, " Next: reply", " Answer: restarted the worker\n Next: reply", 1) + got := strings.Join(plainLines(t, raw, nil, 80), "\n") + if !strings.Contains(got, "? What did you change on your side in the meantime?") || !strings.Contains(got, "↳ restarted the worker") { + t.Fatalf("question/reply not drawn:\n%s", got) + } + if strings.Contains(got, "Ask:") || strings.Contains(got, "Answer:") { + t.Fatalf("raw keywords leaked:\n%s", got) + } +} + +func TestNoEmojiOrButtonsAreDrawnAroundItems(t *testing.T) { + got := strings.Join(plainLines(t, marksBoard, nil, 80), "\n") + for _, bad := range []string{"[ ", "💬", "Reply", "Done ]"} { + if strings.Contains(got, bad) { + t.Fatalf("%q must not appear in the item area:\n%s", bad, got) + } + } +} + +func TestOneBlankLineSeparatesItems(t *testing.T) { + raw := "## 🧠 Needs you\n\n- first\n- second\n more\n- third\n\n## ✅ Done\n\n- a\n- b\n" + text := strings.Join(plainLines(t, raw, nil, 60), "\n") + for _, pair := range [][2]string{{"• first", "• second"}, {"more", "• third"}, {"• a", "• b"}} { + a, b := strings.Index(text, pair[0]), strings.Index(text, pair[1]) + if between := text[a:b]; !strings.Contains(between, "\n\n") || strings.Contains(between, "\n\n\n") { + t.Fatalf("want exactly one blank line between %q and %q, got %q\n%s", pair[0], pair[1], between, text) + } + } + if strings.Contains(text, "\n\n\n") { + t.Fatalf("no run of blank lines anywhere:\n%s", text) + } +} + +func TestClickBulletTicksAndClickAgainUnticks(t *testing.T) { + m, p := marksModel(t) + m = clickAt(t, m, "• Config rewritten", 0) + if !strings.Contains(readFile(t, p), "- [x] Config rewritten") { + t.Fatalf("bullet click should tick:\n%s", readFile(t, p)) + } + if v := stripANSI(m.vp.View()); !strings.Contains(v, "✓ Config rewritten") { + t.Fatalf("the bullet should now draw as ✓:\n%s", v) + } + m = clickAt(t, m, "✓ Config rewritten", 1) + if got := readFile(t, p); strings.Contains(got, "[x]") || !strings.Contains(got, "- Config rewritten") { + t.Fatalf("second click should untick to a plain bullet:\n%s", got) + } +} + +func TestClickQuestionOpensInlineReply(t *testing.T) { + m, p := marksModel(t) + m = clickAt(t, m, "? What did you change", 4) + if !m.typing { + t.Fatal("clicking the question should open the reply line") + } + m = typeText(t, m, "Restarted the worker by hand") + if v := stripANSI(m.vp.View()); !strings.Contains(v, "↳ Restarted the worker by hand▌") || !strings.Contains(v, "⏎ send") { + t.Fatalf("reply should be typed under the question with a hint:\n%s", v) + } + special(t, m, tea.KeyEnter) + if got := readFile(t, p); !strings.Contains(got, " Ask: What did you change on your side in the meantime?\n Answer: Restarted the worker by hand\n") { + t.Fatalf("reply not written under the question:\n%s", got) + } +} + +func TestEmptyReplyLineSaysWhatToDo(t *testing.T) { + m, _ := marksModel(t) + m = clickAt(t, m, "? What did you change", 4) + if v := stripANSI(m.vp.View()); !strings.Contains(v, "type your reply…") { + t.Fatalf("placeholder missing:\n%s", v) + } + m = special(t, m, tea.KeyEsc) + if v := stripANSI(m.vp.View()); strings.Contains(v, "type your reply") || strings.Contains(v, "↳") { + t.Fatalf("esc should remove the reply line:\n%s", v) + } +} + +func TestReplyOnItemWithoutQuestionIsInsertedAfterIt(t *testing.T) { + m, p := marksModel(t) + m = press(t, m, "]", "]", "a") // "Second item." + m = typeText(t, m, "unprompted note") + var rows []string + for _, l := range strings.Split(stripANSI(m.vp.View()), "\n") { + rows = append(rows, strings.TrimRight(l, " ")) + } + v := strings.Join(rows, "\n") + if !strings.Contains(v, "• Second item.\n ↳ unprompted note▌") { + t.Fatalf("typed line should appear under the item:\n%s", v) + } + special(t, m, tea.KeyEnter) + if got := readFile(t, p); !strings.Contains(got, "- Second item.\n Answer: unprompted note\n") { + t.Fatalf("got:\n%s", got) + } +} + +func TestClickElsewhereOnAnItemOnlySelectsAndExplains(t *testing.T) { + m, p := marksModel(t) + m = clickAt(t, m, "Next: reply", 0) + if m.itemSec < 0 || readFile(t, p) != marksBoard || !strings.Contains(m.notice, "click its bullet") { + t.Fatalf("a click off the controls should select and explain, notice %q", m.notice) + } +} + +func TestClickIgnoredWhenMouseIsOff(t *testing.T) { + m, p := marksModel(t) + m.mouse = false + clickAt(t, m, "• Config rewritten", 0) + if readFile(t, p) != marksBoard { + t.Fatal("clicks must not act while mouse is off") + } +} + +func TestNothingMovesBetweenSections(t *testing.T) { + m, p := marksModel(t) + m = clickAt(t, m, "• Config rewritten", 0) + m = clickAt(t, m, "? What did you change", 4) + m = typeText(t, m, "ok") + special(t, m, tea.KeyEnter) + b, _ := parseBoard(readFile(t, p)) + if len(b.Sections[0].Items) != 1 || len(b.Sections[1].Items) != 1 || len(b.Sections[2].Items) != 1 { + t.Fatalf("the human never files items:\n%s", readFile(t, p)) + } +} + +func TestHoverPaintsBulletAndQuestion(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + m, _ := marksModel(t) + m = press(t, m, "M", "M") // toggle off and back on: hover state resets cleanly + rest := m.vp.View() + + m = mouseAt(t, m, "• Config rewritten", 0, tea.MouseActionMotion, tea.MouseButtonNone) + if m.hover.kind != hoverBullet || m.vp.View() == rest || !strings.Contains(m.vp.View(), bulletHotStyle.Render("• Co")) { + t.Fatalf("hovering the bullet should paint it solid (hover %+v)", m.hover) + } + m = mouseAt(t, m, "? What did you change", 4, tea.MouseActionMotion, tea.MouseButtonNone) + if m.hover.kind != hoverQuestion || !strings.Contains(m.vp.View(), questionHotStyle.Render(" ? What did you change on your side in the meantime?")) { + t.Fatalf("hovering the question should underline it (hover %+v)", m.hover) + } + m = mouseAt(t, m, "Next: reply", 0, tea.MouseActionMotion, tea.MouseButtonNone) + if m.hover.line != -1 || m.vp.View() != rest { + t.Fatal("moving off the controls should return everything to rest") + } +} + +func TestHoverIgnoredWithMouseOff(t *testing.T) { + m, _ := marksModel(t) + m.mouse = false + m = mouseAt(t, m, "• Config rewritten", 0, tea.MouseActionMotion, tea.MouseButtonNone) + if m.hover.line != -1 { + t.Fatal("no hover while mouse is off") + } +} + +func TestUndoRestoresTheBoardAndRefusesAfterAgentEdits(t *testing.T) { + m, p := marksModel(t) + m = clickAt(t, m, "• Config rewritten", 0) + if !strings.Contains(m.notice, "u to undo") { + t.Fatalf("notice %q should offer undo", m.notice) + } + m = press(t, m, "u") + if readFile(t, p) != marksBoard || !strings.HasPrefix(m.notice, "undone") { + t.Fatalf("undo did not restore the board (notice %q):\n%s", m.notice, readFile(t, p)) + } + m = clickAt(t, m, "• Config rewritten", 0) + writeFile(t, p, readFile(t, p)+"- agent added this\n") + m = press(t, m, "u") + if !strings.Contains(readFile(t, p), "- agent added this") || !strings.Contains(m.notice, "changed since") { + t.Fatalf("undo overwrote the agent's edit (notice %q)", m.notice) + } +} + +func TestClickingTheStatusMessageUndoes(t *testing.T) { + m, p := marksModel(t) + m = clickAt(t, m, "• Config rewritten", 0) + m.Update(tea.MouseMsg{X: 5, Y: m.vp.Height, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) + if readFile(t, p) != marksBoard { + t.Fatal("clicking the status bar should undo the last change") + } +} + +func TestUKeyFallsThroughWhenNothingToUndo(t *testing.T) { + m, _ := marksModel(t) + if m = press(t, m, "u"); m.notice != "" { + t.Fatalf("u with nothing to undo should scroll, got notice %q", m.notice) + } +} + +const wideBoard = `# Board + +## 🧠 Needs you + +- PR #30 lets the human answer the agent from the board, with a long line that wraps at every pane width the test tries. It ends mid sentence so nothing hides in slack space. + https://github.com/than/sidecar/pull/30 + Ask: What did you change on your side while this ran, and did the deploy behave? + Next: review and merge. + +## 🚧 In progress + +- Short one. +- Second short one with **bold** and a [link](https://example.com/a/long/path/that/keeps/going). + +## ✅ Done + +- shipped +` + +// The bullet and the question must be clickable however the pane wraps. +func TestControlsWorkAtEveryWidth(t *testing.T) { + for w := 24; w <= 200; w += 7 { + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, wideBoard) + m := newModel(p, false) + m.mouse = true + next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: 80}) + m = next.(model) + + if m2 := clickAt(t, m, "• Short one.", 0); !strings.Contains(readFile(t, p), "- [x] Short one.") { + t.Fatalf("width %d: bullet click did nothing (notice %q)", w, m2.notice) + } + writeFile(t, p, wideBoard) + m3 := clickAt(t, m, "? What", 2) + if !m3.typing { + t.Fatalf("width %d: question click did nothing (notice %q)", w, m3.notice) + } + } +} + +const longQuestionBoard = `# Board + +## 🧠 Needs you + +- Result of the turn. + Ask: After relaunching, does ticking a bullet and answering a question feel right, or is something still missing from the flow? + Next: review. +` + +func TestQuestionIsIndentedAndOneColorOnEveryWrappedLine(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, longQuestionBoard) + m := testModel(t, p) // 60 wide: the question wraps + var block []string + for _, ln := range m.renderedLines { + plain := stripANSI(ln) + if strings.HasPrefix(plain, " ? ") || (len(block) > 0 && strings.HasPrefix(plain, " ") && strings.TrimSpace(plain) != "") { + if !strings.HasPrefix(ln, "\x1b") || !strings.Contains(ln, "229;192;123") { + t.Fatalf("question line lost its color: %q", ln) + } + block = append(block, plain) + } + } + if len(block) < 2 { + t.Fatalf("expected the question to wrap over several lines, got %q", block) + } + for _, l := range block[1:] { + if !strings.HasPrefix(l, " ") { + t.Fatalf("wrapped lines should hang under the text: %q", l) + } + } + if !strings.Contains(strings.Join(strings.Fields(strings.Join(block, " ")), " "), "still missing from the flow?") { + t.Fatalf("question text lost: %q", block) + } +} + +func TestClickOnAWrappedQuestionLineOpensReply(t *testing.T) { + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, longQuestionBoard) + m := testModel(t, p) + m.mouse = true + m = clickAt(t, m, "still missing", 1) // the last wrapped line + if !m.typing { + t.Fatalf("any line of the question should open the reply (notice %q)", m.notice) + } +} + +func TestBulletTargetIsFourCellsWide(t *testing.T) { + for _, x := range []int{0, 1, 2, 3} { + m, p := marksModel(t) + clickAt(t, m, "• Config rewritten", x) + if !strings.Contains(readFile(t, p), "[x] Config") { + t.Fatalf("a click %d cells in should tick", x) + } + } + m, p := marksModel(t) + clickAt(t, m, "• Config rewritten", 4) + if readFile(t, p) != marksBoard { + t.Fatal("a click past the target must not tick") + } +} + +func TestJKMoveBetweenItemsAndScrollWhenThereAreNone(t *testing.T) { + m, _ := marksModel(t) + m = press(t, m, "j") + if it, _, ok := m.selected(); !ok || !strings.HasPrefix(it.Key, "Config rewritten") { + t.Fatalf("j should select the first item, got %+v", it) + } + m = press(t, m, "j") + if it, _, _ := m.selected(); it.Key != "Second item." { + t.Fatalf("j should move down, got %q", it.Key) + } + m = press(t, m, "k") + if it, _, _ := m.selected(); !strings.HasPrefix(it.Key, "Config rewritten") { + t.Fatalf("k should move up, got %q", it.Key) + } + // No items at all: j is plain scrolling. + p := filepath.Join(t.TempDir(), "sidecar.md") + writeFile(t, p, manyLines(100, "x")) + s := testModel(t, p) + before := s.vp.YOffset + s = press(t, s, "j") + if s.vp.YOffset != before+1 { + t.Fatalf("with nothing to select j should scroll (offset %d → %d)", before, s.vp.YOffset) + } +} + +func TestSpaceTicksAndEnterReplies(t *testing.T) { + m, p := marksModel(t) + m = press(t, m, "j", " ") + if !strings.Contains(readFile(t, p), "- [x] Config rewritten") { + t.Fatalf("space should tick the selected item:\n%s", readFile(t, p)) + } + m = special(t, m, tea.KeyEnter) + if !m.typing { + t.Fatal("enter should open the reply") + } +} + +func TestTabLeavesItemSelectionForSections(t *testing.T) { + m, _ := marksModel(t) + m = press(t, m, "j") + m = special(t, m, tea.KeyTab) + if m.itemSec != -1 { + t.Fatal("tab moves between sections and drops the item selection") + } +} diff --git a/sections.go b/sections.go index 68101f4..6846244 100644 --- a/sections.go +++ b/sections.go @@ -146,6 +146,7 @@ func entryStyleRules(sections []Section, bullet string) string { "two sentences of detail — more belongs in the PR or issue you link", "bare URLs, each on its own line", "one `Next:` line naming the single next action", + "optionally an indented `Ask:` line with one narrative question, which the human answers with an `Answer:` line", "entry text on one line — never hard-wrap; the viewer wraps to the pane and source newlines become visible breaks", } { b.WriteString(bullet + r + "\n") @@ -159,6 +160,7 @@ func entryStyleRules(sections []Section, bullet string) string { } b.WriteString(" until it needs a decision, and then the `Next:` line asks for that decision.\n") } + b.WriteString("\nWhen a turn ends, leave its result as an entry and, under it, an indented `Ask:` line with one narrative question — \"what did you change on your side?\", \"how did the deploy go?\" — never a yes/no. The human sees it as `? …` and answers under it; the answer arrives as an `Answer:` line, and sidecar's hook reports it after your next tool call, so read it, act on it, then clear the `Ask:` and `Answer:` lines or move the entry. The human can also click any entry's bullet, which turns it to `✓` (`- [x]`): read that as \"I did this\". The human never moves entries between sections — filing them stays yours.\n") return b.String() } diff --git a/semdiff.go b/semdiff.go index d823d3c..9849729 100644 --- a/semdiff.go +++ b/semdiff.go @@ -66,9 +66,9 @@ func semanticDiff(old, new Board) []string { ot, nt := sectionTag(o.section), sectionTag(n.section) switch { case ot != nt: - moved = append(moved, fmt.Sprintf("moved %s→%s: %q", ot, nt, title(n.item.Key))) + moved = append(moved, fmt.Sprintf("moved %s→%s: %q%s", ot, nt, title(n.item.Key), humanChange(o.item, n.item))) case o.item.Raw != n.item.Raw: - edited = append(edited, fmt.Sprintf("edited %s: %q", nt, title(n.item.Key))) + edited = append(edited, fmt.Sprintf("edited %s: %q%s", nt, title(n.item.Key), humanChange(o.item, n.item))) } } @@ -361,3 +361,27 @@ func hunkHeader(o, dels, n, adds int) string { } return fmt.Sprintf("@@ -%s +%s @@", side(o, dels), side(n, adds)) } + +// humanChange names the reader-facing state changes the viewer writes — a +// ticked bullet or a reply — so the agent sees the decision in the diff +// itself, not just that the item changed. +func humanChange(old, new BoardItem) string { + var out string + switch ot, nt := isTicked(old), isTicked(new); { + case !ot && nt: + out += " — ticked" + case ot && !nt: + out += " — unticked" + } + if a := answerOf(new); a != answerOf(old) && a != "" { + out += fmt.Sprintf(" — replied %q", truncateRunes(a, 400)) + } + return out +} + +func truncateRunes(s string, n int) string { + if r := []rune(s); len(r) > n { + return string(r[:n]) + "…" + } + return s +} diff --git a/ui.go b/ui.go index 4f9485a..1d4e37d 100644 --- a/ui.go +++ b/ui.go @@ -82,10 +82,22 @@ type model struct { collapsed map[string]bool cursor int headerLines []int + + // Interactive updates (interact.go). itemSec/itemIdx select a board + // item, -1 for none; itemStarts maps items to rendered lines. notice is + // a one-shot status-bar message; mouse is mouse mode (M). + itemSec, itemIdx int + itemStarts [][]int + notice string + mouse bool + typing bool // a reply is being typed under the selected item + input string // the reply typed so far + hover hoverTarget + undo []undoEntry } func newModel(path string, noFlash bool) model { - return model{path: path, noFlash: noFlash, collapsed: map[string]bool{}, cursor: -1} + return model{path: path, noFlash: noFlash, collapsed: map[string]bool{}, cursor: -1, itemSec: -1, itemIdx: -1, hover: hoverTarget{line: -1}} } func (m model) Init() tea.Cmd { @@ -98,10 +110,52 @@ func tick() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseMsg: + if m.mouse && msg.Action == tea.MouseActionMotion { + m.setHover(msg.X, msg.Y) + return m, nil + } + if m.mouse && msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + m.notice = fmt.Sprintf("click %d,%d", msg.X, msg.Y) // proof the click arrived, if nothing acts on it + m.mouseClick(msg.X, msg.Y) + return m, nil + } + case tea.KeyMsg: + if m.typing { + m.typeKey(msg) + return m, nil + } + m.notice = "" + if m.itemKey(msg.String()) { + return m, nil + } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "j", "]": + if m.moveItemCursor(1) { + return m, nil + } + case "k", "[": + if m.moveItemCursor(-1) { + return m, nil + } + case "}": + m.moveQuestionCursor(1) + return m, nil + case "{": + m.moveQuestionCursor(-1) + return m, nil + case "u": + if len(m.undo) > 0 { + m.undoLast() + return m, nil + } + case "M": + return m, m.toggleMouse() + case "esc": + return m, nil case "r": if m.reload(true) { m.flashGen++ @@ -123,9 +177,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.vp.GotoBottom() return m, nil case "tab": + m.itemSec, m.itemIdx = -1, -1 // Tab moves between sections, not items m.moveCursor(1) return m, nil case "shift+tab": + m.itemSec, m.itemIdx = -1, -1 m.moveCursor(-1) return m, nil case "enter", " ": @@ -280,10 +336,10 @@ func (m *model) reload(force bool) (changed bool) { } displayRaw := raw if boardOK { - displayRaw = applyCollapse(raw, board, m.collapsed) + displayRaw = displayText(raw, board, m.collapsed) } - rendered, err := renderMarkdown(displayRaw, m.renderWidth(), true) + lines, err := renderBoardLines(displayRaw, m.board, m.renderWidth()) if err != nil { m.loadErr = err m.vp.SetContent(fmt.Sprintf("\n Render error: %v", err)) @@ -293,8 +349,9 @@ func (m *model) reload(force bool) (changed bool) { m.hasBaseline = false return false } - lines := strings.Split(rendered, "\n") m.headerLines = sectionHeaderLines(lines) + m.itemStarts = itemStartLines(lines, m.headerLines, m.board) + m.fixItemCursor() var changedMap map[int]bool if m.hasBaseline { @@ -305,10 +362,11 @@ func (m *model) reload(force bool) (changed bool) { // a live viewer setting, not a property of any one file revision. baseDisplay := m.prevBaseline if baseBoard, ok := parseBoard(m.prevBaseline); ok { - baseDisplay = applyCollapse(m.prevBaseline, baseBoard, m.collapsed) + baseDisplay = displayText(m.prevBaseline, baseBoard, m.collapsed) } - if base, berr := renderMarkdown(baseDisplay, m.renderWidth(), true); berr == nil { - changedMap = changedLines(strings.Split(base, "\n"), lines) + baseBoard, _ := parseBoard(m.prevBaseline) + if base, berr := renderBoardLines(baseDisplay, baseBoard, m.renderWidth()); berr == nil { + changedMap = changedLines(base, lines) } } m.renderedLines = lines @@ -326,8 +384,27 @@ func (m *model) reload(force bool) (changed bool) { // changed/flash marking (composeMarked) and the section-cursor highlight // (applyCursorHighlight) — at the current flash and cursor state. func (m *model) compose() string { - display := composeMarked(m.renderedLines, m.changed, m.lineFlash && !m.noFlash, m.renderWidth()) - return applyCursorHighlight(display, m.headerLines, m.cursor, m.renderWidth()) + lines := m.renderedLines + // Overlays that swap or add a line work on a copy, before any tint is + // layered on: the hovered bullet or question turns solid, and a reply + // being typed is drawn under its item. + if m.hover.line >= 0 && m.hover.last < len(lines) { + lines = append([]string(nil), lines...) + paintHover(lines, m.hover) + } + if f, l, text, insert, ok := m.typingRow(m.renderWidth()); ok && l <= len(lines) { + copied := append([]string(nil), lines[:f]...) + copied = append(copied, text) + if !insert { + l++ + } else { + l = f + } + lines = append(copied, lines[l:]...) + } + display := composeMarked(lines, m.changed, m.lineFlash && !m.noFlash, m.renderWidth()) + display = applyCursorHighlight(display, m.headerLines, m.cursor, m.renderWidth()) + return applyItemHighlight(display, m.itemStarts, m.itemSec, m.itemIdx, m.renderWidth()) } // recompose re-renders the cached lines for the current flash state without @@ -350,13 +427,15 @@ func (m *model) rerenderCollapse() { if !m.ready || m.fileMissing || m.loadErr != nil || len(m.board.Sections) == 0 { return } - displayRaw := applyCollapse(m.raw, m.board, m.collapsed) - rendered, err := renderMarkdown(displayRaw, m.renderWidth(), true) + displayRaw := displayText(m.raw, m.board, m.collapsed) + lines, err := renderBoardLines(displayRaw, m.board, m.renderWidth()) if err != nil { return // m.raw already rendered fine on the last successful reload } - m.renderedLines = strings.Split(rendered, "\n") + m.renderedLines = lines m.headerLines = sectionHeaderLines(m.renderedLines) + m.itemStarts = itemStartLines(m.renderedLines, m.headerLines, m.board) + m.fixItemCursor() m.changed = nil offset := m.vp.YOffset m.vp.SetContent(m.compose()) @@ -477,8 +556,27 @@ func (m model) statusBar() string { } left := " 🚗 " + name + " " - info := "· " + updated pct := fmt.Sprintf(" %3.0f%% ", m.vp.ScrollPercent()*100) + info := "· " + updated + if q := m.pendingQuestions(); q > 0 && !m.fileMissing && m.loadErr == nil { + info += fmt.Sprintf(" · %d awaiting you", q) + } + switch h := m.hint(); { + case m.typing: + const lead = "· answer: " + room := max(0, m.width-visibleWidth(left)-visibleWidth(pct)-visibleWidth(lead)-1) + shown := m.input + for visibleWidth(shown) > room && shown != "" { + shown = string([]rune(shown)[1:]) // keep the end, where the cursor is + } + info = lead + shown + "▌" + case m.hover.kind == hoverLink && m.hover.line >= 0: + info = "· ↗ " + m.hover.link.url + case m.notice != "": + info = "· " + m.notice + case h != "": + info = "· " + h + } pad := m.width - visibleWidth(left) - visibleWidth(info) - visibleWidth(pct) if pad < 0 { @@ -526,3 +624,15 @@ func humanSince(d time.Duration) string { return fmt.Sprintf("%dd", int(d.Hours()/24)) } } + +// fixItemCursor drops the item selection when the file changed under it — +// the item is gone, or its section can no longer be mapped to rendered +// lines — rather than leave the tint on an unrelated line. +func (m *model) fixItemCursor() { + if m.itemSec < 0 { + return + } + if m.itemSec >= len(m.itemStarts) || m.itemIdx >= len(m.itemStarts[m.itemSec]) { + m.itemSec, m.itemIdx = -1, -1 + } +}