From 8945fba966db42d1b99b83d199af2386c9737cfd Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Fri, 31 Jul 2026 23:32:02 -0400 Subject: [PATCH 01/14] =?UTF-8?q?spec:=20update=20pointer=20=E2=80=94=20ma?= =?UTF-8?q?rk=20changed=20lines=20on=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../specs/2026-07-31-update-pointer-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-update-pointer-design.md diff --git a/docs/superpowers/specs/2026-07-31-update-pointer-design.md b/docs/superpowers/specs/2026-07-31-update-pointer-design.md new file mode 100644 index 0000000..eb41580 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-update-pointer-design.md @@ -0,0 +1,130 @@ +# Update pointer — mark what changed on reload + +Date: 2026-07-31 +Status: approved design, pending spec review + +## Problem + +When the watched file changes, the viewer re-renders the whole document and +flashes the status bar amber, but gives no cue to *where* the change was. On a +long queue the human has to hunt for what moved. Sidecar should point at the +changed lines. + +## Behavior + +1. **Per-line change detection.** On each render, diff the previous baseline + content against the current content at the current pane width, producing the + set of rendered lines that are new/changed. +2. **Persistent `▸` marker (bullets only).** Every changed line that is a + bullet has glamour's `• ` prefix replaced with a bright `▸ ` (identical + width — no layout shift). Changed non-bullet lines (headings, prose) get no + marker. The marker persists until the next content change. +3. **Subtle one-shot flash.** On a content change, all changed lines get a + gentle background lightening for ~500 ms, then a timer clears it, leaving the + `▸` behind. Single fade, no repeat/strobe. Toggleable with `--no-flash` + (flash on by default). This is independent of the existing amber status-bar + flash. + +## Change detection (resize-proof) + +The model keeps `prevBaseline string` — the raw content as of *before* the most +recent content change. Markers are always computed as a line-level diff of +`render(prevBaseline, width)` vs `render(raw, width)`, recomputed on every +render. Because it re-diffs from raw at the current width, the marker set +survives a terminal resize / rewrap. + +- On a **content change** (new raw differs from current `raw`): set + `prevBaseline` to the *old* `raw` before replacing it. The diff then marks + exactly what that change introduced, and persists (recomputed identically) + until the next content change. +- On a **forced re-render with no content change** (resize): `prevBaseline` and + `raw` are unchanged, so the same lines are marked, recomputed at the new + width. +- **Initial load**: `prevBaseline` is empty; treat an empty baseline as "no + markers" (don't mark the entire first render as changed). + +### Line diff + +A standard longest-common-subsequence over the two slices of rendered lines. A +line in the new render that is not part of the LCS with the old render is +"changed" (added or modified). This prevents an inserted line from cascading a +"changed" mark onto every identical line below it. Document sizes are hundreds +of lines, so an O(n·m) LCS table is fine. + +Comparison is on the **visible text** of each line (ANSI stripped via the +existing `visibleWidth`/reflow helpers), so a pure restyle with identical text +isn't flagged; content is what matters. + +## Rendering the markers + +`reload()` renders markdown to a string as today, then post-processes: + +1. Split into lines. +2. Compute the changed-line set (per above). Empty baseline → empty set. +3. For each line, if its index is in the changed set **and** the line is a + bullet, replace the bullet with `▸ ` styled in `colorUpdated`. Bullet + detection/replacement: the `•` glyph is emitted as a literal character in the + rendered line (only surrounded by ANSI color codes, not encoded by them), so + locate the first literal `• ` and replace that single occurrence with the + styled `▸ `. A line counts as a bullet only when its first visible + (ANSI-stripped) content is the `• ` prefix, so a `•` inside body text isn't + matched. Indentation before the bullet is preserved, so nested bullets align. +4. If the flash is active, wrap every changed line (bullet or not) with a + `colorFlashLineBg` background for its full rendered width. +5. Join and `SetContent`, preserving the scroll offset (as today). + +The composition is a pure function of `(renderedLines, changedSet, flashActive)` +so it can be re-run cheaply when only the flash state changes. + +## Flash lifecycle + +- On a content change, set `lineFlash = true` and schedule a + `lineFlashOffMsg` via `tea.Tick(~500 ms)`. Reuse the existing flash-timer + pattern in `ui.go`. +- On `lineFlashOffMsg`, set `lineFlash = false` and re-`SetContent` from the + cached rendered lines + changed set (scroll preserved) so the background + clears but the `▸` stays. +- A resize while flashing recomputes normally and keeps `lineFlash` as-is. +- `--no-flash` sets a model field that suppresses step 4 and the timer entirely; + the `▸` marker still works. + +## Colors (new consts in `style.go`) + +- `colorUpdated = "#5FE3A1"` — bright teal-green for the `▸` (tunable). +- `colorFlashLineBg = "#2A2A33"` — a subtle lightening just above the normal + terminal background (tunable). + +## Files + +- `style.go` — the two color constants. +- `diff.go` (new) — `changedLines(oldLines, newLines []string) map[int]bool` + (LCS), and `composeMarked(lines []string, changed map[int]bool, flash bool) + string` (bullet `•`→`▸` swap + optional flash background). +- `ui.go` — add `prevBaseline`, `lineFlash`, `noFlash` fields; wire the diff and + composition into `reload()`; add the `lineFlashOffMsg` + tick handling. +- `main.go` — parse `--no-flash`, thread into `newModel`. +- `diff_test.go` (new), `ui_test.go` (extend). + +## Testing + +- `changedLines`: identical inputs → empty set; a changed line → just that + index; an **inserted** line → only the inserted index (not everything below); + a deleted line → the surrounding context isn't spuriously marked. +- `composeMarked`: a changed bullet line has `• ` replaced by a styled `▸ ` + with indentation preserved; a changed non-bullet line is unchanged by the + marker step; nested/indented bullets keep alignment. +- Flash off (`noFlash=true` or after `lineFlashOffMsg`): no background styling + present; `▸` still present. +- `ui.go`: after a content-change reload the model reports changed lines and + `lineFlash=true` with a scheduled command; `lineFlashOffMsg` clears the flash + but a subsequent render still shows `▸`; initial load marks nothing; a resize + (no content change) preserves the marker set. + +## Out of scope (YAGNI) + +- No per-word/intra-line highlighting — line granularity only. +- No marker for non-bullet changed lines (headings/prose) — bullets only, per + the chosen design. +- No configurable colors beyond editing the `style.go` consts. +- No fade animation frames — a single flash-on then flash-off, nothing + in between. From 46d4c1aa356777f9d3e66ef49ca45aad28104bdc Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Fri, 31 Jul 2026 23:41:08 -0400 Subject: [PATCH 02/14] plan: update pointer implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-31-update-pointer.md | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-update-pointer.md diff --git a/docs/superpowers/plans/2026-07-31-update-pointer.md b/docs/superpowers/plans/2026-07-31-update-pointer.md new file mode 100644 index 0000000..a81dac1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-update-pointer.md @@ -0,0 +1,718 @@ +# Update Pointer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Mark the lines that changed on each reload — swap the bullet `• → ▸` (bright) on changed bullet lines and persist it until the next change, plus a subtle ~500 ms one-shot background flash on all changed lines. + +**Architecture:** A new `diff.go` holds a line-level LCS (`changedLines`) and the display composition (`composeMarked` + ANSI helpers). `ui.go` keeps a `prevBaseline` (content before the last change) and re-diffs `render(prevBaseline)` vs `render(raw)` on every render, so markers survive resize. The flash is a timer (`lineFlashOffMsg`) mirroring the existing status-bar flash. A `--no-flash` flag disables the flash; the `▸` marker always works. + +**Tech Stack:** Go, glamour (already renders), lipgloss (already used), `muesli/reflow` (already used by `visibleWidth`). No new dependencies. + +## Global Constraints + +- Module `github.com/than/sidecar`, package `main`, flat repo root. +- No new dependencies. +- The `▸`/flash composition operates on already-rendered ANSI lines. The `•` + glyph is emitted literally (e.g. `\x1b[38;2;208;208;208m• \x1b[0m`), so swap + the first literal `• `. Background tinting must be SGR-aware: prepend the bg + code and re-apply it after every `\x1b[0m` reset, because a reset clears the + background mid-line. +- Line comparison for the diff is on ANSI-stripped visible text, so a pure + restyle isn't flagged. +- Never change the viewer's existing guarantees: scroll preservation across + reloads, pane-width cap, the status-bar amber flash. `renderMarkdown`, + `watcher.go`, and the parent-dir watch are untouched. +- Initial load (empty baseline) marks nothing. +- gofmt-clean, `go vet ./...` clean. + +## Colors (added in Task 2, `style.go`) + +- `colorUpdated = "#5FE3A1"` — bright teal-green `▸`. +- `colorFlashLineBg = "#2A2A33"` — subtle bg lightening. + +--- + +### Task 1: Line diff + stripANSI + +**Files:** +- Create: `diff.go` +- Modify: `render_test.go` — remove its local `stripANSI` (moved to `diff.go`) +- Test: `diff_test.go` + +**Interfaces:** +- Produces: + - `func stripANSI(s string) string` (moved to production) + - `func changedLines(oldLines, newLines []string) map[int]bool` — indices into + `newLines` that are not part of the LCS with `oldLines` (added/modified), + comparing on ANSI-stripped text. + +- [ ] **Step 1: Write the failing test** + +```go +// diff_test.go +package main + +import "testing" + +func idx(m map[int]bool) []int { + out := []int{} + for i := 0; i < 100; i++ { + if m[i] { + out = append(out, i) + } + } + return out +} + +func eq(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestChangedLinesIdentical(t *testing.T) { + l := []string{"a", "b", "c"} + if got := idx(changedLines(l, l)); len(got) != 0 { + t.Errorf("identical → %v, want none", got) + } +} + +func TestChangedLinesOneModified(t *testing.T) { + old := []string{"a", "b", "c"} + nw := []string{"a", "B", "c"} + if got := idx(changedLines(old, nw)); !eq(got, []int{1}) { + t.Errorf("modified → %v, want [1]", got) + } +} + +func TestChangedLinesInsertionNoCascade(t *testing.T) { + // Inserting one line must mark ONLY the new line, not everything below. + old := []string{"a", "b", "c"} + nw := []string{"a", "NEW", "b", "c"} + if got := idx(changedLines(old, nw)); !eq(got, []int{1}) { + t.Errorf("insertion → %v, want [1] (no cascade)", got) + } +} + +func TestChangedLinesIgnoresANSI(t *testing.T) { + old := []string{"\x1b[31mhello\x1b[0m"} + nw := []string{"\x1b[32mhello\x1b[0m"} // same text, different color + if got := idx(changedLines(old, nw)); len(got) != 0 { + t.Errorf("restyle-only → %v, want none", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./... -run TestChangedLines -v` +Expected: FAIL — `undefined: changedLines` + +- [ ] **Step 3: Write minimal implementation** + +```go +// diff.go +package main + +import ( + "regexp" + "strings" +) + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripANSI removes SGR escape sequences, leaving the visible text. +func stripANSI(s string) string { + return ansiRE.ReplaceAllString(s, "") +} + +// changedLines returns the indices into newLines that are new or modified +// relative to oldLines — the lines not covered by the longest common +// subsequence of the two, compared on ANSI-stripped visible text. An inserted +// line marks only itself, not the identical lines shifted below it. +func changedLines(oldLines, newLines []string) map[int]bool { + o := make([]string, len(oldLines)) + for i, l := range oldLines { + o[i] = stripANSI(l) + } + n := make([]string, len(newLines)) + for i, l := range newLines { + n[i] = stripANSI(l) + } + + // LCS length table. + lcs := make([][]int, len(o)+1) + for i := range lcs { + lcs[i] = make([]int, len(n)+1) + } + for i := len(o) - 1; i >= 0; i-- { + for j := len(n) - 1; j >= 0; j-- { + if o[i] == n[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else if lcs[i+1][j] >= lcs[i][j+1] { + lcs[i][j] = lcs[i+1][j] + } else { + lcs[i][j] = lcs[i][j+1] + } + } + } + + // Walk the table; lines in n that aren't part of the common subsequence + // are the changed ones. + changed := map[int]bool{} + i, j := 0, 0 + for j < len(n) { + if i < len(o) && o[i] == n[j] { + i++ + j++ + } else if i < len(o) && lcs[i+1][j] >= lcs[i][j+1] { + i++ // a line from old was removed + } else { + changed[j] = true // n[j] is new/modified + j++ + } + } + return changed +} +``` + +Then remove the duplicate `stripANSI` from `render_test.go` (delete its `func stripANSI(...)` block; the tests there now use the production one). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -run 'TestChangedLines|TestScroll|TestReloadFlash' -v` +Expected: PASS (diff tests pass; existing render/ui tests still compile with the moved `stripANSI`) + +- [ ] **Step 5: Commit** + +```bash +git add diff.go diff_test.go render_test.go +git commit -m "feat: line-level LCS diff of rendered output (update pointer)" +``` + +--- + +### Task 2: Marker + flash composition + +**Files:** +- Modify: `style.go` — add the two color constants +- Modify: `diff.go` — add `composeMarked`, `applyLineBg`, `isBulletLine`, `swapBullet`, `hexToRGB` +- Test: `diff_test.go` + +**Interfaces:** +- Consumes: `changedLines` (Task 1), `visibleWidth` (`style.go`), `colorUpdated`/`colorFlashLineBg`. +- Produces: + - `func composeMarked(lines []string, changed map[int]bool, flash bool, width int) string` + +- [ ] **Step 1: Write the failing test** + +```go +// diff_test.go (append) +import "strings" // add to the import block + +func TestComposeMarkedBulletSwap(t *testing.T) { + // A changed bullet line: the literal "• " becomes a styled "▸ ". + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{0: true}, false, 40) + if strings.Contains(stripANSI(out), "• ") { + t.Errorf("bullet not replaced:\n%q", out) + } + if !strings.Contains(stripANSI(out), "▸ alpha") { + t.Errorf("expected ▸ alpha, got:\n%q", stripANSI(out)) + } +} + +func TestComposeMarkedUnchangedLineUntouched(t *testing.T) { + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{}, false, 40) // nothing changed + if out != lines[0] { + t.Errorf("unchanged line altered:\n%q", out) + } +} + +func TestComposeMarkedNonBulletNoMarker(t *testing.T) { + // A changed non-bullet line gets no ▸ (bullets only), and without flash + // its text is unchanged. + lines := []string{"\x1b[38;2;209;154;102m▍ Heading\x1b[0m"} + out := composeMarked(lines, map[int]bool{0: true}, false, 40) + if strings.Contains(out, "▸") { + t.Errorf("non-bullet line should not get ▸:\n%q", out) + } +} + +func TestComposeMarkedFlashAddsBackground(t *testing.T) { + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{0: true}, true, 40) + if !strings.Contains(out, "\x1b[48;2;") { + t.Errorf("flash should inject a background SGR:\n%q", out) + } + // The ▸ still shows through the flash. + if !strings.Contains(stripANSI(out), "▸ alpha") { + t.Errorf("▸ missing under flash:\n%q", stripANSI(out)) + } +} + +func TestComposeMarkedNoFlashNoBackground(t *testing.T) { + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{0: true}, false, 40) + if strings.Contains(out, "\x1b[48;2;") { + t.Errorf("no flash should not inject a background:\n%q", out) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./... -run TestComposeMarked -v` +Expected: FAIL — `undefined: composeMarked` + +- [ ] **Step 3: Write minimal implementation** + +Add to `style.go` (near the other `color…` consts): + +```go + colorUpdated = "#5FE3A1" // bright ▸ marking a changed line + colorFlashLineBg = "#2A2A33" // subtle bg lightening on a just-changed line +``` + +Add to `diff.go`: + +```go +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// composeMarked renders the display string: changed bullet lines get their +// "• " swapped for a bright "▸ ", and (when flash is true) every changed line +// gets a subtle background tint. Order matters — the bullet is swapped first so +// applyLineBg re-establishes the background after the reset the swap introduces. +func composeMarked(lines []string, changed map[int]bool, flash bool, width int) string { + updatedMark := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorUpdated)).Bold(true).Render("▸ ") + + out := make([]string, len(lines)) + for i, ln := range lines { + if changed[i] { + if isBulletLine(ln) { + ln = strings.Replace(ln, "• ", updatedMark, 1) + } + if flash { + ln = applyLineBg(ln, colorFlashLineBg, width) + } + } + out[i] = ln + } + return strings.Join(out, "\n") +} + +// isBulletLine reports whether the line's first visible content is glamour's +// "• " item prefix (so a "•" inside body text isn't matched). +func isBulletLine(ln string) bool { + t := strings.TrimLeft(stripANSI(ln), " ") + return strings.HasPrefix(t, "• ") +} + +// applyLineBg tints the whole visible line with the given hex background, +// re-applying it after each SGR reset (a reset would otherwise clear the +// background mid-line), and pads to width so the tint spans the pane. +func applyLineBg(ln, hex string, width int) string { + r, g, b := hexToRGB(hex) + bg := fmt.Sprintf("\x1b[48;2;%d;%d;%dm", r, g, b) + const reset = "\x1b[0m" + body := strings.ReplaceAll(ln, reset, reset+bg) + pad := "" + if v := visibleWidth(ln); v < width { + pad = strings.Repeat(" ", width-v) + } + return bg + body + pad + reset +} + +// hexToRGB parses "#RRGGBB" into its components. +func hexToRGB(hex string) (int, int, int) { + hex = strings.TrimPrefix(hex, "#") + var r, g, b int + fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + return r, g, b +} +``` + +(Merge the new `import` block with `diff.go`'s existing one — `regexp`, `strings`, `fmt`, `github.com/charmbracelet/lipgloss`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -run TestComposeMarked -v && go vet ./...` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add diff.go diff_test.go style.go +git commit -m "feat: ▸ bullet marker + subtle line flash composition (update pointer)" +``` + +--- + +### Task 3: Wire diff + flash into the viewer + +**Files:** +- Modify: `ui.go` +- Test: `ui_test.go` + +**Interfaces:** +- Consumes: `changedLines`, `composeMarked` (Tasks 1–2). +- Produces: + - `model` gains `prevBaseline string`, `renderedLines []string`, `changed map[int]bool`, `lineFlash bool`, `noFlash bool`. + - `func newModel(path string, noFlash bool) model` (signature changed). + - `type lineFlashOffMsg struct{}`, `func (m *model) recompose()`. + +- [ ] **Step 1: Write the failing test** + +```go +// ui_test.go — update the helper first: +// testModel calls newModel(path) → newModel(path, false) +// then append: + +func TestUpdatePointerMarksChangedBullet(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n- beta\n") + m := testModel(t, path) + + // change one bullet + writeFile(t, path, "# T\n\n- alpha\n- BETA\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + + view := stripANSI(m.vp.View()) + if !strings.Contains(view, "▸ BETA") { + t.Errorf("changed bullet not marked with ▸:\n%s", view) + } + if !m.lineFlash { + t.Error("lineFlash should be set after a content change") + } +} + +func TestUpdatePointerInitialLoadUnmarked(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) // first load renders via WindowSizeMsg + + if strings.Contains(stripANSI(m.vp.View()), "▸") { + t.Errorf("initial load should mark nothing:\n%s", stripANSI(m.vp.View())) + } +} + +func TestUpdatePointerFlashOffKeepsMarker(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) + writeFile(t, path, "# T\n\n- ALPHA\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + + // flash on → background present + if !strings.Contains(m.vp.View(), "\x1b[48;2;") { + t.Error("expected flash background right after change") + } + next, _ = m.Update(lineFlashOffMsg{}) + m = next.(model) + if strings.Contains(m.vp.View(), "\x1b[48;2;") { + t.Error("flash background should clear on lineFlashOffMsg") + } + if !strings.Contains(stripANSI(m.vp.View()), "▸ ALPHA") { + t.Error("▸ marker should persist after flash clears") + } +} + +func TestUpdatePointerNoFlashFlag(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := newModel(path, true) // noFlash + next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) + m = next.(model) + writeFile(t, path, "# T\n\n- ALPHA\n") + next, _ = m.Update(fileEventMsg{}) + m = next.(model) + if strings.Contains(m.vp.View(), "\x1b[48;2;") { + t.Error("no-flash mode should never inject a background") + } + if !strings.Contains(stripANSI(m.vp.View()), "▸ ALPHA") { + t.Error("▸ marker should still work with --no-flash") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./... -run TestUpdatePointer -v` +Expected: FAIL — `newModel` arity / `lineFlashOffMsg` undefined + +- [ ] **Step 3: Write minimal implementation** + +In `ui.go`: + +1. Add the message type and timer next to the existing flash ones: + +```go +// lineFlashOffMsg clears the subtle post-reload line-background flash. +type lineFlashOffMsg struct{} + +const lineFlashDuration = 500 * time.Millisecond + +func lineFlashOff() tea.Cmd { + return tea.Tick(lineFlashDuration, func(time.Time) tea.Msg { return lineFlashOffMsg{} }) +} +``` + +2. Extend the `model` struct and `newModel`: + +```go +type model struct { + // ...existing fields... + flash bool + + // update pointer + prevBaseline string // content before the last change; diffed vs raw + renderedLines []string // cached rendered lines for cheap recompose + changed map[int]bool // changed line indices in the current render + lineFlash bool // subtle line-bg flash active + noFlash bool // --no-flash: suppress the line flash +} + +func newModel(path string, noFlash bool) model { + return model{path: path, noFlash: noFlash} +} +``` + +3. In `reload`, capture the pre-change baseline and compose the display. Replace + the tail of `reload` (from computing `contentChanged` through `SetContent`) + with: + +```go + raw := string(data) + contentChanged := raw != m.raw + if !force && !contentChanged { + return false + } + if contentChanged { + m.prevBaseline = m.raw // old content ("" on first load → no markers) + } + m.raw = raw + + rendered, err := renderMarkdown(raw, m.renderWidth()) + if err != nil { + m.loadErr = err + m.vp.SetContent(fmt.Sprintf("\n Render error: %v", err)) + return false + } + lines := strings.Split(rendered, "\n") + + var changed map[int]bool + if m.prevBaseline != "" { + if base, berr := renderMarkdown(m.prevBaseline, m.renderWidth()); berr == nil { + changed = changedLines(strings.Split(base, "\n"), lines) + } + } + m.renderedLines = lines + m.changed = changed + + display := composeMarked(lines, changed, m.lineFlash && !m.noFlash, m.renderWidth()) + offset := m.vp.YOffset + m.vp.SetContent(display) + m.vp.SetYOffset(offset) + return contentChanged +``` + +4. Add `recompose` (re-set content for the current flash state, scroll preserved): + +```go +// recompose re-renders the cached lines for the current flash state without +// re-reading the file — used when only the flash toggles. +func (m *model) recompose() { + if m.renderedLines == nil { + return + } + display := composeMarked(m.renderedLines, m.changed, m.lineFlash && !m.noFlash, m.renderWidth()) + offset := m.vp.YOffset + m.vp.SetContent(display) + m.vp.SetYOffset(offset) +} +``` + +5. Trigger the line flash on the two reload paths, and handle `lineFlashOffMsg`. + In the `fileEventMsg` case: + +```go + case fileEventMsg: + if m.reload(false) { + m.flash = true + cmds := []tea.Cmd{flashOff()} + if !m.noFlash { + m.lineFlash = true + m.recompose() // show the flash background immediately + cmds = append(cmds, lineFlashOff()) + } + return m, tea.Batch(cmds...) + } + return m, nil +``` + + In the `tickMsg` case, where `changed := m.reload(false)` currently gates the + flash, mirror the same block: when `changed` is true, set `m.flash = true`, + and if `!m.noFlash` set `m.lineFlash = true`, call `m.recompose()`, and add + `lineFlashOff()` to the batch alongside `tick()` and `flashOff()`. + + Add a new case: + +```go + case lineFlashOffMsg: + m.lineFlash = false + m.recompose() + return m, nil +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... && go vet ./...` +Expected: PASS (new pointer tests + all existing ui/render tests) + +- [ ] **Step 5: Commit** + +```bash +git add ui.go ui_test.go +git commit -m "feat: wire update pointer + line flash into the viewer" +``` + +--- + +### Task 4: `--no-flash` flag + +**Files:** +- Modify: `main.go` +- Test: `main.go` has no unit test harness; covered by `TestUpdatePointerNoFlashFlag` (Task 3) exercising `newModel(path, true)`. + +**Interfaces:** +- Consumes: `newModel(path, noFlash)` (Task 3). + +- [ ] **Step 1: Parse the flag** + +There is no automated test for `main()` arg parsing (it wires `os.Args` to the +tea program). Implement directly, then verify by build + manual run. + +The current `main` dispatches subcommands on `os.Args[1]` and then runs the +viewer. Keep the subcommand dispatch exactly as-is (so `init`, `--static`, `-h`, +`-v` are byte-for-byte unchanged), and add `--no-flash` handling only in the +viewer path. Replace the body of `main` with: + +```go +func main() { + // Subcommands dispatch on the first arg, exactly as before. + if len(os.Args) > 1 { + switch os.Args[1] { + case "-h", "--help": + fmt.Print(help) + return + case "-v", "--version": + fmt.Println("sidecar", version) + return + case "-s", "--static": + os.Exit(runStatic(os.Args[2:])) + case "init": + os.Exit(runInit(os.Args[2:])) + } + } + + // Viewer mode: an optional file path plus the --no-flash flag, any order. + path := defaultFile + noFlash := false + for _, a := range os.Args[1:] { + switch a { + case "--no-flash": + noFlash = true + default: + path = a + } + } + + abs, err := filepath.Abs(expandTilde(path)) + if err != nil { + fmt.Fprintln(os.Stderr, "sidecar:", err) + os.Exit(1) + } + + offerCreate(abs) // if missing and interactive, offer to scaffold before opening + + p := tea.NewProgram(newModel(abs, noFlash), + tea.WithAltScreen(), + ) + go watchFile(abs, p.Send) + + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "sidecar:", err) + os.Exit(1) + } +} +``` + +(`sidecar --no-flash` falls through the subcommand switch — `os.Args[1]` matches +no case — into viewer mode, where the loop sets `noFlash` and leaves `path` at +the default. `sidecar --no-flash foo.md` sets both.) + +Verify the subcommand paths still work: + +Run: `go build -o /tmp/sidecar . && /tmp/sidecar --help >/dev/null && /tmp/sidecar --version && echo "x" | /tmp/sidecar --static /dev/stdin >/dev/null && echo OK` +Expected: prints the version and `OK` (subcommands unaffected). + +- [ ] **Step 2: Update the help text** + +Add a line to the `help` const's usage/keys area documenting `--no-flash`: + +``` + sidecar --no-flash [file] disable the subtle change-flash (▸ still shows) +``` + +- [ ] **Step 3: Build + vet + full suite** + +Run: `go build ./... && go vet ./... && gofmt -l . && go test ./...` +Expected: builds, clean, all tests pass. + +- [ ] **Step 4: Manual smoke (needs a TTY — run once)** + +```bash +go build -o /tmp/sidecar . && cd "$(mktemp -d)" && /tmp/sidecar init >/dev/null 2>&1 || true +``` + +Then in one pane run `/tmp/sidecar SIDECAR.md`, edit a bullet in another pane, +and confirm: the changed bullet shows a bright `▸`, a subtle background tint +flashes once and settles, and `--no-flash` suppresses only the tint. + +- [ ] **Step 5: Commit** + +```bash +git add main.go +git commit -m "feat: --no-flash flag to disable the change-flash (update pointer)" +``` + +--- + +## Self-review notes + +- Spec §"change detection (resize-proof)" → Task 3 `prevBaseline` + re-diff from + raw on every render. ✓ +- Spec §"per-line, no cascade" → Task 1 LCS, `TestChangedLinesInsertionNoCascade`. ✓ +- Spec §"`▸` bullets only, width-preserving" → Task 2 `composeMarked`/`isBulletLine`, + `TestComposeMarkedNonBulletNoMarker`. ✓ +- Spec §"subtle one-shot flash, SGR-aware bg" → Task 2 `applyLineBg`, Task 3 + `lineFlashOffMsg`; `TestUpdatePointerFlashOffKeepsMarker`. ✓ +- Spec §"`--no-flash`, ▸ still works" → Task 4 flag + Task 3 + `TestUpdatePointerNoFlashFlag`. ✓ +- Spec §"initial load marks nothing" → Task 3 empty-baseline guard, + `TestUpdatePointerInitialLoadUnmarked`. ✓ +- Spec §"colors tunable in style.go" → Task 2 consts. ✓ From 96831b16f4f262c92c12eb3f8261480fab06cf6d Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Fri, 31 Jul 2026 23:45:29 -0400 Subject: [PATCH 03/14] feat: line-level LCS diff of rendered output (update pointer) --- diff.go | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ diff_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++ render_test.go | 6 ----- 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 diff.go create mode 100644 diff_test.go diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..c5c5a5c --- /dev/null +++ b/diff.go @@ -0,0 +1,62 @@ +// diff.go +package main + +import ( + "regexp" +) + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripANSI removes SGR escape sequences, leaving the visible text. +func stripANSI(s string) string { + return ansiRE.ReplaceAllString(s, "") +} + +// changedLines returns the indices into newLines that are new or modified +// relative to oldLines — the lines not covered by the longest common +// subsequence of the two, compared on ANSI-stripped visible text. An inserted +// line marks only itself, not the identical lines shifted below it. +func changedLines(oldLines, newLines []string) map[int]bool { + o := make([]string, len(oldLines)) + for i, l := range oldLines { + o[i] = stripANSI(l) + } + n := make([]string, len(newLines)) + for i, l := range newLines { + n[i] = stripANSI(l) + } + + // LCS length table. + lcs := make([][]int, len(o)+1) + for i := range lcs { + lcs[i] = make([]int, len(n)+1) + } + for i := len(o) - 1; i >= 0; i-- { + for j := len(n) - 1; j >= 0; j-- { + if o[i] == n[j] { + lcs[i][j] = lcs[i+1][j+1] + 1 + } else if lcs[i+1][j] >= lcs[i][j+1] { + lcs[i][j] = lcs[i+1][j] + } else { + lcs[i][j] = lcs[i][j+1] + } + } + } + + // Walk the table; lines in n that aren't part of the common subsequence + // are the changed ones. + changed := map[int]bool{} + i, j := 0, 0 + for j < len(n) { + if i < len(o) && o[i] == n[j] { + i++ + j++ + } else if i < len(o) && lcs[i+1][j] >= lcs[i][j+1] { + i++ // a line from old was removed + } else { + changed[j] = true // n[j] is new/modified + j++ + } + } + return changed +} diff --git a/diff_test.go b/diff_test.go new file mode 100644 index 0000000..a217a8e --- /dev/null +++ b/diff_test.go @@ -0,0 +1,58 @@ +// diff_test.go +package main + +import "testing" + +func idx(m map[int]bool) []int { + out := []int{} + for i := 0; i < 100; i++ { + if m[i] { + out = append(out, i) + } + } + return out +} + +func eq(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestChangedLinesIdentical(t *testing.T) { + l := []string{"a", "b", "c"} + if got := idx(changedLines(l, l)); len(got) != 0 { + t.Errorf("identical → %v, want none", got) + } +} + +func TestChangedLinesOneModified(t *testing.T) { + old := []string{"a", "b", "c"} + nw := []string{"a", "B", "c"} + if got := idx(changedLines(old, nw)); !eq(got, []int{1}) { + t.Errorf("modified → %v, want [1]", got) + } +} + +func TestChangedLinesInsertionNoCascade(t *testing.T) { + // Inserting one line must mark ONLY the new line, not everything below. + old := []string{"a", "b", "c"} + nw := []string{"a", "NEW", "b", "c"} + if got := idx(changedLines(old, nw)); !eq(got, []int{1}) { + t.Errorf("insertion → %v, want [1] (no cascade)", got) + } +} + +func TestChangedLinesIgnoresANSI(t *testing.T) { + old := []string{"\x1b[31mhello\x1b[0m"} + nw := []string{"\x1b[32mhello\x1b[0m"} // same text, different color + if got := idx(changedLines(old, nw)); len(got) != 0 { + t.Errorf("restyle-only → %v, want none", got) + } +} diff --git a/render_test.go b/render_test.go index 510edf4..794ca68 100644 --- a/render_test.go +++ b/render_test.go @@ -7,12 +7,6 @@ import ( "testing" ) -var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -func stripANSI(s string) string { - return ansiRE.ReplaceAllString(s, "") -} - func renderFixture(t *testing.T, width int) string { t.Helper() raw, err := os.ReadFile("testdata/REVIEW.md") From 7b8bac411ab347387ebce2588602d06eaaad1931 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Fri, 31 Jul 2026 23:49:14 -0400 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=E2=96=B8=20bullet=20marker=20+?= =?UTF-8?q?=20subtle=20line=20flash=20composition=20(update=20pointer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- diff.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ diff_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++- style.go | 24 ++++++++++++---------- 3 files changed, 124 insertions(+), 12 deletions(-) diff --git a/diff.go b/diff.go index c5c5a5c..022666e 100644 --- a/diff.go +++ b/diff.go @@ -2,7 +2,11 @@ package main import ( + "fmt" "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" ) var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -60,3 +64,56 @@ func changedLines(oldLines, newLines []string) map[int]bool { } return changed } + +// composeMarked renders the display string: changed bullet lines get their +// "• " swapped for a bright "▸ ", and (when flash is true) every changed line +// gets a subtle background tint. Order matters — the bullet is swapped first so +// applyLineBg re-establishes the background after the reset the swap introduces. +func composeMarked(lines []string, changed map[int]bool, flash bool, width int) string { + updatedMark := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorUpdated)).Bold(true).Render("▸ ") + + out := make([]string, len(lines)) + for i, ln := range lines { + if changed[i] { + if isBulletLine(ln) { + ln = strings.Replace(ln, "• ", updatedMark, 1) + } + if flash { + ln = applyLineBg(ln, colorFlashLineBg, width) + } + } + out[i] = ln + } + return strings.Join(out, "\n") +} + +// isBulletLine reports whether the line's first visible content is glamour's +// "• " item prefix (so a "•" inside body text isn't matched). +func isBulletLine(ln string) bool { + t := strings.TrimLeft(stripANSI(ln), " ") + return strings.HasPrefix(t, "• ") +} + +// applyLineBg tints the whole visible line with the given hex background, +// re-applying it after each SGR reset (a reset would otherwise clear the +// background mid-line), and pads to width so the tint spans the pane. +func applyLineBg(ln, hex string, width int) string { + r, g, b := hexToRGB(hex) + bg := fmt.Sprintf("\x1b[48;2;%d;%d;%dm", r, g, b) + const reset = "\x1b[0m" + body := strings.ReplaceAll(ln, reset, reset+bg) + pad := "" + if v := visibleWidth(ln); v < width { + pad = strings.Repeat(" ", width-v) + } + return bg + body + pad + reset +} + +// hexToRGB parses "#RRGGBB" into its components. +func hexToRGB(hex string) (int, int, int) { + hex = strings.TrimPrefix(hex, "#") + var r, g, b int + fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + return r, g, b +} diff --git a/diff_test.go b/diff_test.go index a217a8e..5746187 100644 --- a/diff_test.go +++ b/diff_test.go @@ -1,7 +1,10 @@ // diff_test.go package main -import "testing" +import ( + "strings" + "testing" +) func idx(m map[int]bool) []int { out := []int{} @@ -56,3 +59,53 @@ func TestChangedLinesIgnoresANSI(t *testing.T) { t.Errorf("restyle-only → %v, want none", got) } } + +func TestComposeMarkedBulletSwap(t *testing.T) { + // A changed bullet line: the literal "• " becomes a styled "▸ ". + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{0: true}, false, 40) + if strings.Contains(stripANSI(out), "• ") { + t.Errorf("bullet not replaced:\n%q", out) + } + if !strings.Contains(stripANSI(out), "▸ alpha") { + t.Errorf("expected ▸ alpha, got:\n%q", stripANSI(out)) + } +} + +func TestComposeMarkedUnchangedLineUntouched(t *testing.T) { + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{}, false, 40) // nothing changed + if out != lines[0] { + t.Errorf("unchanged line altered:\n%q", out) + } +} + +func TestComposeMarkedNonBulletNoMarker(t *testing.T) { + // A changed non-bullet line gets no ▸ (bullets only), and without flash + // its text is unchanged. + lines := []string{"\x1b[38;2;209;154;102m▍ Heading\x1b[0m"} + out := composeMarked(lines, map[int]bool{0: true}, false, 40) + if strings.Contains(out, "▸") { + t.Errorf("non-bullet line should not get ▸:\n%q", out) + } +} + +func TestComposeMarkedFlashAddsBackground(t *testing.T) { + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{0: true}, true, 40) + if !strings.Contains(out, "\x1b[48;2;") { + t.Errorf("flash should inject a background SGR:\n%q", out) + } + // The ▸ still shows through the flash. + if !strings.Contains(stripANSI(out), "▸ alpha") { + t.Errorf("▸ missing under flash:\n%q", stripANSI(out)) + } +} + +func TestComposeMarkedNoFlashNoBackground(t *testing.T) { + lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"} + out := composeMarked(lines, map[int]bool{0: true}, false, 40) + if strings.Contains(out, "\x1b[48;2;") { + t.Errorf("no flash should not inject a background:\n%q", out) + } +} diff --git a/style.go b/style.go index dd4a539..d958303 100644 --- a/style.go +++ b/style.go @@ -16,17 +16,19 @@ const ( // Tune this one const to adjust every link. colorLink = "#4EC9E5" - colorText = "#D0D0D0" // body text - colorHeading = "#D19A66" // muted amber, h2/h3 - colorH1Fg = "#000000" // h1 badge text - colorH1Bg = "#AF87FF" // h1 badge background, light lavender - colorCodeFg = "#FF5F5F" // inline code - colorCodeBg = "#303030" // inline code background - colorCodeDim = "#808080" // fenced code blocks - colorRule = "#585858" // horizontal rules - colorStatusFg = "#8A8F98" // status bar text - colorStatusHi = "#C8CCD4" // status bar filename - colorStatusBg = "#26262A" // status bar background + colorText = "#D0D0D0" // body text + colorHeading = "#D19A66" // muted amber, h2/h3 + colorH1Fg = "#000000" // h1 badge text + colorH1Bg = "#AF87FF" // h1 badge background, light lavender + colorCodeFg = "#FF5F5F" // inline code + colorCodeBg = "#303030" // inline code background + colorCodeDim = "#808080" // fenced code blocks + colorRule = "#585858" // horizontal rules + colorStatusFg = "#8A8F98" // status bar text + colorStatusHi = "#C8CCD4" // status bar filename + colorStatusBg = "#26262A" // status bar background + colorUpdated = "#5FE3A1" // bright ▸ marking a changed line + colorFlashLineBg = "#2A2A33" // subtle bg lightening on a just-changed line ) func ptr[T any](v T) *T { return &v } From 1b341aaab671201a8165026a2e3b8390817444b1 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Fri, 31 Jul 2026 23:53:55 -0400 Subject: [PATCH 05/14] feat: wire update pointer + line flash into the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reload now captures a pre-change baseline, diffs it against the newly rendered lines via changedLines, and composes the display with composeMarked so changed bullets/lines get the ▸ pointer. A brief line-level background flash (lineFlash/lineFlashOffMsg) highlights what changed on both the fsnotify and tick-fallback reload paths, and recompose() lets the flash toggle without re-reading the file. newModel gains a noFlash param (--no-flash support lands in the CLI flag in a later task); main.go's single call site is updated to pass false so the package keeps compiling. --- main.go | 2 +- ui.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++++------ ui_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 2230992..9d9b69c 100644 --- a/main.go +++ b/main.go @@ -58,7 +58,7 @@ func main() { offerCreate(abs) // if missing and interactive, offer to scaffold before opening - p := tea.NewProgram(newModel(abs), + p := tea.NewProgram(newModel(abs, false), tea.WithAltScreen(), // No mouse capture: keeps the terminal's native text selection and // clickable links working. Scroll with the keyboard (see keys below). diff --git a/ui.go b/ui.go index 6e977af..e32a57a 100644 --- a/ui.go +++ b/ui.go @@ -27,6 +27,15 @@ func flashOff() tea.Cmd { return tea.Tick(flashDuration, func(time.Time) tea.Msg { return flashOffMsg{} }) } +// lineFlashOffMsg clears the subtle post-reload line-background flash. +type lineFlashOffMsg struct{} + +const lineFlashDuration = 500 * time.Millisecond + +func lineFlashOff() tea.Cmd { + return tea.Tick(lineFlashDuration, func(time.Time) tea.Msg { return lineFlashOffMsg{} }) +} + type model struct { path string @@ -47,10 +56,17 @@ type model struct { // flash briefly highlights the status bar right after a live reload. flash bool + + // update pointer + prevBaseline string // content before the last change; diffed vs raw + renderedLines []string // cached rendered lines for cheap recompose + changed map[int]bool // changed line indices in the current render + lineFlash bool // subtle line-bg flash active + noFlash bool // --no-flash: suppress the line flash } -func newModel(path string) model { - return model{path: path} +func newModel(path string, noFlash bool) model { + return model{path: path, noFlash: noFlash} } func (m model) Init() tea.Cmd { @@ -93,7 +109,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fileEventMsg: if m.reload(false) { m.flash = true - return m, flashOff() + cmds := []tea.Cmd{flashOff()} + if !m.noFlash { + m.lineFlash = true + m.recompose() // show the flash background immediately + cmds = append(cmds, lineFlashOff()) + } + return m, tea.Batch(cmds...) } return m, nil @@ -109,13 +131,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if changed { m.flash = true - return m, tea.Batch(tick(), flashOff()) + cmds := []tea.Cmd{tick(), flashOff()} + if !m.noFlash { + m.lineFlash = true + m.recompose() + cmds = append(cmds, lineFlashOff()) + } + return m, tea.Batch(cmds...) } return m, tick() case flashOffMsg: m.flash = false return m, nil + + case lineFlashOffMsg: + m.lineFlash = false + m.recompose() + return m, nil } var cmd tea.Cmd @@ -158,6 +191,9 @@ func (m *model) reload(force bool) (changed bool) { if !force && !contentChanged { return false } + if contentChanged { + m.prevBaseline = m.raw // old content ("" on first load → no markers) + } m.raw = raw rendered, err := renderMarkdown(raw, m.renderWidth()) @@ -166,13 +202,36 @@ func (m *model) reload(force bool) (changed bool) { m.vp.SetContent(fmt.Sprintf("\n Render error: %v", err)) return false } + lines := strings.Split(rendered, "\n") + + var changedMap map[int]bool + if m.prevBaseline != "" { + if base, berr := renderMarkdown(m.prevBaseline, m.renderWidth()); berr == nil { + changedMap = changedLines(strings.Split(base, "\n"), lines) + } + } + m.renderedLines = lines + m.changed = changedMap - offset := m.vp.YOffset // preserve scroll; if at top this is 0 and stays 0 - m.vp.SetContent(rendered) - m.vp.SetYOffset(offset) // viewport clamps to the new content height + display := composeMarked(lines, changedMap, m.lineFlash && !m.noFlash, m.renderWidth()) + offset := m.vp.YOffset + m.vp.SetContent(display) + m.vp.SetYOffset(offset) return contentChanged } +// recompose re-renders the cached lines for the current flash state without +// re-reading the file — used when only the flash toggles. +func (m *model) recompose() { + if m.renderedLines == nil { + return + } + display := composeMarked(m.renderedLines, m.changed, m.lineFlash && !m.noFlash, m.renderWidth()) + offset := m.vp.YOffset + m.vp.SetContent(display) + m.vp.SetYOffset(offset) +} + // renderWidth is the markdown wrap width: pane width minus 2, never wider // than the pane. func (m model) renderWidth() int { diff --git a/ui_test.go b/ui_test.go index fb46ce3..9eb2d1f 100644 --- a/ui_test.go +++ b/ui_test.go @@ -13,7 +13,7 @@ import ( func testModel(t *testing.T, path string) model { t.Helper() - m := newModel(path) + m := newModel(path, false) next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) return next.(model) } @@ -180,6 +180,74 @@ func TestReloadFlash(t *testing.T) { } } +func TestUpdatePointerMarksChangedBullet(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n- beta\n") + m := testModel(t, path) + + // change one bullet + writeFile(t, path, "# T\n\n- alpha\n- BETA\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + + view := stripANSI(m.vp.View()) + if !strings.Contains(view, "▸ BETA") { + t.Errorf("changed bullet not marked with ▸:\n%s", view) + } + if !m.lineFlash { + t.Error("lineFlash should be set after a content change") + } +} + +func TestUpdatePointerInitialLoadUnmarked(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) // first load renders via WindowSizeMsg + + if strings.Contains(stripANSI(m.vp.View()), "▸") { + t.Errorf("initial load should mark nothing:\n%s", stripANSI(m.vp.View())) + } +} + +func TestUpdatePointerFlashOffKeepsMarker(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) + writeFile(t, path, "# T\n\n- ALPHA\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + + // flash on → background present + if !strings.Contains(m.vp.View(), "\x1b[48;2;") { + t.Error("expected flash background right after change") + } + next, _ = m.Update(lineFlashOffMsg{}) + m = next.(model) + if strings.Contains(m.vp.View(), "\x1b[48;2;") { + t.Error("flash background should clear on lineFlashOffMsg") + } + if !strings.Contains(stripANSI(m.vp.View()), "▸ ALPHA") { + t.Error("▸ marker should persist after flash clears") + } +} + +func TestUpdatePointerNoFlashFlag(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := newModel(path, true) // noFlash + next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) + m = next.(model) + writeFile(t, path, "# T\n\n- ALPHA\n") + next, _ = m.Update(fileEventMsg{}) + m = next.(model) + if strings.Contains(m.vp.View(), "\x1b[48;2;") { + t.Error("no-flash mode should never inject a background") + } + if !strings.Contains(stripANSI(m.vp.View()), "▸ ALPHA") { + t.Error("▸ marker should still work with --no-flash") + } +} + // The status bar is exactly pane width — never wider. func TestStatusBarWidth(t *testing.T) { path := filepath.Join(t.TempDir(), "REVIEW.md") From 9625aad87218995c368e5880c43f43602d080b88 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Fri, 31 Jul 2026 23:57:03 -0400 Subject: [PATCH 06/14] feat: --no-flash flag to disable the change-flash (update pointer) --- main.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 9d9b69c..e31822b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ usage: sidecar [file.md] (default: ./SIDECAR.md) sidecar init [file.md] scaffold the file; offer to git-exclude it and wire it into Claude Code sidecar --static [file] render once to stdout and exit (no TUI) + sidecar --no-flash [file] disable the subtle change-flash (▸ still shows) keys: j/k, arrows, PgUp/PgDn scroll g / G top / bottom @@ -32,7 +33,7 @@ moment it appears, then live-reloads on every change. ` func main() { - path := defaultFile + // Subcommands dispatch on the first arg, exactly as before. if len(os.Args) > 1 { switch os.Args[1] { case "-h", "--help": @@ -45,8 +46,18 @@ func main() { os.Exit(runStatic(os.Args[2:])) case "init": os.Exit(runInit(os.Args[2:])) + } + } + + // Viewer mode: an optional file path plus the --no-flash flag, any order. + path := defaultFile + noFlash := false + for _, a := range os.Args[1:] { + switch a { + case "--no-flash": + noFlash = true default: - path = os.Args[1] + path = a } } @@ -58,10 +69,8 @@ func main() { offerCreate(abs) // if missing and interactive, offer to scaffold before opening - p := tea.NewProgram(newModel(abs, false), + p := tea.NewProgram(newModel(abs, noFlash), tea.WithAltScreen(), - // No mouse capture: keeps the terminal's native text selection and - // clickable links working. Scroll with the keyboard (see keys below). ) go watchFile(abs, p.Send) From e3737d8ed51c650bd4dca3facbb1b8d799641430 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 00:03:20 -0400 Subject: [PATCH 07/14] fix: don't recompose stale content over the waiting/error view (update pointer) recompose() could resurrect stale rendered content over the "waiting for file" placeholder or render-error view if the file was deleted (or began erroring) while a line-flash timer was pending. Guard recompose() against fileMissing/loadErr, and reset renderedLines/changed/lineFlash on both failure exits of reload so a later flash-off is a no-op. Co-Authored-By: Claude Opus 4.8 --- diff_test.go | 9 +++++++++ ui.go | 8 +++++++- ui_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/diff_test.go b/diff_test.go index 5746187..c98f703 100644 --- a/diff_test.go +++ b/diff_test.go @@ -109,3 +109,12 @@ func TestComposeMarkedNoFlashNoBackground(t *testing.T) { t.Errorf("no flash should not inject a background:\n%q", out) } } + +func TestChangedLinesDeletionNoSpuriousMark(t *testing.T) { + // Deleting a line should not mark the surrounding context in the new render. + old := []string{"a", "b", "c"} + nw := []string{"a", "c"} + if got := idx(changedLines(old, nw)); len(got) != 0 { + t.Errorf("deletion → %v, want none (nothing added in new)", got) + } +} diff --git a/ui.go b/ui.go index e32a57a..5cd9d80 100644 --- a/ui.go +++ b/ui.go @@ -178,6 +178,9 @@ func (m *model) reload(force bool) (changed bool) { m.vp.SetContent(fmt.Sprintf("\n Error reading %s:\n %v", m.path, err)) } m.vp.GotoTop() + m.renderedLines = nil + m.changed = nil + m.lineFlash = false return false } if st, err := os.Stat(m.path); err == nil { @@ -200,6 +203,9 @@ func (m *model) reload(force bool) (changed bool) { if err != nil { m.loadErr = err m.vp.SetContent(fmt.Sprintf("\n Render error: %v", err)) + m.renderedLines = nil + m.changed = nil + m.lineFlash = false return false } lines := strings.Split(rendered, "\n") @@ -223,7 +229,7 @@ func (m *model) reload(force bool) (changed bool) { // recompose re-renders the cached lines for the current flash state without // re-reading the file — used when only the flash toggles. func (m *model) recompose() { - if m.renderedLines == nil { + if m.renderedLines == nil || m.fileMissing || m.loadErr != nil { return } display := composeMarked(m.renderedLines, m.changed, m.lineFlash && !m.noFlash, m.renderWidth()) diff --git a/ui_test.go b/ui_test.go index 9eb2d1f..cd3eddc 100644 --- a/ui_test.go +++ b/ui_test.go @@ -248,6 +248,44 @@ func TestUpdatePointerNoFlashFlag(t *testing.T) { } } +// A file deleted while the line flash is pending must not have its stale +// content recomposed over the "waiting for file" placeholder. +func TestUpdatePointerFileMissingDuringFlash(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) + + // A change starts the flash. + writeFile(t, path, "# T\n\n- ALPHA\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + if !m.lineFlash { + t.Fatal("expected flash after change") + } + + // File disappears before the flash timer fires. + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + next, _ = m.Update(fileEventMsg{}) + m = next.(model) + if !m.fileMissing { + t.Fatal("expected fileMissing after delete") + } + + // Flash timer fires now → must NOT resurrect the old document. + next, _ = m.Update(lineFlashOffMsg{}) + m = next.(model) + view := stripANSI(m.vp.View()) + if strings.Contains(view, "ALPHA") { + t.Errorf("stale content recomposed over waiting view:\n%s", view) + } + if !strings.Contains(view, "waiting for") { + t.Errorf("waiting placeholder lost:\n%s", view) + } +} + // The status bar is exactly pane width — never wider. func TestStatusBarWidth(t *testing.T) { path := filepath.Join(t.TempDir(), "REVIEW.md") From 3b1ba9ed75eccbd33eb9656f2044c6e2aa998491 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 00:26:16 -0400 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20PR=20#13=20review=20=E2=80=94=20ra?= =?UTF-8?q?w-ANSI=20marker,=20no=20blank-line=20tint,=20bounded=20LCS,=20r?= =?UTF-8?q?eject=20unknown=20flags=20(update=20pointer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- diff.go | 50 +++++++++++++++++++++++++++++++------------------- diff_test.go | 8 ++++++++ main.go | 8 ++++++-- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/diff.go b/diff.go index 022666e..9b5ae33 100644 --- a/diff.go +++ b/diff.go @@ -5,8 +5,6 @@ import ( "fmt" "regexp" "strings" - - "github.com/charmbracelet/lipgloss" ) var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -30,14 +28,29 @@ func changedLines(oldLines, newLines []string) map[int]bool { n[i] = stripANSI(l) } - // LCS length table. - lcs := make([][]int, len(o)+1) + changed := map[int]bool{} + + // Trim the common prefix and suffix — unchanged lines need no diffing and + // keep the DP table proportional to the edited region, not the whole file. + start := 0 + for start < len(o) && start < len(n) && o[start] == n[start] { + start++ + } + endO, endN := len(o), len(n) + for endO > start && endN > start && o[endO-1] == n[endN-1] { + endO-- + endN-- + } + om, nm := o[start:endO], n[start:endN] + + // LCS length table over the differing middle. + lcs := make([][]int, len(om)+1) for i := range lcs { - lcs[i] = make([]int, len(n)+1) + lcs[i] = make([]int, len(nm)+1) } - for i := len(o) - 1; i >= 0; i-- { - for j := len(n) - 1; j >= 0; j-- { - if o[i] == n[j] { + for i := len(om) - 1; i >= 0; i-- { + for j := len(nm) - 1; j >= 0; j-- { + if om[i] == nm[j] { lcs[i][j] = lcs[i+1][j+1] + 1 } else if lcs[i+1][j] >= lcs[i][j+1] { lcs[i][j] = lcs[i+1][j] @@ -47,18 +60,16 @@ func changedLines(oldLines, newLines []string) map[int]bool { } } - // Walk the table; lines in n that aren't part of the common subsequence - // are the changed ones. - changed := map[int]bool{} + // Walk; lines in the middle of nm not on the common subsequence are changed. i, j := 0, 0 - for j < len(n) { - if i < len(o) && o[i] == n[j] { + for j < len(nm) { + if i < len(om) && om[i] == nm[j] { i++ j++ - } else if i < len(o) && lcs[i+1][j] >= lcs[i][j+1] { - i++ // a line from old was removed + } else if i < len(om) && lcs[i+1][j] >= lcs[i][j+1] { + i++ } else { - changed[j] = true // n[j] is new/modified + changed[start+j] = true j++ } } @@ -70,8 +81,9 @@ func changedLines(oldLines, newLines []string) map[int]bool { // gets a subtle background tint. Order matters — the bullet is swapped first so // applyLineBg re-establishes the background after the reset the swap introduces. func composeMarked(lines []string, changed map[int]bool, flash bool, width int) string { - updatedMark := lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorUpdated)).Bold(true).Render("▸ ") + ur, ug, ub := hexToRGB(colorUpdated) + tr, tg, tb := hexToRGB(colorText) + updatedMark := fmt.Sprintf("\x1b[1;38;2;%d;%d;%dm▸ \x1b[22;38;2;%d;%d;%dm", ur, ug, ub, tr, tg, tb) out := make([]string, len(lines)) for i, ln := range lines { @@ -79,7 +91,7 @@ func composeMarked(lines []string, changed map[int]bool, flash bool, width int) if isBulletLine(ln) { ln = strings.Replace(ln, "• ", updatedMark, 1) } - if flash { + if flash && visibleWidth(ln) > 0 { ln = applyLineBg(ln, colorFlashLineBg, width) } } diff --git a/diff_test.go b/diff_test.go index c98f703..5dbc178 100644 --- a/diff_test.go +++ b/diff_test.go @@ -110,6 +110,14 @@ func TestComposeMarkedNoFlashNoBackground(t *testing.T) { } } +func TestChangedLinesTrimmedContext(t *testing.T) { + old := []string{"h", "a", "b", "c", "z"} + nw := []string{"h", "a", "X", "c", "z"} // only index 2 changed + if got := idx(changedLines(old, nw)); !eq(got, []int{2}) { + t.Errorf("trimmed-context change → %v, want [2]", got) + } +} + func TestChangedLinesDeletionNoSpuriousMark(t *testing.T) { // Deleting a line should not mark the surrounding context in the new render. old := []string{"a", "b", "c"} diff --git a/main.go b/main.go index e31822b..eac213e 100644 --- a/main.go +++ b/main.go @@ -53,9 +53,13 @@ func main() { path := defaultFile noFlash := false for _, a := range os.Args[1:] { - switch a { - case "--no-flash": + switch { + case a == "--no-flash": noFlash = true + case strings.HasPrefix(a, "-"): + fmt.Fprintf(os.Stderr, "sidecar: unknown flag %q\n\n", a) + fmt.Print(help) + os.Exit(2) default: path = a } From db5f9c8a9082e45cf1a0e1b98467b552fef496aa Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 00:37:08 -0400 Subject: [PATCH 09/14] fix: cap LCS table on huge files; restore mouse-capture comment (update pointer) Co-Authored-By: Claude Opus 4.8 --- diff.go | 8 ++++++++ diff_test.go | 18 ++++++++++++++++++ main.go | 2 ++ 3 files changed, 28 insertions(+) diff --git a/diff.go b/diff.go index 9b5ae33..b0db86c 100644 --- a/diff.go +++ b/diff.go @@ -43,6 +43,14 @@ func changedLines(oldLines, newLines []string) map[int]bool { } om, nm := o[start:endO], n[start:endN] + // Guard against a pathological table on very large files: past this many + // cells, skip line marking entirely rather than allocate hundreds of MB + // per render. Real queues are far smaller; this only trips on huge docs. + const maxDiffCells = 4 << 20 // ~32 MB of int cells + if len(om)*len(nm) > maxDiffCells { + return changed // empty — no markers, but the render still happens + } + // LCS length table over the differing middle. lcs := make([][]int, len(om)+1) for i := range lcs { diff --git a/diff_test.go b/diff_test.go index 5dbc178..97d8077 100644 --- a/diff_test.go +++ b/diff_test.go @@ -126,3 +126,21 @@ func TestChangedLinesDeletionNoSpuriousMark(t *testing.T) { t.Errorf("deletion → %v, want none (nothing added in new)", got) } } + +func TestChangedLinesCapDegradesToEmpty(t *testing.T) { + // Two large, fully-different slices would blow the table; the cap must + // return an empty set instead of allocating/panicking. + n := 5000 + old := make([]string, n) + nw := make([]string, n) + for i := 0; i < n; i++ { + old[i] = "old-" + string(rune('a'+i%26)) + nw[i] = "new-" + string(rune('a'+i%26)) + } + // Force no common prefix/suffix so the trim can't shrink it. + old[0], nw[0] = "A", "B" + old[n-1], nw[n-1] = "Y", "Z" + if got := len(changedLines(old, nw)); got != 0 { + t.Errorf("oversized diff should mark nothing, got %d", got) + } +} diff --git a/main.go b/main.go index eac213e..2dc9ac3 100644 --- a/main.go +++ b/main.go @@ -75,6 +75,8 @@ func main() { p := tea.NewProgram(newModel(abs, noFlash), tea.WithAltScreen(), + // No mouse capture: keeps the terminal's native text selection and + // clickable links working. Scroll with the keyboard (see keys below). ) go watchFile(abs, p.Send) From dc8e734d2d75565d52e9cdf81fe1e4410773dd45 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 00:47:19 -0400 Subject: [PATCH 10/14] fix: mark owning bullet on wrapped-line edits; tighten LCS cap; doc degrade (update pointer) Co-Authored-By: Claude Opus 4.8 --- diff.go | 35 +++++++++++++++++++++++++++-------- diff_test.go | 30 ++++++++++++++++++++++++++++++ ui.go | 3 +++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/diff.go b/diff.go index b0db86c..c92cb9b 100644 --- a/diff.go +++ b/diff.go @@ -46,7 +46,7 @@ func changedLines(oldLines, newLines []string) map[int]bool { // Guard against a pathological table on very large files: past this many // cells, skip line marking entirely rather than allocate hundreds of MB // per render. Real queues are far smaller; this only trips on huge docs. - const maxDiffCells = 4 << 20 // ~32 MB of int cells + const maxDiffCells = 1 << 18 // ~2 MB of int cells; ample for any real queue if len(om)*len(nm) > maxDiffCells { return changed // empty — no markers, but the render still happens } @@ -93,15 +93,34 @@ func composeMarked(lines []string, changed map[int]bool, flash bool, width int) tr, tg, tb := hexToRGB(colorText) updatedMark := fmt.Sprintf("\x1b[1;38;2;%d;%d;%dm▸ \x1b[22;38;2;%d;%d;%dm", ur, ug, ub, tr, tg, tb) + // Resolve which bullet lines to mark. A changed bullet marks itself; a + // changed non-bullet line (e.g. a wrapped continuation of a long item) + // marks its owning bullet — the nearest preceding bullet line, bounded by a + // blank line so we don't cross into a previous block. + swap := map[int]bool{} + for i := range lines { + if !changed[i] { + continue + } + if isBulletLine(lines[i]) { + swap[i] = true + continue + } + for j := i - 1; j >= 0 && visibleWidth(lines[j]) > 0; j-- { + if isBulletLine(lines[j]) { + swap[j] = true + break + } + } + } + out := make([]string, len(lines)) for i, ln := range lines { - if changed[i] { - if isBulletLine(ln) { - ln = strings.Replace(ln, "• ", updatedMark, 1) - } - if flash && visibleWidth(ln) > 0 { - ln = applyLineBg(ln, colorFlashLineBg, width) - } + if swap[i] { + ln = strings.Replace(ln, "• ", updatedMark, 1) + } + if changed[i] && flash && visibleWidth(ln) > 0 { + ln = applyLineBg(ln, colorFlashLineBg, width) } out[i] = ln } diff --git a/diff_test.go b/diff_test.go index 97d8077..5545342 100644 --- a/diff_test.go +++ b/diff_test.go @@ -110,6 +110,36 @@ func TestComposeMarkedNoFlashNoBackground(t *testing.T) { } } +func TestComposeMarkedWrappedContinuationMarksOwningBullet(t *testing.T) { + // A long bullet wrapped onto a continuation line; the edit landed on the + // continuation (index 1). The ▸ must appear on the bullet line (index 0). + lines := []string{ + "\x1b[38;2;208;208;208m• \x1b[0malpha the first", + "\x1b[38;2;208;208;208m and its wrapped tail\x1b[0m", + } + out := composeMarked(lines, map[int]bool{1: true}, false, 40) + got := strings.Split(stripANSI(out), "\n") + if !strings.Contains(got[0], "▸ ") { + t.Errorf("owning bullet not marked:\n%q", got[0]) + } + if strings.Contains(got[1], "▸") { + t.Errorf("continuation line should not itself get ▸:\n%q", got[1]) + } +} + +func TestComposeMarkedChangedProseAfterBlankNoMarker(t *testing.T) { + // A changed non-bullet line preceded by a blank line: the backward walk + // stops at the blank, so nothing is marked. + lines := []string{ + "", + "\x1b[38;2;208;208;208mjust prose\x1b[0m", + } + out := composeMarked(lines, map[int]bool{1: true}, false, 40) + if strings.Contains(out, "▸") { + t.Errorf("prose after a blank should get no marker:\n%q", stripANSI(out)) + } +} + func TestChangedLinesTrimmedContext(t *testing.T) { old := []string{"h", "a", "b", "c", "z"} nw := []string{"h", "a", "X", "c", "z"} // only index 2 changed diff --git a/ui.go b/ui.go index 5cd9d80..82e5c7c 100644 --- a/ui.go +++ b/ui.go @@ -212,6 +212,9 @@ func (m *model) reload(force bool) (changed bool) { var changedMap map[int]bool if m.prevBaseline != "" { + // Deliberate degrade: if the baseline fails to render we show no + // markers this pass rather than surface an error — the content render + // above already succeeded. if base, berr := renderMarkdown(m.prevBaseline, m.renderWidth()); berr == nil { changedMap = changedLines(strings.Split(base, "\n"), lines) } From bd6b8cdafa80334c5a8031c697e18151cd3379e8 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 12:44:16 -0400 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20PR=20#13=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20int32=20diff=20cap,=20O(n)=20owning-bullet,=20hasBa?= =?UTF-8?q?seline,=20width=20test,=20README=20(update=20pointer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++++ diff.go | 48 +++++++++++++++++++++++++++--------------------- diff_test.go | 19 +++++++++++++++++++ ui.go | 13 +++++++++++-- ui_test.go | 19 +++++++++++++++++++ 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 6afe465..2ae53d3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Sidecar is a live, scrollable Markdown viewer for a narrow terminal pane. It’s ``` sidecar [file.md] # default: ./SIDECAR.md sidecar init [file.md] # create the file, and optionally keep it out of git +sidecar --no-flash [file] disable the subtle change-flash (▸ still shows) ``` ## Why two panes @@ -33,6 +34,9 @@ It then offers to add a note to `CLAUDE.md`, and optionally a per-turn `UserProm - 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. - 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. +- Points at what changed: on reload, a changed bullet's `•` becomes a bright + `▸` (until the next change), and changed lines get a brief, subtle background + flash. Disable the flash with `--no-flash`. - Shows a thin status bar: the filename, the time since the last update, and the scroll percentage. ## Rendering style diff --git a/diff.go b/diff.go index c92cb9b..e733f1e 100644 --- a/diff.go +++ b/diff.go @@ -46,15 +46,17 @@ func changedLines(oldLines, newLines []string) map[int]bool { // Guard against a pathological table on very large files: past this many // cells, skip line marking entirely rather than allocate hundreds of MB // per render. Real queues are far smaller; this only trips on huge docs. - const maxDiffCells = 1 << 18 // ~2 MB of int cells; ample for any real queue + const maxDiffCells = 1 << 20 // ~4 MB with int32 cells; ~1000 lines a side if len(om)*len(nm) > maxDiffCells { return changed // empty — no markers, but the render still happens } - // LCS length table over the differing middle. - lcs := make([][]int, len(om)+1) + // LCS length table over the differing middle. int32 halves the per-cell + // footprint vs int — LCS lengths are bounded by the line count, far under + // int32 max. + lcs := make([][]int32, len(om)+1) for i := range lcs { - lcs[i] = make([]int, len(nm)+1) + lcs[i] = make([]int32, len(nm)+1) } for i := len(om) - 1; i >= 0; i-- { for j := len(nm) - 1; j >= 0; j-- { @@ -93,24 +95,25 @@ func composeMarked(lines []string, changed map[int]bool, flash bool, width int) tr, tg, tb := hexToRGB(colorText) updatedMark := fmt.Sprintf("\x1b[1;38;2;%d;%d;%dm▸ \x1b[22;38;2;%d;%d;%dm", ur, ug, ub, tr, tg, tb) - // Resolve which bullet lines to mark. A changed bullet marks itself; a - // changed non-bullet line (e.g. a wrapped continuation of a long item) - // marks its owning bullet — the nearest preceding bullet line, bounded by a - // blank line so we don't cross into a previous block. + // owner[i] = index of the bullet line that owns line i (itself if it is a + // bullet; the nearest preceding bullet within the same block otherwise), or + // -1 if none. Single O(n) pass, reset at blank lines so ownership can't + // cross a block boundary. + owner := make([]int, len(lines)) + cur := -1 + for i, ln := range lines { + if visibleWidth(ln) == 0 { + cur = -1 + } else if isBulletLine(ln) { + cur = i + } + owner[i] = cur + } + swap := map[int]bool{} for i := range lines { - if !changed[i] { - continue - } - if isBulletLine(lines[i]) { - swap[i] = true - continue - } - for j := i - 1; j >= 0 && visibleWidth(lines[j]) > 0; j-- { - if isBulletLine(lines[j]) { - swap[j] = true - break - } + if changed[i] && owner[i] >= 0 { + swap[owner[i]] = true } } @@ -119,7 +122,7 @@ func composeMarked(lines []string, changed map[int]bool, flash bool, width int) if swap[i] { ln = strings.Replace(ln, "• ", updatedMark, 1) } - if changed[i] && flash && visibleWidth(ln) > 0 { + if (changed[i] || swap[i]) && flash && visibleWidth(ln) > 0 { ln = applyLineBg(ln, colorFlashLineBg, width) } out[i] = ln @@ -137,6 +140,9 @@ func isBulletLine(ln string) bool { // applyLineBg tints the whole visible line with the given hex background, // re-applying it after each SGR reset (a reset would otherwise clear the // background mid-line), and pads to width so the tint spans the pane. +// It re-applies the background after each standalone ESC[0m reset; this relies +// on termenv emitting resets as a bare ESC[0m (a combined ESC[0;…m would drop +// the tint mid-line — not produced by the current renderer). func applyLineBg(ln, hex string, width int) string { r, g, b := hexToRGB(hex) bg := fmt.Sprintf("\x1b[48;2;%d;%d;%dm", r, g, b) diff --git a/diff_test.go b/diff_test.go index 5545342..e5b6d49 100644 --- a/diff_test.go +++ b/diff_test.go @@ -157,6 +157,25 @@ func TestChangedLinesDeletionNoSpuriousMark(t *testing.T) { } } +func TestComposeMarkedNeverWiderThanWidth(t *testing.T) { + for _, w := range []int{20, 40, 80} { + lines, _ := func() ([]string, error) { + out, err := renderMarkdown("# Title\n\n- a fairly long bullet item that will wrap\n- short\n\nsome prose here too\n", w) + return strings.Split(out, "\n"), err + }() + all := map[int]bool{} + for i := range lines { + all[i] = true + } + out := composeMarked(lines, all, true, w) // flash on, everything changed + for _, ln := range strings.Split(out, "\n") { + if visibleWidth(ln) > w { + t.Errorf("width %d: composed line exceeds pane (%d):\n%q", w, visibleWidth(ln), ln) + } + } + } +} + func TestChangedLinesCapDegradesToEmpty(t *testing.T) { // Two large, fully-different slices would blow the table; the cap must // return an empty set instead of allocating/panicking. diff --git a/ui.go b/ui.go index 82e5c7c..32d49f6 100644 --- a/ui.go +++ b/ui.go @@ -58,7 +58,13 @@ type model struct { flash bool // update pointer - prevBaseline string // content before the last change; diffed vs raw + prevBaseline string // content before the last change; diffed vs raw + // hasBaseline is true once a prior successful content render exists — it + // distinguishes "no baseline yet" (very first render, always unmarked) + // from "baseline was a legitimately empty file" (prevBaseline == "" but + // still a real prior state to diff against). Set true at the end of the + // first successful reload, so that reload itself marks nothing. + hasBaseline bool renderedLines []string // cached rendered lines for cheap recompose changed map[int]bool // changed line indices in the current render lineFlash bool // subtle line-bg flash active @@ -181,6 +187,7 @@ func (m *model) reload(force bool) (changed bool) { m.renderedLines = nil m.changed = nil m.lineFlash = false + m.hasBaseline = false return false } if st, err := os.Stat(m.path); err == nil { @@ -206,12 +213,13 @@ func (m *model) reload(force bool) (changed bool) { m.renderedLines = nil m.changed = nil m.lineFlash = false + m.hasBaseline = false return false } lines := strings.Split(rendered, "\n") var changedMap map[int]bool - if m.prevBaseline != "" { + if m.hasBaseline { // Deliberate degrade: if the baseline fails to render we show no // markers this pass rather than surface an error — the content render // above already succeeded. @@ -226,6 +234,7 @@ func (m *model) reload(force bool) (changed bool) { offset := m.vp.YOffset m.vp.SetContent(display) m.vp.SetYOffset(offset) + m.hasBaseline = true return contentChanged } diff --git a/ui_test.go b/ui_test.go index cd3eddc..8ce17b9 100644 --- a/ui_test.go +++ b/ui_test.go @@ -286,6 +286,25 @@ func TestUpdatePointerFileMissingDuringFlash(t *testing.T) { } } +// An empty-file baseline is a legitimate prior state, distinct from "no +// baseline yet" — a change after it must still be marked. +func TestUpdatePointerEmptyBaselineThenLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "") // empty file + m := testModel(t, path) + // first non-empty content: this is the first real render, marks nothing + writeFile(t, path, "# T\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + // now add a bullet — must be marked even though the prior baseline was empty + writeFile(t, path, "# T\n\n- added\n") + next, _ = m.Update(fileEventMsg{}) + m = next.(model) + if !strings.Contains(stripANSI(m.vp.View()), "▸ added") { + t.Errorf("added bullet not marked after empty-file baseline:\n%s", stripANSI(m.vp.View())) + } +} + // The status bar is exactly pane width — never wider. func TestStatusBarWidth(t *testing.T) { path := filepath.Join(t.TempDir(), "REVIEW.md") From 03b6032a26e9ae97808dbccbc202f4a5198a0213 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 12:56:43 -0400 Subject: [PATCH 12/14] fix: r-key flashes on change; hexToRGB neutral fallback (update pointer) Co-Authored-By: Claude Opus 4.8 --- diff.go | 8 ++++++-- ui.go | 11 ++++++++++- ui_test.go | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/diff.go b/diff.go index e733f1e..9493df2 100644 --- a/diff.go +++ b/diff.go @@ -155,10 +155,14 @@ func applyLineBg(ln, hex string, width int) string { return bg + body + pad + reset } -// hexToRGB parses "#RRGGBB" into its components. +// hexToRGB parses "#RRGGBB" into its components. Input is expected to be a +// valid hex color const; on a malformed value it falls back to a visible +// mid-grey rather than silently yielding black. func hexToRGB(hex string) (int, int, int) { hex = strings.TrimPrefix(hex, "#") var r, g, b int - fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + if n, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); n != 3 || err != nil { + return 128, 128, 128 + } return r, g, b } diff --git a/ui.go b/ui.go index 32d49f6..9f0f66a 100644 --- a/ui.go +++ b/ui.go @@ -90,7 +90,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q", "ctrl+c": return m, tea.Quit case "r": - m.reload(true) + if m.reload(true) { + m.flash = true + cmds := []tea.Cmd{flashOff()} + if !m.noFlash { + m.lineFlash = true + m.recompose() + cmds = append(cmds, lineFlashOff()) + } + return m, tea.Batch(cmds...) + } return m, nil case "g", "home": m.vp.GotoTop() diff --git a/ui_test.go b/ui_test.go index 8ce17b9..86480b1 100644 --- a/ui_test.go +++ b/ui_test.go @@ -305,6 +305,28 @@ func TestUpdatePointerEmptyBaselineThenLine(t *testing.T) { } } +// r key should flash like a file event when a real change is loaded. +func TestUpdatePointerRKeyFlashesOnChange(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) + + // Change on disk, then force-reload with `r` before any fileEventMsg. + writeFile(t, path, "# T\n\n- ALPHA\n") + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m = next.(model) + + if !m.lineFlash { + t.Error("r after a real change should set the line flash") + } + if cmd == nil { + t.Error("r after a change should schedule flash-off commands") + } + if !strings.Contains(stripANSI(m.vp.View()), "▸ ALPHA") { + t.Errorf("r should render the change markers:\n%s", stripANSI(m.vp.View())) + } +} + // The status bar is exactly pane width — never wider. func TestStatusBarWidth(t *testing.T) { path := filepath.Join(t.TempDir(), "REVIEW.md") From a6de48324298ef097dd8dc8f9f9061c0fc9f74d6 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 13:07:34 -0400 Subject: [PATCH 13/14] fix: seed baseline on first render so resize/r before a change marks nothing (update pointer) Co-Authored-By: Claude Opus 4.8 --- ui.go | 8 ++++++++ ui_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/ui.go b/ui.go index 9f0f66a..c03d2c5 100644 --- a/ui.go +++ b/ui.go @@ -215,6 +215,14 @@ func (m *model) reload(force bool) (changed bool) { } m.raw = raw + // First successful render — and after an error/missing-file recovery, where + // hasBaseline was reset — seed the baseline to the content itself, so a + // forced re-render (resize, r) before any real change diffs against itself + // and marks nothing. + if !m.hasBaseline { + m.prevBaseline = raw + } + rendered, err := renderMarkdown(raw, m.renderWidth()) if err != nil { m.loadErr = err diff --git a/ui_test.go b/ui_test.go index 86480b1..7c3145c 100644 --- a/ui_test.go +++ b/ui_test.go @@ -341,3 +341,30 @@ func TestStatusBarWidth(t *testing.T) { } } } + +// A resize before any content change must not mark anything (regression: +// hasBaseline true + empty prevBaseline diffed against the whole document). +func TestUpdatePointerResizeBeforeChangeUnmarked(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n- beta\n") + m := testModel(t, path) // first render via the initial WindowSizeMsg + + next, _ := m.Update(tea.WindowSizeMsg{Width: 50, Height: 20}) + m = next.(model) + if strings.Contains(stripANSI(m.vp.View()), "▸") { + t.Errorf("resize before any change should mark nothing:\n%s", stripANSI(m.vp.View())) + } +} + +// The `r` force-reload before any content change must not mark anything. +func TestUpdatePointerRKeyBeforeChangeUnmarked(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m = next.(model) + if strings.Contains(stripANSI(m.vp.View()), "▸") { + t.Errorf("r before any change should mark nothing:\n%s", stripANSI(m.vp.View())) + } +} From a032a2c06903e9c0a258f9dc510896e2b59e2cb1 Mon Sep 17 00:00:00 2001 From: Than Tibbetts Date: Sat, 1 Aug 2026 13:16:52 -0400 Subject: [PATCH 14/14] fix: flash generation token so rapid changes don't cancel each other's flash (update pointer) Co-Authored-By: Claude Opus 4.8 --- ui.go | 37 +++++++++++++++++++++++++------------ ui_test.go | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/ui.go b/ui.go index c03d2c5..f072505 100644 --- a/ui.go +++ b/ui.go @@ -19,21 +19,21 @@ type fileEventMsg struct{} type tickMsg time.Time // flashOffMsg clears the post-reload status-bar highlight. -type flashOffMsg struct{} +type flashOffMsg struct{ gen int } const flashDuration = 450 * time.Millisecond -func flashOff() tea.Cmd { - return tea.Tick(flashDuration, func(time.Time) tea.Msg { return flashOffMsg{} }) +func flashOff(gen int) tea.Cmd { + return tea.Tick(flashDuration, func(time.Time) tea.Msg { return flashOffMsg{gen} }) } // lineFlashOffMsg clears the subtle post-reload line-background flash. -type lineFlashOffMsg struct{} +type lineFlashOffMsg struct{ gen int } const lineFlashDuration = 500 * time.Millisecond -func lineFlashOff() tea.Cmd { - return tea.Tick(lineFlashDuration, func(time.Time) tea.Msg { return lineFlashOffMsg{} }) +func lineFlashOff(gen int) tea.Cmd { + return tea.Tick(lineFlashDuration, func(time.Time) tea.Msg { return lineFlashOffMsg{gen} }) } type model struct { @@ -69,6 +69,7 @@ type model struct { changed map[int]bool // changed line indices in the current render lineFlash bool // subtle line-bg flash active noFlash bool // --no-flash: suppress the line flash + flashGen int // bumped on each change; a stale flash-off msg is ignored } func newModel(path string, noFlash bool) model { @@ -91,12 +92,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "r": if m.reload(true) { + m.flashGen++ + gen := m.flashGen m.flash = true - cmds := []tea.Cmd{flashOff()} + cmds := []tea.Cmd{flashOff(gen)} if !m.noFlash { m.lineFlash = true m.recompose() - cmds = append(cmds, lineFlashOff()) + cmds = append(cmds, lineFlashOff(gen)) } return m, tea.Batch(cmds...) } @@ -123,12 +126,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fileEventMsg: if m.reload(false) { + m.flashGen++ + gen := m.flashGen m.flash = true - cmds := []tea.Cmd{flashOff()} + cmds := []tea.Cmd{flashOff(gen)} if !m.noFlash { m.lineFlash = true m.recompose() // show the flash background immediately - cmds = append(cmds, lineFlashOff()) + cmds = append(cmds, lineFlashOff(gen)) } return m, tea.Batch(cmds...) } @@ -145,22 +150,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { changed = m.reload(false) } if changed { + m.flashGen++ + gen := m.flashGen m.flash = true - cmds := []tea.Cmd{tick(), flashOff()} + cmds := []tea.Cmd{tick(), flashOff(gen)} if !m.noFlash { m.lineFlash = true m.recompose() - cmds = append(cmds, lineFlashOff()) + cmds = append(cmds, lineFlashOff(gen)) } return m, tea.Batch(cmds...) } return m, tick() case flashOffMsg: + if msg.gen != m.flashGen { + return m, nil + } m.flash = false return m, nil case lineFlashOffMsg: + if msg.gen != m.flashGen { + return m, nil + } m.lineFlash = false m.recompose() return m, nil diff --git a/ui_test.go b/ui_test.go index 7c3145c..64d0b7b 100644 --- a/ui_test.go +++ b/ui_test.go @@ -163,7 +163,7 @@ func TestReloadFlash(t *testing.T) { } // flashOffMsg clears it. - next, _ = m.Update(flashOffMsg{}) + next, _ = m.Update(flashOffMsg{gen: m.flashGen}) m = next.(model) if m.flash { t.Error("flash not cleared by flashOffMsg") @@ -221,7 +221,7 @@ func TestUpdatePointerFlashOffKeepsMarker(t *testing.T) { if !strings.Contains(m.vp.View(), "\x1b[48;2;") { t.Error("expected flash background right after change") } - next, _ = m.Update(lineFlashOffMsg{}) + next, _ = m.Update(lineFlashOffMsg{gen: m.flashGen}) m = next.(model) if strings.Contains(m.vp.View(), "\x1b[48;2;") { t.Error("flash background should clear on lineFlashOffMsg") @@ -231,6 +231,40 @@ func TestUpdatePointerFlashOffKeepsMarker(t *testing.T) { } } +func TestUpdatePointerOverlappingFlashNotCancelled(t *testing.T) { + path := filepath.Join(t.TempDir(), "SIDECAR.md") + writeFile(t, path, "# T\n\n- alpha\n") + m := testModel(t, path) + + // First change → flash, generation g1. + writeFile(t, path, "# T\n\n- ALPHA\n") + next, _ := m.Update(fileEventMsg{}) + m = next.(model) + g1 := m.flashGen + + // Second change before the first timer fires → flash, generation g2 > g1. + writeFile(t, path, "# T\n\n- ALPHA\n- beta\n") + next, _ = m.Update(fileEventMsg{}) + m = next.(model) + if m.flashGen == g1 { + t.Fatal("second change should bump the flash generation") + } + + // The FIRST timer now fires (stale gen). It must NOT clear the second flash. + next, _ = m.Update(lineFlashOffMsg{gen: g1}) + m = next.(model) + if !m.lineFlash { + t.Error("a stale flash-off must not cancel the newer flash") + } + + // The current-generation timer clears it. + next, _ = m.Update(lineFlashOffMsg{gen: m.flashGen}) + m = next.(model) + if m.lineFlash { + t.Error("current-generation flash-off should clear the flash") + } +} + func TestUpdatePointerNoFlashFlag(t *testing.T) { path := filepath.Join(t.TempDir(), "SIDECAR.md") writeFile(t, path, "# T\n\n- alpha\n") @@ -275,7 +309,7 @@ func TestUpdatePointerFileMissingDuringFlash(t *testing.T) { } // Flash timer fires now → must NOT resurrect the old document. - next, _ = m.Update(lineFlashOffMsg{}) + next, _ = m.Update(lineFlashOffMsg{gen: m.flashGen}) m = next.(model) view := stripANSI(m.vp.View()) if strings.Contains(view, "ALPHA") {