clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

README for publishing, demo GIF and VHS tape; show the path as typed, relative diff headers, newest-first archives, plain names for archive events

Dami committed 132d354
commit
132d3541102086ec167cc889f259e987687aeaa9
parent
92b186aef648f523a272e229177ee9d221ad7f63
author
Dami <dami.etoile@gmail.com>
11 files changed, +265 -88
MREADME.md161+++++++++++++++---------------
Ademo/README.md7+
Ademo/demo.tape62++++++++++++
Adocs/media/demo.gifbin
Adocs/media/demo.pngbin
Minternal/gitx/git.go4+
Minternal/spec/load.go45++++++++-
Minternal/spec/load_test.go56++++++++++
Minternal/ui/model.go3+-
Minternal/ui/render.go12++-
Mmain.go3+-
M README.md +80 -81
@@ -1,115 +1,114 @@
11 # lazyopenspec22 3-A lazygit-style terminal viewer for [OpenSpec](https://github.com/Fission-AI/OpenSpec) repos. Leave it open in a4-split while agents work. It shows each change's artifacts, task progress, and spec deltas updating live, with an5-activity feed of what was touched.3+A lazygit-style terminal viewer for [OpenSpec](https://github.com/Fission-AI/OpenSpec) repos, built on4+[Bubble Tea v2](https://github.com/charmbracelet/bubbletea). Leave it open in a split while agents work and watch5+changes, task progress, and spec deltas update as files land on disk, with a running feed of what was touched.66 7+![Following a swarm of simulated agents: tasks checked off, new changes drafted, a change archived](docs/media/demo.gif)8+9+## Install10+11+```sh12+go install github.com/FelineStateMachine/lazyopenspec@latest # needs Go 1.27+713 ```8- lazyopenspec │ ~/developer/tally │ main +1 uncommitted │ 5 active, 1 rea… FOLLOW │ ● last edit just now9-╭─[1] Changes──────────────────────────────╮╭─Change harden-session-security─────────────────────────────────────────╮10-│ add-csv-export-filters PDST ▰▰▰▰▰ 8/8││ harden-session-security spec-driven edited just now │11-│ add-due-dates PDST ▱▱▱▱▱ ││ A September penetration test flagged our 30-day idle sessions (90 days…│12-│ add-task-search PDST ▱▱▱▱▱ ││ ✓ proposal ✓ design ✓ specs ✓ tasks ▰▰▰▰▰▰▱▱▱▱▱▱ 5/10 Δ +1 ~1 -1 …│13-│ add-workspace-invites PDST ▱▱▱▱▱ ││ proposal.md │ design.md │ ● tasks.md M │ Δ user-auth │14-│▌● harden-session-secu… M PDST ▰▰▱▱▱ 5/10││────────────────────────────────────────────────────────────────────────│15-│ ││ │16-│ ││ ## 1. Session storage │17-│ ││ │18-│ ││ [✓] 1.1 Add nullable  absolute_expires_at  to  sessions  │19-│ ││ (migration 0042) │20-│ ││ [✓] 1.2 Backfill  absolute_expires_at = now() + interval '7 │21-│ ││ days'  in batches of 5,000 │22-│ ││ │23-╰─5 of 5───────────────────────────────────╯│ ## 2. Expiry and revocation │24-╭─[2] Specs────────────────────────────────╮│ │25-│ data-export Δ1 3 req││ [✓] 2.1 Enforce 12h idle timeout in session middleware │26-│ notifications 2 req││ [✓] 2.2 Enforce 7-day absolute lifetime (treat NULL as │27-│ task-management Δ1 4 req││  created_at + 7d ) │28-│ user-auth Δ1 4 req││ [✓] Unit test: session used 1h ago but created 8 days ago is │29-╰─1 of 4───────────────────────────────────╯│ rejected │30-╭─[3] Archive──────────────────────────────╮│ [ ] Unit test: NULL  absolute_expires_at  falls back to │31-│ 09-23 add-email-notif… PDST ▰▰▰▰▰ 7/7││  created_at  │32-│ 08-19 add-user-auth PDST ▰▰▰▰▰ 10/10││ [ ] 2.3 Add  GET /account/sessions  and  DELETE │33-╰─1 of 2───────────────────────────────────╯│ /account/sessions/:id  │34-╭─[4] Activity─────────────────────────────╮│ [ ] 2.4 Build Active sessions page with "This device" label │35-│ 14:26:02 ▸ harden-sessio… tasks 4/10 → 5…││ │36-╰─1 of 1───────────────────────────────────╯╰─ 0%───────────────────────────────────────────────────────────────────╯37- j/k move enter read [ ] tab d diff e edit s sort f follow z zoom ? help q quit38-```3914 40-## Install / run15+Or download a binary for macOS, Linux, or Windows from16+[Releases](https://github.com/FelineStateMachine/lazyopenspec/releases).17+18+## Run4119 4220 ```sh43-go install . # or: go build -o lazyopenspec .44-lazyopenspec # finds openspec/ in the current dir or any parent45-lazyopenspec ~/code/repo # or point it at a repo46-lazyopenspec --follow # start in follow mode47-lazyopenspec --interval 1s # poll less often (default 400ms)48-lazyopenspec --portrait always # always stack panels above the main pane (auto | always | never)21+lazyopenspec # finds openspec/ in the current directory or any parent22+lazyopenspec ~/code/repo # or point it at a repo23+lazyopenspec --follow # jump to whatever the agents touch next24+lazyopenspec --portrait always # stack panels above the main pane: auto (default), always, never25+lazyopenspec --interval 1s # poll less often (default 400ms)4926 ```5027 51-Requires Go 1.27+. `git` is optional: without it, the uncommitted markers and diff view are unavailable.28+It only reads files. `git` is optional; without it you lose the uncommitted markers and the diff view.5229 5330 ## What it shows5431 55-- **Changes**: every active change under `openspec/changes/`. `PDST` lights up for proposal, design, spec deltas,56- and tasks. The bar and count use the same top-level checkbox rule as `openspec list`, so the numbers match the CLI.57-- **Specs**: main specs with requirement counts. `Δn` means n active changes have a delta against that spec. Open58- the spec and use `]` to read the pending deltas next to it.32+- **Changes**: every active change under `openspec/changes/`. `PDST` lights up as the proposal, design, spec33+ deltas, and tasks appear. The progress bar counts top-level checkboxes the way `openspec list` does, so the34+ numbers match the CLI.35+- **Specs**: the main specs with requirement counts. `Δ2` means two active changes have a delta against that spec;36+ open the spec and press `]` to read them next to it.5937 - **Archive**: archived changes, newest first.60-- **Activity**: a feed of new changes, added, edited, or removed artifacts, task progress (`tasks 4/10 → 5/10`),61- requirement count changes, and archives. `enter` jumps to the item.62-- **Main pane**: the selected file rendered as markdown, with a tab per artifact. `d` switches to `git diff HEAD`63- for that file (untracked files show in full), so you see what an agent changed since the last commit.38+- **Activity**: new changes, added, edited, or removed files, task progress (`tasks 4/10 → 5/10`), requirement39+ count changes, and archives, newest first. `enter` jumps to the item.40+- **Main pane**: the selected file rendered as markdown, one tab per artifact. `d` switches to `git diff HEAD` for41+ that file, so you see what an agent changed since its last commit.6442 65-Freshness: `●` bright = edited in the last 15s, dim = last 2 minutes. `M` / `+` = uncommitted / untracked in git.66-Git is run with `GIT_OPTIONAL_LOCKS=0`, so the viewer never holds `.git/index.lock` while agents are committing.43+A bright `●` means edited in the last 15 seconds and a dim one the last 2 minutes. `M` and `+` mark uncommitted and44+untracked files.45+46+## Follow mode47+48+`f` (or `--follow`) moves the selection to whatever changed most recently and opens the file that changed, so the49+main pane always shows what the agents are doing right now. Scroll position is kept per file, so an edit to the50+file you're reading doesn't send you back to the top.51+52+## Vertical stack (portrait) mode53+54+This works like lazygit's `portraitMode`. In a tall, narrow pane (84 columns or fewer and 46 rows or more,55+lazygit's defaults), the panels stack on top and take a third of the height, and the main pane gets the rest at56+full width. `--portrait always` or `never` overrides that, and `v` flips the layout while it runs.57+58+When the panels are short on space, the focused one expands and the others shrink to their selected row, or to59+just their title if space is very tight. Tab between panels to open each one in turn.6760 6861 ## Keys6962 70-| key | action |63+| Key | Action |7164 |---|---|72-| `tab` / `shift+tab`, `1`-`4`, `0` | switch panel (`0` = main pane) |73-| `j` `k` / arrows, `g` `G` | move selection, or scroll when the main pane is focused |74-| `enter` / `l`, `esc` / `h` | focus main pane / back to list |65+| `tab` / `shift+tab`, `1`-`4`, `0` | switch panel (`0` is the main pane) |66+| `j` `k` / arrows, `g` `G` | move the selection, or scroll when the main pane is focused |67+| `enter` / `l`, `esc` / `h` | focus the main pane, back to the list |7568 | `J` `K`, `ctrl+d` `ctrl+u` | scroll the main pane from anywhere |7669 | `[` `]` | previous / next file tab |77-| `d` | toggle git diff vs HEAD |78-| `f` | follow mode: jump to the item and file the agents touch next |79-| `s` | sort changes by name / most recently edited |70+| `d` | git diff against HEAD |71+| `f` | follow mode |72+| `s` | sort changes by name or by most recent edit |8073 | `z` | zoom the main pane |81-| `v` | toggle the vertical stack (portrait) layout |82-| `e` | open the current file in `$VISUAL` / `$EDITOR` |74+| `v` | toggle the vertical stack layout |75+| `e` | open the file in `$VISUAL` or `$EDITOR` |8376 | `c` | clear the activity feed |84-| `r` | force reload |77+| `r` | reload |8578 | `?` | help |8679 | `q` | quit |8780 88-Mouse: wheel scrolls the pane under the pointer, and clicking selects a row.89-90-## Vertical stack (portrait) mode81+The mouse wheel scrolls the pane under the pointer, and clicking selects a row.9182 92-Works like lazygit's `portraitMode`. In a tall, narrow pane (84 columns or fewer and 46 rows or more, lazygit's93-defaults), the list panels stack on top and take a third of the height, and the main pane gets the rest at full94-width. `--portrait always|never` overrides the auto rule, and `v` flips the layout while it runs.83+## How it works9584 96-When the panels are short on space, which is the usual case when stacked, lazygit's accordion behavior applies: the97-focused panel expands and the others shrink to their selected row, or to just their title if space is very tight.98-Tab between panels to expand each one in turn.85+- **Polling, not fsnotify.** It stats every file under `openspec/` every `--interval` and re-parses only when86+ something changed. On macOS, fsnotify holds a file descriptor open per watched file, and agents save files by87+ writing a temp file and renaming it, which polling handles without special cases.88+- **Reads the files directly.** It doesn't call the `openspec` CLI, which takes about 100 ms per call and can't read89+ archived changes. The parsers follow the CLI's rules; [docs/openspec-format.md](docs/openspec-format.md) has the90+ format notes they're based on.91+- **Stays out of git's way.** Git runs with `GIT_OPTIONAL_LOCKS=0`, so the viewer never takes `.git/index.lock`92+ while agents are committing.9993 100-## How it watches94+## Try it without agents10195 102-It does a stat-only walk of `openspec/` every `--interval` and re-parses only when something changed. It doesn't103-use fsnotify because on macOS kqueue holds an open file descriptor per watched file. Polling also picks up104-atomic write-then-rename saves, which agents and editors use all the time. Scroll position is kept per file, so a105-live edit to the file you're reading doesn't send you back to the top.96+`scripts/swarm-sim.sh` copies the test fixture to a temp repo and runs three simulated agents that draft changes,97+check off tasks, add requirements, and archive finished work:10698 107-It reads the files directly and doesn't shell out to the `openspec` CLI. The CLI takes about 100ms per call, mostly108-Node startup, and can't read archived changes. See `docs/openspec-format.md` for the format notes it's based on.99+```sh100+scripts/swarm-sim.sh /tmp/lazyopenspec-demo # ctrl+c to stop101+lazyopenspec /tmp/lazyopenspec-demo --follow # in another pane102+```109103 110-## Try it with a fake swarm104+## Development111105 112106 ```sh113-scripts/swarm-sim.sh /tmp/lazyopenspec-demo # copies testdata/fixture and starts simulated agents114-go run . /tmp/lazyopenspec-demo --follow # in another pane107+make check # gofmt, go vet, staticcheck, tests with -race108+make build # bin/lazyopenspec109+make demo # re-record docs/media/demo.gif with vhs115110 ```111+112+## License113+114+[MIT](LICENSE)
A demo/README.md +7 -0
A demo/demo.tape +62 -0
@@ -0,0 +1,62 @@
1+# README demo: three simulated agents (scripts/swarm-sim.sh) work through2+# a copy of testdata/fixture while lazyopenspec follows them. Run from the3+# repo root with `vhs demo/demo.tape`; demo/README.md has the shrink step.4+# The raw recording goes to /tmp, the still to docs/media/demo.png.5+Output "/tmp/lazyopenspec-demo-raw.gif"6+7+# 150x40 cells in Menlo 14 with 012's padding and pace.8+Set Shell "bash"9+Set FontFamily "Menlo"10+Set FontSize 1411+Set Width 142012+Set Height 78013+Set Padding 2014+Set LineHeight 1.1515+Set TypingSpeed 55ms16+Set CursorBlink false17+Set Theme "Catppuccin Mocha"18+19+# Build to a temp dir, start the swarm with its output discarded, and kill20+# it (and its agents) when this shell exits, even if the recording fails.21+# VHS's terminal does truecolor but doesn't say so; without COLORTERM the22+# theme's hex colors are rounded to the 256-color palette.23+Hide24+Type `export COLORTERM=truecolor; bin=$(mktemp -d) && go build -o "$bin/lazyopenspec" . && export PATH="$bin:$PATH"` Enter25+Wait@120s26+Type `scripts/swarm-sim.sh /tmp/lazyopenspec-demo >/dev/null 2>&1 & sim=$!; trap 'kill $sim 2>/dev/null; rm -rf "$bin" /tmp/lazyopenspec-demo' EXIT HUP TERM` Enter27+Sleep 2s28+Type "clear" Enter29+Sleep 300ms30+Show31+32+Type "lazyopenspec /tmp/lazyopenspec-demo --follow"33+Sleep 500ms34+Enter35+Wait+Screen@10s /Activity/36+37+# Follow mode jumps to whatever the agents touch.38+Sleep 7s39+40+# The git diff of the followed file, then back to the rendered view.41+Type "d"42+Sleep 4s43+Type "d"44+Sleep 1.5s45+46+# Follow off to read a spec and its pending delta, then back on.47+Type "f"48+Sleep 800ms49+Type "2"50+Sleep 1.5s51+Type "]"52+Sleep 3s53+Type "f"54+Sleep 3s55+Screenshot "docs/media/demo.png"56+Sleep 4s57+58+Type "q"59+Sleep 300ms60+Hide61+Type `kill $sim; wait; rm -rf "$bin" /tmp/lazyopenspec-demo; trap - EXIT HUP TERM` Enter62+Sleep 500ms
A docs/media/demo.gif +0 -0

Binary file.

A docs/media/demo.png +0 -0

Binary file.

M internal/gitx/git.go +4 -0
M internal/spec/load.go +42 -3
@@ -16,14 +16,43 @@ const DirName = "openspec"
1616 1717 var archivePrefixRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-(.+)$`)1818 19+// DisplayRoot returns root as reached from start without resolving symlinks20+// (e.g. /tmp/repo rather than /private/tmp/repo), for showing to the user.21+// It falls back to root when no unresolved ancestor of start resolves to it.22+func DisplayRoot(start, root string) string {23+ abs, err := filepath.Abs(start)24+ if err != nil {25+ return root26+ }27+ for dir := abs; ; dir = filepath.Dir(dir) {28+ if real, err := filepath.EvalSymlinks(dir); err == nil && real == root {29+ return dir30+ }31+ if filepath.Dir(dir) == dir {32+ return root33+ }34+ }35+}36+37+// ArchiveName strips the YYYY-MM-DD- prefix from an archived change's ID.38+func ArchiveName(id string) string {39+ if m := archivePrefixRe.FindStringSubmatch(id); m != nil {40+ return m[2]41+ }42+ return id43+}44+1945 // FindRoot walks up from start looking for a directory that contains20-// openspec/. It returns start itself when no ancestor has one.46+// openspec/. It returns start itself when no ancestor has one. The result has47+// symlinks resolved so it matches the paths git reports; DisplayRoot gives48+// the same directory as the user would write it.2149 func FindRoot(start string) (string, error) {2250 abs, err := filepath.Abs(start)2351 if err != nil {2452 return "", err2553 }26- // Resolve symlinks so paths match what git reports (e.g. /tmp on macOS).54+ // Resolve first so the walk goes up the real tree, not a symlink's55+ // parents, and so paths match what git reports (/tmp on macOS).2756 if real, err := filepath.EvalSymlinks(abs); err == nil {2857 abs = real2958 }
@@ -75,7 +104,17 @@ func Load(root string) (*Snapshot, error) {
75104 if err != nil {76105 return nil, err77106 }78- sort.Slice(s.Archive, func(i, j int) bool { return s.Archive[i].ID > s.Archive[j].ID })107+ // Newest first: by archive date, then by last edit within a day.108+ sort.Slice(s.Archive, func(i, j int) bool {109+ a, b := s.Archive[i], s.Archive[j]110+ if a.ArchivedOn != b.ArchivedOn {111+ return a.ArchivedOn > b.ArchivedOn112+ }113+ if !a.ModTime.Equal(b.ModTime) {114+ return a.ModTime.After(b.ModTime)115+ }116+ return a.ID > b.ID117+ })79118 return s, nil80119 }81120
M internal/spec/load_test.go +56 -0
@@ -4,6 +4,7 @@ import (
44 "os"55 "path/filepath"66 "reflect"7+ "slices"78 "strings"89 "testing"910 "time"
@@ -405,6 +406,36 @@ func TestFindRoot(t *testing.T) {
405406 t.Errorf("FindRoot(link) = %q, %v; want %q", got, err, root)406407 }407408 })409+410+ t.Run("display keeps a symlinked root as typed", func(t *testing.T) {411+ link := filepath.Join(base, "repo-link")412+ if err := os.Symlink(root, link); err != nil {413+ t.Skip("symlinks unsupported:", err)414+ }415+ start := filepath.Join(link, "src", "pkg")416+ got, err := FindRoot(start)417+ if err != nil || got != root {418+ t.Fatalf("FindRoot(%q) = %q, %v; want %q", start, got, err, root)419+ }420+ if d := DisplayRoot(start, got); d != link {421+ t.Errorf("DisplayRoot = %q, want %q", d, link)422+ }423+ if d := DisplayRoot(deep, got); d != root {424+ t.Errorf("DisplayRoot without symlinks = %q, want %q", d, root)425+ }426+ })427+}428+429+func TestArchiveName(t *testing.T) {430+ for id, want := range map[string]string{431+ "2026-10-13-add-auth": "add-auth",432+ "add-auth": "add-auth",433+ "2026-10-13": "2026-10-13",434+ } {435+ if got := ArchiveName(id); got != want {436+ t.Errorf("ArchiveName(%q) = %q, want %q", id, got, want)437+ }438+ }408439 }409440 410441 // One unreadable directory hides only its own contents.
@@ -423,3 +454,28 @@ func TestLoadSkipsUnreadableDirs(t *testing.T) {
423454 t.Errorf("Specs = %d, Changes = %d; want 3 readable specs and 5 changes", len(s.Specs), len(s.Changes))424455 }425456 }457+458+// Two changes archived the same day: the one edited last comes first.459+func TestArchiveSortsNewestFirstWithinADay(t *testing.T) {460+ root := copyFixture(t)461+ archive := filepath.Join(root, "openspec", "changes", "archive")462+ older := filepath.Join(archive, "2026-10-13-zz-older", "tasks.md")463+ newer := filepath.Join(archive, "2026-10-13-aa-newer", "tasks.md")464+ writeFile(t, older, "- [x] done\n")465+ writeFile(t, newer, "- [x] done\n")466+ past := time.Now().Add(-time.Hour)467+ if err := os.Chtimes(older, past, past); err != nil {468+ t.Fatal(err)469+ }470+ if err := os.Chtimes(filepath.Dir(older), past, past); err != nil {471+ t.Fatal(err)472+ }473+ var got []string474+ for _, c := range mustLoad(t, root).Archive {475+ got = append(got, c.ID)476+ }477+ want := []string{"2026-10-13-aa-newer", "2026-10-13-zz-older", "2026-09-23-add-email-notifications", "2026-08-19-add-user-auth"}478+ if !slices.Equal(got, want) {479+ t.Errorf("archive order = %v, want %v", got, want)480+ }481+}
M internal/ui/model.go +2 -1
M internal/ui/render.go +10 -2
M main.go +2 -1