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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion board.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), " ")
}
67 changes: 57 additions & 10 deletions diffcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main

import (
"bytes"
"encoding/json"
"fmt"
"hash/fnv"
"os"
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
30 changes: 30 additions & 0 deletions diffcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading