clone

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

lazyopenspec: initial TUI

Dami committed fb8fe5f
commit
fb8fe5f4c949d5ae925873c2d0e74be46ee0c228
author
Dami <dami.etoile@gmail.com>
48 files changed, +3821 -0
A.gitignore1+
Adocs/openspec-format.md403+++++++++++++++++++++
Ago.mod38++
Ago.sum71++++
Ainternal/gitx/git.go85++++
Ainternal/spec/diff.go148++++++++
Ainternal/spec/fingerprint.go50+++
Ainternal/spec/load.go238++++++++++++
Ainternal/spec/model.go163++++++++
Ainternal/spec/parse.go157++++++++
Ainternal/ui/layout.go146++++++++
Ainternal/ui/mainpane.go459++++++++++++++++++++++++
Ainternal/ui/model.go582++++++++++++++++++++++++++++++
Ainternal/ui/render.go454+++++++++++++++++++++++
Ainternal/ui/theme.go69++++
Amain.go49+++
Atestdata/fixture/openspec/changes/add-csv-export-filters/.openspec.yaml2+
Atestdata/fixture/openspec/changes/add-csv-export-filters/proposal.md22+
Atestdata/fixture/openspec/changes/add-csv-export-filters/specs/data-export/spec.md38++
Atestdata/fixture/openspec/changes/add-csv-export-filters/tasks.md16+
Atestdata/fixture/openspec/changes/add-due-dates/.openspec.yaml2+
Atestdata/fixture/openspec/changes/add-due-dates/README.md3+
Atestdata/fixture/openspec/changes/add-due-dates/proposal.md24+
Atestdata/fixture/openspec/changes/add-task-search/.openspec.yaml2+
Atestdata/fixture/openspec/changes/add-task-search/proposal.md25+
Atestdata/fixture/openspec/changes/add-task-search/specs/search/spec.md27+
Atestdata/fixture/openspec/changes/add-task-search/specs/task-management/spec.md21+
Atestdata/fixture/openspec/changes/add-workspace-invites/.openspec.yaml2+
Atestdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/.openspec.yaml2+
Atestdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/design.md26+
Atestdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/proposal.md25+
Atestdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/specs/user-auth/spec.md44++
Atestdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/tasks.md18+
Atestdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/.openspec.yaml2+
Atestdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/proposal.md25+
Atestdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/specs/notifications/spec.md23+
Atestdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/specs/task-management/spec.md19+
Atestdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/tasks.md15+
Atestdata/fixture/openspec/changes/harden-session-security/.openspec.yaml2+
Atestdata/fixture/openspec/changes/harden-session-security/design.md39++
Atestdata/fixture/openspec/changes/harden-session-security/proposal.md25+
Atestdata/fixture/openspec/changes/harden-session-security/specs/user-auth/spec.md41++
Atestdata/fixture/openspec/changes/harden-session-security/tasks.md23+
Atestdata/fixture/openspec/config.yaml20+
Atestdata/fixture/openspec/specs/data-export/spec.md34++
Atestdata/fixture/openspec/specs/notifications/spec.md27+
Atestdata/fixture/openspec/specs/task-management/spec.md66+++
Atestdata/fixture/openspec/specs/user-auth/spec.md48++
A .gitignore +1 -0
@@ -0,0 +1 @@
1+/lazyopenspec
A docs/openspec-format.md +403 -0
@@ -0,0 +1,403 @@
1+# OpenSpec 1.4.1 on-disk format (for lazyopenspec)2+3+Source of truth: `/Users/dami/.local/lib/node_modules/@fission-ai/openspec` (v1.4.1, Node CLI, `dist/` is plain readable JS).4+Everything below was confirmed by reading `dist/` and running the CLI against the fixture.5+6+Fixture repo: `/Users/dami/.t3/scratch/2026-10-13-id-like-to-build-a-9bb5a1f9/fixture` (git, 7 commits, backdated history).7+Full raw CLI outputs: `fixture/cli-samples/*.json` (committed).8+9+---10+11+## 1. `openspec init`12+13+`openspec init --tools none .` (non-interactive) creates **only empty dirs**:14+15+```16+openspec/17+openspec/specs/18+openspec/changes/19+openspec/changes/archive/20+```21+22+`config.yaml` is skipped in non-interactive mode ("Config: skipped (non-interactive mode)") unless you pass `--force`23+(or run interactively). `openspec init --tools none --force .` adds `openspec/config.yaml`:24+25+```yaml26+schema: spec-driven27+28+# Project context (optional)29+# ... (commented examples for `context: |` and `rules: {proposal: [...], tasks: [...]}`)30+```31+32+With tools (`--tools claude` etc.) it also writes agent skills/commands outside `openspec/` (e.g. `.claude/skills/openspec-*/`,33+`.claude/commands/opsx/*`). Not relevant to a viewer. **1.4.1 does not create `project.md` or `openspec/AGENTS.md`**; those are34+0.x legacy files (init only prints a migration hint for `project.md`, which is superseded by `config.yaml` `context:`).35+36+Note: git does not track empty dirs, so a fresh clone may lack `changes/archive/` (or even `specs/`). Handle missing dirs.37+38+## 2. Directory tree (fixture)39+40+```41+openspec/42+ config.yaml # schema + optional context/rules43+ specs/ # "main" (source-of-truth) specs44+ data-export/spec.md45+ notifications/spec.md # created by archive (Purpose still "TBD - ...")46+ task-management/spec.md47+ user-auth/spec.md48+ changes/49+ add-workspace-invites/ # scaffold only (just ran `openspec new change`)50+ .openspec.yaml51+ add-due-dates/ # proposal only (+ README.md from --description)52+ .openspec.yaml README.md proposal.md53+ add-task-search/ # proposal + delta specs (one new capability, one modified)54+ .openspec.yaml proposal.md55+ specs/search/spec.md # ADDED (new capability)56+ specs/task-management/spec.md # MODIFIED57+ harden-session-security/ # proposal+design+specs+tasks, tasks 4/1058+ .openspec.yaml proposal.md design.md tasks.md59+ specs/user-auth/spec.md # ADDED + MODIFIED + REMOVED + RENAMED60+ add-csv-export-filters/ # proposal+specs+tasks (8/8), NO design.md61+ .openspec.yaml proposal.md tasks.md62+ specs/data-export/spec.md # ADDED + MODIFIED63+ archive/64+ 2026-08-19-add-user-auth/ # full change dir moved verbatim65+ .openspec.yaml proposal.md design.md tasks.md specs/user-auth/spec.md66+ 2026-09-23-add-email-notifications/67+ .openspec.yaml proposal.md tasks.md specs/{notifications,task-management}/spec.md68+```69+70+Optional extra dirs a viewer may see: `openspec/schemas/<name>/schema.yaml` (+ `templates/*.md`) for project-local workflow71+schemas; `openspec/config.yml` (alt extension); legacy `openspec/project.md`, `openspec/AGENTS.md`.72+73+## 3. Per-change files74+75+### `.openspec.yaml` (written by `openspec new change`)76+```yaml77+schema: spec-driven78+created: 2026-10-13 # YYYY-MM-DD, optional79+# optional, workspace/initiative features only:80+goal: "..." # string81+affected_areas: [web, api] # string[]82+initiative: { store: my-store, id: my-initiative } # kebab-case ids83+```84+Zod schema in `dist/core/change-metadata/schema.js`. `schema` must name an existing schema or the CLI errors.85+Schema resolution for a change: explicit `--schema` > `.openspec.yaml schema` > `config.yaml schema` > `spec-driven`.86+The file may be missing (hand-made or pre-1.x changes): treat as optional.87+88+### `README.md`89+Only created by `openspec new change <name> --description "..."`: `# <name>\n\n<description>\n`. Not an artifact.90+91+### Change names92+`new change` enforces `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/`. Archived dirs (`2026-...`) fail this, which is why93+`status`/`show` cannot address archived changes (see section 9).94+95+## 4. Default workflow schema `spec-driven`96+97+`schemas/spec-driven/schema.yaml` (also `workspace-planning`, same 4 artifact ids, used for workspaces):98+99+| artifact | generates | requires |100+|---|---|---|101+| proposal | `proposal.md` | - |102+| specs | `specs/**/*.md` (glob) | proposal |103+| design | `design.md` | proposal |104+| tasks | `tasks.md` | specs, design |105+106+`apply: { requires: [tasks], tracks: tasks.md }`. Templates live in `schemas/spec-driven/templates/{proposal,spec,design,tasks}.md`107+(`openspec templates --json` prints resolved paths). `openspec schemas --json` lists `{name, description, artifacts[], source: package|user|project}`.108+109+Schema lookup order: `<repo>/openspec/schemas/<name>/` > `$XDG_DATA_HOME/openspec/schemas/<name>/` (default `~/.local/share/openspec/schemas`) > package.110+111+### How `status` decides an artifact is done (`dist/core/artifact-graph/{state,outputs}.js`)112+- **done** = the `generates` path exists as a file. Pure existence; empty file counts. For globs, fast-glob113+ `specs/**/*.md` relative to the change dir (dotfiles excluded) must match >= 1 file. Note `specs/foo.md` (no capability dir)114+ counts as done for status even though the delta parser only reads `specs/<cap>/spec.md`.115+- **ready** = not done and every `requires` id is done.116+- **blocked** = otherwise; `missingDeps` = unmet requires (sorted).117+- Dependencies do NOT gate "done": `add-csv-export-filters` has tasks.md but no design.md, so status is118+ proposal=done, design=ready, specs=done, tasks=done, `isComplete:false`.119+- `isComplete` = every artifact done. It has nothing to do with task checkboxes.120+- Artifact order in output = topological build order with alphabetical tie-break: `proposal, design, specs, tasks`.121+122+## 5. Markdown formats123+124+### proposal.md (template; no H1)125+```126+## Why127+## What Changes (bullets; **BREAKING** marker convention)128+## Capabilities129+### New Capabilities - `<kebab-name>`: description130+### Modified Capabilities131+## Impact132+```133+Change **title** (used by `show`/`change list` JSON) = first match of `/^#\s+(?:Change:\s+)?(.+)$/im` in proposal.md, else the134+change id. The template has no H1, so most titles fall back to the id. Fixture: `add-task-search` has `# Change: Full-text task search`;135+`add-email-notifications` (archived) also has an H1; others don't.136+Parsing a change for `show --json` throws unless `## Why` and `## What Changes` exist (case-insensitive header match, any level).137+138+### design.md139+`## Context`, `## Goals / Non-Goals` (with `**Goals:**`/`**Non-Goals:**`), `## Decisions`, `## Risks / Trade-offs`,140+optional `## Migration Plan`, `## Open Questions`. Free-form; never parsed by the CLI.141+142+### tasks.md and task progress143+```144+## 1. Group name145+146+- [ ] 1.1 Task description147+- [x] 1.2 Done task148+```149+Counting (`dist/utils/task-progress.js`, used by `list`, `view`, `archive`, `change list`):150+```js151+TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i // tested against the raw line, NOT trimmed152+COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i153+```154+- Only column-0 `-`/`*` bullets count. **Indented (nested) checkboxes are ignored entirely.** `+` bullets ignored.155+- `[x]` and `[X]` both complete. `[~]`, `[-]` etc. are not tasks.156+- Code fences are NOT excluded (checkboxes inside ``` blocks count).157+- Missing tasks.md => `{total:0, completed:0}`.158+- `list --json` status: `total==0 -> "no-tasks"`, `completed==total -> "complete"`, else `"in-progress"`.159+`instructions apply --json` uses a slightly different regex, `/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/` (space after bullet optional,160+needs text), and returns per-task `{id: "<1-based ordinal>", description, done}` plus `state: blocked|all_done|ready`.161+Fixture `harden-session-security/tasks.md` exercises this: 10 top-level tasks (4 done incl. one `[X]`) plus 2 indented162+sub-checkboxes that the CLI ignores (=> `4/10`; counting nested would give `5/12`).163+164+### Main spec `openspec/specs/<cap>/spec.md`165+```166+# <Title> Specification (H1 is free text; the CLI never reads it)167+168+## Purpose169+<text> (required; < 50 chars => WARNING)170+171+## Requirements172+### Requirement: <name>173+<first body line MUST contain SHALL or MUST>174+175+#### Scenario: <name>176+- **WHEN** ...177+- **THEN** ...178+- **AND** ...179+```180+Key regexes (`dist/core/parsers/*.js`):181+```js182+/^(#{1,6})\s+(.+)$/ // generic header; fenced code (``` or ~~~) is masked out183+/^##\s+Requirements\s*$/i // requirements section (ends at next /^##\s+/)184+/^###\s*Requirement:\s*(.+)\s*$/i // requirement header; name = trim(capture)185+/^####\s+/ // scenario count (any H4 under a requirement)186+/^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i // forbidden in main specs (validation ERROR)187+```188+Section lookup for `Purpose`/`Requirements`/`Why`/`What Changes` is case-insensitive on the title and searches nested189+sections, so `## Purpose` vs `### Purpose` both work. Requirement "text" = first non-empty line after the header (before any `#`).190+191+Archive-created specs use this skeleton (note: archive also strips blank lines around `## Requirements` in specs it rewrites):192+```193+# <cap-id> Specification194+195+## Purpose196+TBD - created by archiving change <change-name>. Update Purpose after archive.197+## Requirements198+### Requirement: ...199+```200+Fixture `notifications` keeps the TBD purpose; `task-management` shows the blank-line stripping after a MODIFIED merge.201+202+### Delta spec `openspec/changes/<change>/specs/<cap>/spec.md`203+No H1/Purpose. Top-level `##` sections (case-insensitive):204+```205+## ADDED Requirements206+### Requirement: Session revocation207+Users SHALL ... (SHALL/MUST on first body line; `**Key**:` metadata lines skipped)208+#### Scenario: ... (>= 1 required for ADDED/MODIFIED)209+210+## MODIFIED Requirements211+### Requirement: Session expiry (name must match the main spec header; FULL replacement block)212+...213+214+## REMOVED Requirements215+### Requirement: Remember me216+**Reason**: ...217+**Migration**: ...218+ (alt form also accepted: - `### Requirement: Name`)219+220+## RENAMED Requirements221+- FROM: `### Requirement: Password login`222+- TO: `### Requirement: Password authentication`223+```224+RENAMED regexes: `/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/` and same with `TO:`; pairs are FROM followed by TO.225+A requirement block runs from its `### Requirement:` line to the next requirement header or `## ` line.226+Only `specs/<dir>/spec.md` files are read for deltas (one level deep, file name must be `spec.md`).227+228+### Archive (`openspec archive <name> -y`)229+Applies deltas into `openspec/specs/<cap>/spec.md` (creating the skeleton for new caps), validates the rebuilt spec,230+then moves the whole change dir to `openspec/changes/archive/<YYYY-MM-DD>-<name>/` (UTC date from `toISOString()`).231+Archived dirs keep `.openspec.yaml`, all artifacts and the delta specs. Fixture archive dirs were renamed to backdated232+dates after a real archive run.233+234+## 6. Discovery rules differ per command (important for parity)235+236+| command | what counts as a change | what counts as a spec | root |237+|---|---|---|---|238+| `list` (changes) | any **dir** in `changes/` except `archive` (dot-dirs included, proposal NOT required) | - | `./openspec` (cwd only) |239+| `list --specs` | - | any dir in `specs/` (missing/unparseable spec.md => 0 requirements) | cwd only |240+| `spec list` | - | any dir in `specs/` that has `spec.md` (dot-dirs included) | cwd only |241+| `show`, `validate`, `change list` | dir not starting with `.`, not `archive`, **has proposal.md** | dir not starting with `.`, has `spec.md` | cwd only |242+| `status`, `instructions`, `new change` | dir under `changesDir` matching kebab name | - | walks up ancestors to the nearest dir containing `openspec/` (or a workspace) |243+244+So `add-workspace-invites` (no proposal.md) appears in `list --json` but not in `change list`, `show`, or `validate`.245+`list`/`show`/`validate` fail from a subdirectory ("No OpenSpec changes directory found"); run them with cwd = repo root.246+247+## 7. "last modified" / `--sort recent`248+249+`dist/core/list.js getLastModified(dir)`: recursive walk of the change dir, max `stat.mtime` over **files** (incl.250+`.openspec.yaml`, nested `specs/**`); if no files, the dir's own mtime. Output as `toISOString()` (UTC, ms). `--sort recent`251+(default) = desc by that; `--sort name` = `localeCompare`. Git checkout resets mtimes, so on a fresh clone all changes tie.252+(The fixture's mtimes were set with `touch -t` locally to give a meaningful order; they won't survive a clone.)253+254+## 8. Validation (`openspec validate`)255+256+- Change validation only looks at **delta specs** (`validateChangeDeltaSpecs`), never at proposal.md content.257+ ERRORs: zero deltas total ("Change must have at least one delta..."), a `specs/<cap>/spec.md` with no delta headers,258+ delta headers with no parsed entries, ADDED/MODIFIED without SHALL/MUST (`/\b(SHALL|MUST)\b/` on first body line) or without259+ an `####` scenario, duplicates within a section, same name in MODIFIED+REMOVED / MODIFIED+ADDED / ADDED+REMOVED,260+ MODIFIED using a RENAMED old name, RENAMED TO colliding with ADDED.261+- Spec validation: needs `## Purpose` + `## Requirements`, >=1 requirement, each with SHALL/MUST (substring, case-sensitive) and262+ >=1 non-empty scenario; delta headers or requirement headers outside `## Requirements` are ERRORs; Purpose <50 chars WARNING;263+ requirement text >500 chars INFO; `--strict` makes WARNINGs fail.264+- Exit code 1 if any item fails. Bulk results sorted by id (changes and specs interleaved).265+266+**Fixture result: 7 passed, 1 failed.** `add-due-dates` (proposal-only) fails with "Change must have at least one delta". This is267+inherent: any change before its specs exist fails validation. `add-workspace-invites` is not validated at all (no proposal.md).268+All 4 main specs and the other 3 changes pass.269+270+## 9. JSON output shapes (trimmed; full files in `fixture/cli-samples/`)271+272+`openspec list --json` (only `changes`; `{"changes": []}` when empty):273+```json274+{"changes": [275+ {"name": "harden-session-security", "completedTasks": 4, "totalTasks": 10,276+ "lastModified": "2026-10-13T17:30:00.000Z", "status": "in-progress"},277+ {"name": "add-workspace-invites", "completedTasks": 0, "totalTasks": 0, "lastModified": "...", "status": "no-tasks"},278+ {"name": "add-csv-export-filters", "completedTasks": 8, "totalTasks": 8, "lastModified": "...", "status": "complete"}]}279+```280+281+`openspec list --specs --json`: **BUG in 1.4.1: `--json` is ignored, prints text**:282+```283+Specs:284+ data-export requirements 3285+ ...286+```287+Use the deprecated `openspec spec list --json` instead (prints a deprecation warning on stderr):288+```json289+[{"id": "data-export", "title": "data-export", "requirementCount": 3}, ...]290+```291+(`title` is always the id, never the H1.) `openspec change list --json` (deprecated) is the change equivalent:292+`[{"id","title","deltaCount","taskStatus":{"total","completed"}}]`, proposal-having changes only, sorted by id.293+294+`openspec show <change> --json` (always prints `Warning: Ignoring flags not applicable to change: scenarios` on stderr):295+```json296+{"id": "harden-session-security", "title": "harden-session-security", "deltaCount": 4,297+ "deltas": [298+ {"spec": "user-auth", "operation": "ADDED",299+ "description": "Add requirement: Users SHALL be able to list ...",300+ "requirement": {"text": "Users SHALL ...", "scenarios": [{"rawText": "- **WHEN** ...\n- **THEN** ..."}]},301+ "requirements": [ /* same object again */ ]},302+ {"spec": "user-auth", "operation": "MODIFIED", "description": "Modify requirement: ...", "requirement": {...}, "requirements": [...]},303+ {"spec": "user-auth", "operation": "REMOVED", "description": "Remove requirement: **Reason**: ...",304+ "requirement": {"text": "**Reason**: ...", "scenarios": []}, "requirements": [...]},305+ {"spec": "user-auth", "operation": "RENAMED", "description": "Rename requirement from \"Password login\" to \"Password authentication\"",306+ "rename": {"from": "Password login", "to": "Password authentication"}}]}307+```308+**Lossy:** requirement names (the `### Requirement:` text) and scenario names are NOT in the JSON; `text` is the first body line309+and scenarios are raw bodies. If no `specs/` deltas exist it falls back to parsing `- **cap**: desc` bullets in What Changes310+(`add-due-dates` => `deltaCount: 0`). Errors (no Why / What Changes) abort with exit 1.311+312+`openspec show <spec> --json --type spec`:313+```json314+{"id": "user-auth", "title": "user-auth", "overview": "<Purpose text>", "requirementCount": 4,315+ "requirements": [{"text": "The system SHALL authenticate ...", "scenarios": [{"rawText": "- **WHEN** ...\n- **THEN** ..."}]}],316+ "metadata": {"version": "1.0.0", "format": "openspec"}}317+```318+Same lossiness (no requirement/scenario names). Flags: `--requirements` (drop scenarios), `--no-scenarios`, `-r <1-based index>`.319+320+`openspec status --change <name> --json`:321+```json322+{"changeName": "add-csv-export-filters", "schemaName": "spec-driven",323+ "planningHome": {"kind": "repo", "root": "/abs/fixture", "changesDir": "/abs/fixture/openspec/changes", "defaultSchema": "spec-driven"},324+ "changeRoot": "/abs/.../changes/add-csv-export-filters",325+ "artifactPaths": {"specs": {"outputPath": "specs/**/*.md", "resolvedOutputPath": "/abs/.../specs/**/*.md",326+ "existingOutputPaths": ["/abs/.../specs/data-export/spec.md"]}, "proposal": {...}, ...},327+ "isComplete": false, "applyRequires": ["tasks"],328+ "nextSteps": ["Run openspec instructions design --change \"add-csv-export-filters\" --json before writing that artifact."],329+ "actionContext": {"mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts": [...], "linkedContext": [],330+ "allowedEditRoots": ["/abs/fixture"], "requiresAffectedAreaSelection": false, "constraints": [...]},331+ "artifacts": [332+ {"id": "proposal", "outputPath": "proposal.md", "status": "done"},333+ {"id": "design", "outputPath": "design.md", "status": "ready"},334+ {"id": "specs", "outputPath": "specs/**/*.md", "status": "done"},335+ {"id": "tasks", "outputPath": "tasks.md", "status": "done"}]}336+```337+Blocked entries add `"missingDeps": ["design", "specs"]`. Optional keys: `initiative`, `affectedAreas` (workspace mode).338+`status` without `--change` and no changes: `{"changes": [], "message": "No active changes."}`; with changes it errors.339+340+`openspec validate --all --json`:341+```json342+{"items": [343+ {"id": "add-due-dates", "type": "change", "valid": false, "durationMs": 4,344+ "issues": [{"level": "ERROR", "path": "file", "message": "Change must have at least one delta. ..."}]},345+ {"id": "data-export", "type": "spec", "valid": true, "issues": [], "durationMs": 2}],346+ "summary": {"totals": {"items": 8, "passed": 7, "failed": 1},347+ "byType": {"change": {"items": 4, "passed": 3, "failed": 1}, "spec": {"items": 4, "passed": 4, "failed": 0}}},348+ "version": "1.0"}349+```350+Issue `level`: `ERROR|WARNING|INFO`; `path` is `file`, a JSON-ish path (`requirements[0].scenarios`) or `<cap>/spec.md`; spec351+structure issues may add `line`.352+353+Also captured: `instructions apply --change X --json` (`progress {total,complete,remaining}`, `tasks[]`, `state`, `contextFiles`).354+355+## 10. Timings (Apple M5 Pro, Node 22.22, fixture repo, median of 10)356+357+| command | `OPENSPEC_TELEMETRY=0` | default (telemetry on) |358+|---|---|---|359+| `node -e ''` baseline | 19 ms | |360+| `openspec --version` | 105 ms | |361+| `list --json` | 110 ms | ~330 ms |362+| `spec list --json` | 112 ms | |363+| `show <change> --json` | 109 ms | |364+| `show <spec> --json --type spec` | 109 ms | |365+| `status --change X --json` | 121 ms | ~350 ms |366+| `validate --all --json` | 115 ms | |367+| `instructions apply --change X --json` | 121 ms | |368+369+- Cost is essentially all Node startup + module loading (~100 ms); parsing is <10 ms. A scaled copy with 45 changes / 44 specs370+ took the same ~110-120 ms per command.371+- **Telemetry is on by default** (PostHog, flushed at exit, adds ~220 ms and a network call per invocation). If you shell out,372+ set `OPENSPEC_TELEMETRY=0` (or `DO_NOT_TRACK=1`) in the child env.373+- Full refresh of the fixture (12 processes: list, spec list, validate --all, 5x status, 4x show): ~1.6 s sequential, ~250 ms374+ with all 12 in parallel.375+376+Verdict: shelling out per file change is viable only if debounced (>= 300 ms), parallelised, scoped to the affected change, and377+telemetry is disabled. Reading the files directly in Go is cheap, gives you data the JSON drops (requirement and scenario names),378+and has no cwd/discovery quirks. Use the CLI only for rare things like validate.379+380+## 11. Other 1.4.1 things a viewer should know381+382+- **Global config**: `~/.config/openspec/config.json` (XDG_CONFIG_HOME; holds profile, delivery, workflows, telemetry id). Global383+ data dir `~/.local/share/openspec/` (XDG_DATA_HOME): user schemas, `workspaces/registry.yaml`, `context-stores/`.384+- **Workspaces** (`openspec workspace setup|link|...`): a workspace root is a dir containing `.openspec-workspace/view.yaml`385+ (`version: 1, name, links: {name: path|null}, ...`) plus `changes/` directly at the root (**no `openspec/` dir**), optionally a386+ `<name>.code-workspace` (written by `workspace open`). Default schema there is `workspace-planning`; area specs go under `specs/<area>/<cap>/spec.md`.387+ `status` chooses the nearest planning home: a workspace wins if it is at least as deep as the nearest repo `openspec/`.388+ `.openspec.yaml` in workspace changes may carry `goal` and `affected_areas`.389+- **Context stores** (`openspec context-store setup|register|...`): store dirs (default `~/.local/share/openspec/context-stores/<id>/`)390+ contain `.openspec-store/store.yaml`; registry at `context-stores/registry.yaml`.391+- **Initiatives** (`openspec initiative create|show|list`): live inside a store at `<store>/initiatives/<id>/` with392+ `initiative.yaml` (`version: 1, id, title, summary, status: exploring|active|complete|archived, created: YYYY-MM-DD, owners[], metadata{}`)393+ and `requirements.md, design.md, decisions.md, questions.md, tasks.md`. A repo change links via `.openspec.yaml`394+ `initiative: {store, id}` (`openspec set change <name> --initiative <id> --store <id>`); `status` then includes `initiative`.395+- **config.yaml**: `schema` (string), `context` (string, <= 50 KB, injected into AI instructions), `rules` (`{artifactId: string[]}`).396+ Parsed leniently field-by-field; `.yml` also accepted.397+- **Schemas dir**: project-local `openspec/schemas/<name>/schema.yaml` (`openspec schema init|fork`) can define different398+ artifact ids/paths, so a viewer that wants to be schema-aware should read `schema.yaml` `artifacts[].{id,generates,requires}`399+ and resolve per-change schema as in section 3 rather than hard-coding the 4 artifacts.400+- **`openspec view`**: dashboard; same discovery as `list` (any non-archive dir), buckets changes as draft (0 tasks), active (partial), completed (all tasks done).401+- Archived changes are invisible to `list`, `show`, `status`, `validate` (no CLI command lists them); read402+ `changes/archive/*/` directly. The archive date prefix is the only archive timestamp on disk.403+- Line endings: all parsers normalise `\r\n?` to `\n` (except the task counter, whose regex is CRLF-safe anyway).
A go.mod +38 -0
@@ -0,0 +1,38 @@
1+module github.com/dami/lazyopenspec2+3+go 1.27.14+5+require (6+ charm.land/bubbles/v2 v2.2.17+ charm.land/bubbletea/v2 v2.0.108+ charm.land/glamour/v2 v2.0.19+ charm.land/lipgloss/v2 v2.0.610+ github.com/charmbracelet/x/ansi v0.11.811+)12+13+require (14+ github.com/alecthomas/chroma/v2 v2.14.0 // indirect15+ github.com/aymerick/douceur v0.2.0 // indirect16+ github.com/charmbracelet/colorprofile v0.4.3 // indirect17+ github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect18+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect19+ github.com/charmbracelet/x/term v0.2.2 // indirect20+ github.com/charmbracelet/x/termios v0.1.1 // indirect21+ github.com/charmbracelet/x/windows v0.2.2 // indirect22+ github.com/clipperhouse/displaywidth v0.11.0 // indirect23+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect24+ github.com/dlclark/regexp2 v1.11.0 // indirect25+ github.com/gorilla/css v1.0.1 // indirect26+ github.com/lucasb-eyer/go-colorful v1.4.1 // indirect27+ github.com/mattn/go-runewidth v0.0.27 // indirect28+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect29+ github.com/muesli/cancelreader v0.2.2 // indirect30+ github.com/rivo/uniseg v0.4.7 // indirect31+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect32+ github.com/yuin/goldmark v1.7.8 // indirect33+ github.com/yuin/goldmark-emoji v1.0.5 // indirect34+ golang.org/x/net v0.39.0 // indirect35+ golang.org/x/sync v0.22.0 // indirect36+ golang.org/x/sys v0.47.0 // indirect37+ golang.org/x/text v0.24.0 // indirect38+)
A go.sum +71 -0
@@ -0,0 +1,71 @@
1+charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=2+charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=3+charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=4+charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=5+charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=6+charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=7+charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=8+charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=9+github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=10+github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=11+github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=12+github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=13+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=14+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=15+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=16+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=17+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=18+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=19+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=20+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=21+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=22+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=23+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=24+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=25+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=26+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=27+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=28+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=29+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=30+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=31+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=32+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=33+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=34+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=35+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=36+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=37+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=38+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=39+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=40+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=41+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=42+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=43+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=44+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=45+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=46+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=47+github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=48+github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=49+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=50+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=51+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=52+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=53+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=54+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=55+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=56+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=57+github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=58+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=59+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=60+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=61+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=62+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=63+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=64+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=65+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=66+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=67+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=68+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=69+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=70+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=71+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
A internal/gitx/git.go +85 -0
@@ -0,0 +1,85 @@
1+// Package gitx shells out to git for the small amount of repository context2+// the viewer shows: the current branch, uncommitted openspec files, and diffs.3+package gitx4+5+import (6+ "bytes"7+ "context"8+ "os/exec"9+ "path/filepath"10+ "strings"11+ "time"12+)13+14+const timeout = 3 * time.Second15+16+// Status is the repository context shown in the status bar.17+type Status struct {18+ IsRepo bool19+ Branch string20+ // Dirty maps absolute paths under the openspec dir to their two-letter21+ // porcelain status (e.g. " M", "??", "A ").22+ Dirty map[string]string23+}24+25+func run(dir string, args ...string) (string, error) {26+ ctx, cancel := context.WithTimeout(context.Background(), timeout)27+ defer cancel()28+ cmd := exec.CommandContext(ctx, "git", args...)29+ cmd.Dir = dir30+ var out bytes.Buffer31+ cmd.Stdout = &out32+ err := cmd.Run()33+ return out.String(), err34+}35+36+// Read collects the branch and uncommitted files under openspecDir.37+func Read(root, openspecDir string) Status {38+ top, err := run(root, "rev-parse", "--show-toplevel")39+ if err != nil {40+ return Status{}41+ }42+ top = strings.TrimSpace(top)43+ s := Status{IsRepo: true, Dirty: map[string]string{}}44+45+ if b, err := run(root, "branch", "--show-current"); err == nil {46+ s.Branch = strings.TrimSpace(b)47+ }48+ if s.Branch == "" {49+ if h, err := run(root, "rev-parse", "--short", "HEAD"); err == nil {50+ s.Branch = "@" + strings.TrimSpace(h)51+ }52+ }53+54+ out, err := run(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", openspecDir)55+ if err != nil {56+ return s57+ }58+ entries := strings.Split(out, "\x00")59+ for i := 0; i < len(entries); i++ {60+ e := entries[i]61+ if len(e) < 4 {62+ continue63+ }64+ code, path := e[:2], e[3:]65+ if code[0] == 'R' || code[0] == 'C' {66+ i++ // -z puts the rename source in the next field67+ }68+ s.Dirty[filepath.Join(top, filepath.FromSlash(path))] = code69+ }70+ return s71+}72+73+// Diff returns the working-tree diff of path against HEAD. Untracked files74+// are diffed against /dev/null so new artifacts show up in full.75+func Diff(root, path string, untracked bool) (string, error) {76+ if untracked {77+ out, err := run(root, "diff", "--no-color", "--no-index", "--", "/dev/null", path)78+ // --no-index exits 1 when files differ.79+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {80+ err = nil81+ }82+ return out, err83+ }84+ return run(root, "diff", "--no-color", "HEAD", "--", path)85+}
A internal/spec/diff.go +148 -0
@@ -0,0 +1,148 @@
1+package spec2+3+import (4+ "fmt"5+ "sort"6+ "time"7+)8+9+// EventKind describes what happened to an item between two snapshots.10+type EventKind int11+12+const (13+ EventCreated EventKind = iota14+ EventEdited15+ EventProgress16+ EventArchived17+ EventRemoved18+)19+20+// ItemKind identifies which list an event's item lives in.21+type ItemKind int22+23+const (24+ ItemChange ItemKind = iota25+ ItemSpec26+ ItemArchive27+)28+29+// Event is one line in the activity feed.30+type Event struct {31+ Time time.Time32+ Kind EventKind33+ Item ItemKind34+ ID string // change or spec ID35+ Rel string // artifact path within the change, or "spec.md"36+ Detail string37+}38+39+// Diff describes what changed from prev to next, oldest-first within a call.40+func Diff(prev, next *Snapshot, now time.Time) []Event {41+ if prev == nil || next == nil {42+ return nil43+ }44+ var events []Event45+ emit := func(kind EventKind, item ItemKind, id, rel, format string, args ...any) {46+ events = append(events, Event{47+ Time: now, Kind: kind, Item: item, ID: id, Rel: rel,48+ Detail: fmt.Sprintf(format, args...),49+ })50+ }51+52+ prevChanges := indexChanges(prev.Changes)53+ nextChanges := indexChanges(next.Changes)54+ archivedNames := map[string]string{}55+ for _, c := range next.Archive {56+ archivedNames[c.Name] = c.ID57+ }58+ prevArchive := indexChanges(prev.Archive)59+60+ for _, id := range sortedKeys(nextChanges) {61+ c := nextChanges[id]62+ old, ok := prevChanges[id]63+ if !ok {64+ emit(EventCreated, ItemChange, id, "", "new change")65+ continue66+ }67+ diffArtifacts(old, c, emit)68+ }69+ for _, id := range sortedKeys(prevChanges) {70+ if _, ok := nextChanges[id]; ok {71+ continue72+ }73+ if archID, ok := archivedNames[id]; ok {74+ if _, was := prevArchive[archID]; !was {75+ emit(EventArchived, ItemArchive, archID, "", "archived")76+ continue77+ }78+ }79+ emit(EventRemoved, ItemChange, id, "", "change removed")80+ }81+82+ prevSpecs := map[string]Spec{}83+ for _, s := range prev.Specs {84+ prevSpecs[s.ID] = s85+ }86+ nextSpecs := map[string]bool{}87+ for _, s := range next.Specs {88+ nextSpecs[s.ID] = true89+ old, ok := prevSpecs[s.ID]90+ switch {91+ case !ok:92+ emit(EventCreated, ItemSpec, s.ID, "spec.md", "new spec, %d requirements", s.Requirements)93+ case old.Requirements != s.Requirements:94+ emit(EventEdited, ItemSpec, s.ID, "spec.md", "requirements %d → %d", old.Requirements, s.Requirements)95+ case !old.ModTime.Equal(s.ModTime):96+ emit(EventEdited, ItemSpec, s.ID, "spec.md", "spec.md")97+ }98+ }99+ for _, s := range prev.Specs {100+ if !nextSpecs[s.ID] {101+ emit(EventRemoved, ItemSpec, s.ID, "", "spec removed")102+ }103+ }104+ return events105+}106+107+func diffArtifacts(old, c Change, emit func(EventKind, ItemKind, string, string, string, ...any)) {108+ oldArts := map[string]Artifact{}109+ for _, a := range old.Artifacts {110+ oldArts[a.Rel] = a111+ }112+ seen := map[string]bool{}113+ for _, a := range c.Artifacts {114+ seen[a.Rel] = true115+ prevArt, existed := oldArts[a.Rel]116+ switch {117+ case !existed:118+ emit(EventCreated, ItemChange, c.ID, a.Rel, "%s", a.Rel)119+ case a.Kind == KindTasks && old.Tasks != c.Tasks:120+ emit(EventProgress, ItemChange, c.ID, a.Rel, "tasks %d/%d → %d/%d",121+ old.Tasks.Done, old.Tasks.Total, c.Tasks.Done, c.Tasks.Total)122+ case !prevArt.ModTime.Equal(a.ModTime):123+ emit(EventEdited, ItemChange, c.ID, a.Rel, "%s", a.Rel)124+ }125+ }126+ for _, a := range old.Artifacts {127+ if !seen[a.Rel] {128+ emit(EventRemoved, ItemChange, c.ID, "", "%s", a.Rel)129+ }130+ }131+}132+133+func indexChanges(cs []Change) map[string]Change {134+ m := make(map[string]Change, len(cs))135+ for _, c := range cs {136+ m[c.ID] = c137+ }138+ return m139+}140+141+func sortedKeys(m map[string]Change) []string {142+ keys := make([]string, 0, len(m))143+ for k := range m {144+ keys = append(keys, k)145+ }146+ sort.Strings(keys)147+ return keys148+}
A internal/spec/fingerprint.go +50 -0
A internal/spec/load.go +238 -0
@@ -0,0 +1,238 @@
1+package spec2+3+import (4+ "errors"5+ "io/fs"6+ "os"7+ "path/filepath"8+ "regexp"9+ "sort"10+ "strings"11+ "time"12+)13+14+// DirName is the name of the OpenSpec directory at a repository root.15+const DirName = "openspec"16+17+var archivePrefixRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-(.+)$`)18+19+// FindRoot walks up from start looking for a directory that contains20+// openspec/. It returns start itself when no ancestor has one.21+func FindRoot(start string) (string, error) {22+ abs, err := filepath.Abs(start)23+ if err != nil {24+ return "", err25+ }26+ // Resolve symlinks so paths match what git reports (e.g. /tmp on macOS).27+ if real, err := filepath.EvalSymlinks(abs); err == nil {28+ abs = real29+ }30+ for dir := abs; ; {31+ if fi, err := os.Stat(filepath.Join(dir, DirName)); err == nil && fi.IsDir() {32+ return dir, nil33+ }34+ parent := filepath.Dir(dir)35+ if parent == dir {36+ return abs, nil37+ }38+ dir = parent39+ }40+}41+42+// Load reads the OpenSpec tree rooted at root/openspec.43+func Load(root string) (*Snapshot, error) {44+ s := &Snapshot{Root: root, Dir: filepath.Join(root, DirName)}45+ fi, err := os.Stat(s.Dir)46+ if errors.Is(err, fs.ErrNotExist) {47+ return s, nil48+ }49+ if err != nil {50+ return nil, err51+ }52+ if !fi.IsDir() {53+ return nil, errors.New(s.Dir + " is not a directory")54+ }55+ s.Exists = true56+57+ for _, name := range []string{"config.yaml", "project.md"} {58+ if p := filepath.Join(s.Dir, name); fileExists(p) {59+ s.Config = p60+ break61+ }62+ }63+64+ s.Specs, err = loadSpecs(filepath.Join(s.Dir, "specs"))65+ if err != nil {66+ return nil, err67+ }68+69+ changesDir := filepath.Join(s.Dir, "changes")70+ s.Changes, err = loadChanges(changesDir, false)71+ if err != nil {72+ return nil, err73+ }74+ s.Archive, err = loadChanges(filepath.Join(changesDir, "archive"), true)75+ if err != nil {76+ return nil, err77+ }78+ sort.Slice(s.Archive, func(i, j int) bool { return s.Archive[i].ID > s.Archive[j].ID })79+ return s, nil80+}81+82+func loadSpecs(dir string) ([]Spec, error) {83+ var specs []Spec84+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {85+ if err != nil {86+ if errors.Is(err, fs.ErrNotExist) {87+ return nil88+ }89+ return err90+ }91+ if d.IsDir() || d.Name() != "spec.md" {92+ return nil93+ }94+ rel, err := filepath.Rel(dir, filepath.Dir(path))95+ if err != nil {96+ return err97+ }98+ src, mod, err := readFile(path)99+ if err != nil {100+ return nil // vanished mid-walk; the next scan will catch up101+ }102+ meta := ParseSpec(src)103+ specs = append(specs, Spec{104+ ID: filepath.ToSlash(rel),105+ Path: path,106+ Title: meta.Title,107+ Purpose: meta.Purpose,108+ Requirements: meta.Requirements,109+ Scenarios: meta.Scenarios,110+ ModTime: mod,111+ Content: src,112+ })113+ return nil114+ })115+ sort.Slice(specs, func(i, j int) bool { return specs[i].ID < specs[j].ID })116+ return specs, err117+}118+119+func loadChanges(dir string, archived bool) ([]Change, error) {120+ entries, err := os.ReadDir(dir)121+ if errors.Is(err, fs.ErrNotExist) {122+ return nil, nil123+ }124+ if err != nil {125+ return nil, err126+ }127+ var changes []Change128+ for _, e := range entries {129+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {130+ continue131+ }132+ if !archived && e.Name() == "archive" {133+ continue134+ }135+ c, err := loadChange(filepath.Join(dir, e.Name()), archived)136+ if err != nil {137+ return nil, err138+ }139+ changes = append(changes, c)140+ }141+ sort.Slice(changes, func(i, j int) bool { return changes[i].ID < changes[j].ID })142+ return changes, nil143+}144+145+func loadChange(dir string, archived bool) (Change, error) {146+ c := Change{ID: filepath.Base(dir), Dir: dir, Archived: archived}147+ c.Name = c.ID148+ if archived {149+ if m := archivePrefixRe.FindStringSubmatch(c.ID); m != nil {150+ c.ArchivedOn, c.Name = m[1], m[2]151+ }152+ }153+ if fi, err := os.Stat(dir); err == nil {154+ c.ModTime = fi.ModTime()155+ }156+157+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {158+ if err != nil {159+ if errors.Is(err, fs.ErrNotExist) {160+ return nil161+ }162+ return err163+ }164+ if d.IsDir() {165+ return nil166+ }167+ rel, err := filepath.Rel(dir, path)168+ if err != nil {169+ return err170+ }171+ rel = filepath.ToSlash(rel)172+ src, mod, err := readFile(path)173+ if err != nil {174+ return nil175+ }176+ if mod.After(c.ModTime) {177+ c.ModTime = mod178+ }179+ if rel == ".openspec.yaml" {180+ c.Schema = ParseSchema(src)181+ return nil182+ }183+ if !strings.HasSuffix(rel, ".md") {184+ return nil185+ }186+ a := Artifact{Kind: classify(rel), Rel: rel, Path: path, ModTime: mod, Content: src}187+ switch a.Kind {188+ case KindProposal:189+ c.Summary = ParseSummary(src)190+ case KindTasks:191+ c.Tasks = ParseTasks(src)192+ case KindDelta:193+ c.Deltas = c.Deltas.add(ParseDelta(src))194+ }195+ c.Artifacts = append(c.Artifacts, a)196+ return nil197+ })198+ sort.SliceStable(c.Artifacts, func(i, j int) bool {199+ a, b := c.Artifacts[i], c.Artifacts[j]200+ if a.Kind != b.Kind {201+ return a.Kind < b.Kind202+ }203+ return a.Rel < b.Rel204+ })205+ return c, err206+}207+208+func classify(rel string) ArtifactKind {209+ switch rel {210+ case "proposal.md":211+ return KindProposal212+ case "design.md":213+ return KindDesign214+ case "tasks.md":215+ return KindTasks216+ }217+ if strings.HasPrefix(rel, "specs/") && strings.HasSuffix(rel, "/spec.md") {218+ return KindDelta219+ }220+ return KindOther221+}222+223+func readFile(path string) (string, time.Time, error) {224+ fi, err := os.Stat(path)225+ if err != nil {226+ return "", time.Time{}, err227+ }228+ b, err := os.ReadFile(path)229+ if err != nil {230+ return "", time.Time{}, err231+ }232+ return string(b), fi.ModTime(), nil233+}234+235+func fileExists(path string) bool {236+ fi, err := os.Stat(path)237+ return err == nil && !fi.IsDir()238+}
A internal/spec/model.go +163 -0
@@ -0,0 +1,163 @@
1+// Package spec reads an OpenSpec tree from disk into an immutable Snapshot.2+package spec3+4+import (5+ "strings"6+ "time"7+)8+9+// ArtifactKind classifies a file inside a change directory.10+type ArtifactKind int11+12+const (13+ KindProposal ArtifactKind = iota14+ KindDesign15+ KindTasks16+ KindDelta17+ KindOther18+)19+20+func (k ArtifactKind) String() string {21+ switch k {22+ case KindProposal:23+ return "proposal"24+ case KindDesign:25+ return "design"26+ case KindTasks:27+ return "tasks"28+ case KindDelta:29+ return "delta"30+ default:31+ return "other"32+ }33+}34+35+// Artifact is a single markdown file belonging to a change.36+type Artifact struct {37+ Kind ArtifactKind38+ Rel string // path relative to the change dir, e.g. "specs/auth/spec.md"39+ Path string // absolute path40+ ModTime time.Time41+ Content string42+}43+44+// Label is the short display name of the artifact.45+func (a Artifact) Label() string {46+ if a.Kind == KindDelta {47+ return "Δ " + a.Capability()48+ }49+ return a.Rel50+}51+52+// Capability is the spec ID a delta artifact targets: specs/<id>/spec.md -> <id>.53+func (a Artifact) Capability() string {54+ return strings.TrimSuffix(strings.TrimPrefix(a.Rel, "specs/"), "/spec.md")55+}56+57+// TaskStats counts markdown checkboxes in tasks.md.58+type TaskStats struct {59+ Done int60+ Total int61+}62+63+// Complete reports whether there is at least one task and all are checked.64+func (t TaskStats) Complete() bool { return t.Total > 0 && t.Done == t.Total }65+66+// DeltaStats counts requirement operations across a change's delta specs.67+type DeltaStats struct {68+ Added int69+ Modified int70+ Removed int71+ Renamed int72+}73+74+// Total is the number of requirement operations.75+func (d DeltaStats) Total() int { return d.Added + d.Modified + d.Removed + d.Renamed }76+77+func (d DeltaStats) add(o DeltaStats) DeltaStats {78+ return DeltaStats{79+ Added: d.Added + o.Added,80+ Modified: d.Modified + o.Modified,81+ Removed: d.Removed + o.Removed,82+ Renamed: d.Renamed + o.Renamed,83+ }84+}85+86+// Change is an active or archived change directory.87+type Change struct {88+ ID string // directory name89+ Dir string90+ Archived bool91+ ArchivedOn string // YYYY-MM-DD prefix for archived changes92+ Name string // ID without the archive date prefix93+ Schema string // from .openspec.yaml, if present94+ Summary string // first prose line under "## Why" in proposal.md95+ Artifacts []Artifact96+ Tasks TaskStats97+ Deltas DeltaStats98+ ModTime time.Time // newest artifact mtime99+}100+101+// Artifact returns the first artifact of the given kind.102+func (c Change) Artifact(kind ArtifactKind) (Artifact, bool) {103+ for _, a := range c.Artifacts {104+ if a.Kind == kind {105+ return a, true106+ }107+ }108+ return Artifact{}, false109+}110+111+// Has reports whether the change has an artifact of the given kind.112+func (c Change) Has(kind ArtifactKind) bool {113+ _, ok := c.Artifact(kind)114+ return ok115+}116+117+// Spec is a main capability spec under openspec/specs.118+type Spec struct {119+ ID string // capability directory name120+ Path string // absolute path to spec.md121+ Title string122+ Purpose string123+ Requirements int124+ Scenarios int125+ ModTime time.Time126+ Content string127+}128+129+// Snapshot is a point-in-time read of an OpenSpec tree.130+type Snapshot struct {131+ Root string // repository root (parent of openspec/)132+ Dir string // the openspec/ directory133+ Exists bool // whether Dir exists134+ Changes []Change135+ Archive []Change136+ Specs []Spec137+ Config string // path to config.yaml or project.md, if any138+}139+140+// FindChange looks up an active or archived change by ID.141+func (s *Snapshot) FindChange(id string) (Change, bool) {142+ for _, c := range s.Changes {143+ if c.ID == id {144+ return c, true145+ }146+ }147+ for _, c := range s.Archive {148+ if c.ID == id {149+ return c, true150+ }151+ }152+ return Change{}, false153+}154+155+// FindSpec looks up a main spec by capability ID.156+func (s *Snapshot) FindSpec(id string) (Spec, bool) {157+ for _, sp := range s.Specs {158+ if sp.ID == id {159+ return sp, true160+ }161+ }162+ return Spec{}, false163+}
A internal/spec/parse.go +157 -0
@@ -0,0 +1,157 @@
1+package spec2+3+import (4+ "bufio"5+ "regexp"6+ "strings"7+)8+9+// These mirror the OpenSpec CLI's parsers so counts match `openspec list`.10+var (11+ // Top-level checkboxes only: indented sub-tasks are not counted, and the12+ // CLI matches them even inside fenced code.13+ taskRe = regexp.MustCompile(`^[-*]\s+\[([\sxX])\]`)14+ sectionRe = regexp.MustCompile(`(?i)^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$`)15+ requirementRe = regexp.MustCompile(`(?i)^###\s*Requirement:`)16+ removedAltRe = regexp.MustCompile("(?i)^\\s*-\\s*`?###\\s*Requirement:")17+ scenarioRe = regexp.MustCompile(`^####\s+`)18+ renameFromRe = regexp.MustCompile(`(?i)^\s*-?\s*FROM:`)19+ fenceRe = regexp.MustCompile("^\\s*(```|~~~)")20+)21+22+// lines yields the lines of a markdown document outside fenced code blocks.23+func lines(src string, fn func(line string)) {24+ sc := bufio.NewScanner(strings.NewReader(src))25+ sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)26+ inFence := false27+ for sc.Scan() {28+ line := sc.Text()29+ if fenceRe.MatchString(line) {30+ inFence = !inFence31+ continue32+ }33+ if !inFence {34+ fn(line)35+ }36+ }37+}38+39+// ParseTasks counts checked and unchecked markdown checkboxes.40+func ParseTasks(src string) TaskStats {41+ var t TaskStats42+ for line := range strings.SplitSeq(src, "\n") {43+ m := taskRe.FindStringSubmatch(line)44+ if m == nil {45+ continue46+ }47+ t.Total++48+ if strings.EqualFold(m[1], "x") {49+ t.Done++50+ }51+ }52+ return t53+}54+55+// ParseDelta counts requirement operations in a delta spec.56+func ParseDelta(src string) DeltaStats {57+ var d DeltaStats58+ section := ""59+ lines(src, func(line string) {60+ if m := sectionRe.FindStringSubmatch(line); m != nil {61+ section = strings.ToUpper(m[1])62+ return63+ }64+ if strings.HasPrefix(line, "## ") {65+ section = ""66+ return67+ }68+ switch section {69+ case "ADDED":70+ if requirementRe.MatchString(line) {71+ d.Added++72+ }73+ case "MODIFIED":74+ if requirementRe.MatchString(line) {75+ d.Modified++76+ }77+ case "REMOVED":78+ if requirementRe.MatchString(line) || removedAltRe.MatchString(line) {79+ d.Removed++80+ }81+ case "RENAMED":82+ if renameFromRe.MatchString(line) {83+ d.Renamed++84+ }85+ }86+ })87+ return d88+}89+90+// SpecMeta is the summary information extracted from a main spec.91+type SpecMeta struct {92+ Title string93+ Purpose string94+ Requirements int95+ Scenarios int96+}97+98+// ParseSpec extracts the title, purpose, and requirement counts from a spec.99+func ParseSpec(src string) SpecMeta {100+ var s SpecMeta101+ section := ""102+ lines(src, func(line string) {103+ switch {104+ case strings.HasPrefix(line, "# ") && s.Title == "":105+ s.Title = strings.TrimSpace(strings.TrimPrefix(line, "# "))106+ case strings.HasPrefix(line, "## "), strings.HasPrefix(line, "### ") && !requirementRe.MatchString(line):107+ section = strings.TrimSpace(strings.TrimLeft(line, "# "))108+ case requirementRe.MatchString(line):109+ s.Requirements++110+ case scenarioRe.MatchString(line):111+ s.Scenarios++112+ case strings.EqualFold(section, "Purpose") && s.Purpose == "":113+ if text := strings.TrimSpace(line); text != "" {114+ s.Purpose = text115+ }116+ }117+ })118+ return s119+}120+121+// ParseSummary returns the first prose line under "## Why" in a proposal,122+// falling back to the first prose line in the document.123+func ParseSummary(src string) string {124+ var why, first string125+ section := ""126+ lines(src, func(line string) {127+ text := strings.TrimSpace(line)128+ if strings.HasPrefix(text, "#") {129+ section = strings.TrimSpace(strings.TrimLeft(text, "#"))130+ return131+ }132+ if text == "" || strings.HasPrefix(text, "<!--") {133+ return134+ }135+ text = strings.TrimSpace(strings.TrimLeft(text, "-*> "))136+ if first == "" {137+ first = text138+ }139+ if why == "" && strings.EqualFold(section, "Why") {140+ why = text141+ }142+ })143+ if why != "" {144+ return why145+ }146+ return first147+}148+149+// ParseSchema reads the "schema:" field from a change's .openspec.yaml.150+func ParseSchema(src string) string {151+ for line := range strings.SplitSeq(src, "\n") {152+ if v, ok := strings.CutPrefix(strings.TrimSpace(line), "schema:"); ok {153+ return strings.Trim(strings.TrimSpace(v), `"'`)154+ }155+ }156+ return ""157+}
A internal/ui/layout.go +146 -0
@@ -0,0 +1,146 @@
1+package ui2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/charmbracelet/x/ansi"8+)9+10+type rect struct{ x, y, w, h int }11+12+func (r rect) contains(x, y int) bool {13+ return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h14+}15+16+// geometry is the screen layout for one frame. Panel rects include borders.17+type geometry struct {18+ panels [numLists]rect19+ main rect20+}21+22+const (23+ headerRows = 124+ footerRows = 125+)26+27+func (m Model) geometry() geometry {28+ var g geometry29+ bodyH := max(m.height-headerRows-footerRows, 0)30+ leftW := clamp(m.width*38/100, 34, 60)31+ if m.width < 80 {32+ leftW = m.width / 233+ }34+ if m.zoom {35+ leftW = 036+ }37+38+ // Content rows available to the four list panels after their borders.39+ avail := max(bodyH-2*numLists, numLists)40+ want := [numLists]int{41+ max(m.count(panelChanges), 1),42+ max(m.count(panelSpecs), 1),43+ max(m.count(panelArchive), 1),44+ max(m.count(panelActivity), 1),45+ }46+ share := [numLists]int{35, 25, 15, 25}47+ rows := allocate(want, share, avail, m.focus)48+49+ y := headerRows50+ for i := range numLists {51+ h := rows[i] + 252+ g.panels[i] = rect{0, y, leftW, h}53+ y += h54+ }55+ g.main = rect{leftW, headerRows, m.width - leftW, bodyH}56+ return g57+}58+59+// allocate splits total rows between panels: each gets its percentage share,60+// unused share flows to panels that want more, and any slack goes to the61+// focused panel.62+func allocate(want, share [numLists]int, total int, focus panelID) [numLists]int {63+ var rows [numLists]int64+ left := total65+ for i := range rows {66+ rows[i] = min(max(total*share[i]/100, 1), want[i])67+ left -= rows[i]68+ }69+ for left > 0 {70+ best, gap := -1, 071+ for i := range rows {72+ if g := want[i] - rows[i]; g > gap {73+ best, gap = i, g74+ }75+ }76+ if best < 0 {77+ break78+ }79+ rows[best]++80+ left--81+ }82+ if left > 0 && int(focus) < numLists {83+ rows[focus] += left84+ } else if left > 0 {85+ rows[panelActivity] += left86+ }87+ return rows88+}89+90+// box draws a rounded border around body with a title embedded in the top91+// edge and an optional footer in the bottom edge, lazygit-style.92+func box(t theme, title, footer, body string, w, h int, focused bool) string {93+ if w < 2 || h < 2 {94+ return ""95+ }96+ border := t.subtle97+ if focused {98+ border = lipgloss.NewStyle().Foreground(t.accent)99+ }100+ b := lipgloss.RoundedBorder()101+ innerW := w - 2102+103+ edge := func(left, right, label string) string {104+ label = ansi.Truncate(label, max(innerW-2, 0), "…")105+ fill := innerW - lipgloss.Width(label)106+ if label != "" {107+ fill -= 2108+ label = border.Render(b.Top) + label + border.Render(b.Top)109+ }110+ return border.Render(left) + label + border.Render(strings.Repeat(b.Top, max(fill, 0))) + border.Render(right)111+ }112+113+ var sb strings.Builder114+ sb.WriteString(edge(b.TopLeft, b.TopRight, title))115+ lines := strings.Split(body, "\n")116+ for i := range h - 2 {117+ line := ""118+ if i < len(lines) {119+ line = lines[i]120+ }121+ sb.WriteString("\n")122+ sb.WriteString(border.Render(b.Left))123+ sb.WriteString(fit(line, innerW))124+ sb.WriteString(border.Render(b.Right))125+ }126+ sb.WriteString("\n")127+ bottom := edge(b.BottomLeft, b.BottomRight, footer)128+ sb.WriteString(bottom)129+ return sb.String()130+}131+132+// fit truncates or right-pads s to exactly w cells.133+func fit(s string, w int) string {134+ if w <= 0 {135+ return ""136+ }137+ s = ansi.Truncate(s, w, "…")138+ if pad := w - lipgloss.Width(s); pad > 0 {139+ s += strings.Repeat(" ", pad)140+ }141+ return s142+}143+144+func clamp(v, lo, hi int) int {145+ return max(lo, min(v, hi))146+}
A internal/ui/mainpane.go +459 -0
@@ -0,0 +1,459 @@
1+package ui2+3+import (4+ "fmt"5+ "path/filepath"6+ "strings"7+ "time"8+9+ tea "charm.land/bubbletea/v2"10+ "charm.land/glamour/v2"11+12+ "github.com/dami/lazyopenspec/internal/gitx"13+ "github.com/dami/lazyopenspec/internal/spec"14+)15+16+// target is the item shown in the main pane.17+type target struct {18+ panel panelID19+ change spec.Change20+ spec spec.Spec21+ ok bool22+}23+24+func (t target) key() string {25+ switch t.panel {26+ case panelSpecs:27+ return targetKey(t.panel, t.spec.ID)28+ default:29+ return targetKey(t.panel, t.change.ID)30+ }31+}32+33+func targetKey(p panelID, id string) string { return fmt.Sprintf("%d:%s", p, id) }34+35+// tab is one file viewable for a target.36+type tab struct {37+ key string38+ label string39+ path string40+ mod time.Time41+ content string42+}43+44+func (m Model) target() target {45+ if m.snap == nil {46+ return target{}47+ }48+ p := m.lastList49+ i := m.cursors[p]50+ switch p {51+ case panelChanges:52+ if cs := m.changes(); i < len(cs) {53+ return target{panel: p, change: cs[i], ok: true}54+ }55+ case panelSpecs:56+ if i < len(m.snap.Specs) {57+ return target{panel: p, spec: m.snap.Specs[i], ok: true}58+ }59+ case panelArchive:60+ if i < len(m.snap.Archive) {61+ return target{panel: p, change: m.snap.Archive[i], ok: true}62+ }63+ }64+ return target{}65+}66+67+// pendingDeltas lists active-change delta artifacts that target a spec.68+func (m Model) pendingDeltas(specID string) []tab {69+ if m.snap == nil {70+ return nil71+ }72+ var tabs []tab73+ for _, c := range m.snap.Changes {74+ for _, a := range c.Artifacts {75+ if a.Kind == spec.KindDelta && a.Capability() == specID {76+ tabs = append(tabs, tab{77+ key: "Δ " + c.ID, label: "Δ " + c.ID,78+ path: a.Path, mod: a.ModTime, content: a.Content,79+ })80+ }81+ }82+ }83+ return tabs84+}85+86+func (m Model) tabsFor(t target) []tab {87+ if !t.ok {88+ return nil89+ }90+ if t.panel == panelSpecs {91+ tabs := []tab{{key: "spec.md", label: "spec.md", path: t.spec.Path, mod: t.spec.ModTime, content: t.spec.Content}}92+ return append(tabs, m.pendingDeltas(t.spec.ID)...)93+ }94+ tabs := make([]tab, 0, len(t.change.Artifacts))95+ for _, a := range t.change.Artifacts {96+ tabs = append(tabs, tab{key: a.Rel, label: a.Label(), path: a.Path, mod: a.ModTime, content: a.Content})97+ }98+ return tabs99+}100+101+func (m Model) currentTab(t target) (tab, bool) {102+ tabs := m.tabsFor(t)103+ if len(tabs) == 0 {104+ return tab{}, false105+ }106+ want := m.tabs[t.key()]107+ for _, tb := range tabs {108+ if tb.key == want {109+ return tb, true110+ }111+ }112+ return tabs[0], true113+}114+115+func (m *Model) cycleTab(delta int) {116+ t := m.target()117+ tabs := m.tabsFor(t)118+ if len(tabs) == 0 {119+ return120+ }121+ cur, _ := m.currentTab(t)122+ i := 0123+ for j, tb := range tabs {124+ if tb.key == cur.key {125+ i = j126+ }127+ }128+ i = (i + delta + len(tabs)) % len(tabs)129+ m.tabs[t.key()] = tabs[i].key130+}131+132+const mainChromeRows = 5 // 3 header rows, tab bar, rule133+134+func (m Model) mainInner() (w, h int) {135+ g := m.geometry()136+ return max(g.main.w-2, 0), max(g.main.h-2, 0)137+}138+139+// syncMain refreshes the viewport when the visible content's identity140+// changes. Scroll position is kept per target/tab so live edits to the file141+// being read don't yank the view back to the top.142+func (m *Model) syncMain() tea.Cmd {143+ w, h := m.mainInner()144+ vpH := max(h-mainChromeRows, 0)145+ t := m.target()146+ tb, hasTab := m.currentTab(t)147+ dirty := m.git.Dirty[tb.path]148+149+ viewKey := fmt.Sprintf("%s|%s|%t", t.key(), tb.key, m.diffMode)150+ contentKey := fmt.Sprintf("%s|%d|%s|%dx%d|%t", viewKey, tb.mod.UnixNano(), dirty, w, vpH, m.theme.isDark)151+ if contentKey == m.mainKey && m.vp.Width() == w && m.vp.Height() == vpH {152+ return nil153+ }154+ if m.viewKey != "" {155+ m.scroll[m.viewKey] = m.vp.YOffset()156+ }157+ m.mainKey, m.viewKey = contentKey, viewKey158+159+ m.vp.SetWidth(w)160+ m.vp.SetHeight(vpH)161+162+ var cmd tea.Cmd163+ switch {164+ case m.snap != nil && !m.snap.Exists:165+ m.vp.SetContent(m.theme.muted.Render(fmt.Sprintf(166+ "\n No %s/ directory in %s yet.\n Waiting for one to appear (run `openspec init`).",167+ spec.DirName, m.opts.Root)))168+ case !t.ok || !hasTab:169+ m.vp.SetContent(m.theme.muted.Render("\n Nothing selected."))170+ case m.diffMode:171+ var content string172+ content, cmd = m.diffContent(tb, dirty)173+ m.vp.SetContent(content)174+ default:175+ m.vp.SetContent(m.renderer.render(tb.path, tb.mod, tb.content, w, m.theme.glamourStyle()))176+ }177+ m.vp.SetYOffset(m.scroll[viewKey])178+ return cmd179+}180+181+func (m *Model) diffContent(tb tab, dirty string) (string, tea.Cmd) {182+ if !m.git.IsRepo {183+ return m.theme.muted.Render("\n Not a git repository; diff unavailable."), nil184+ }185+ if dirty == "" {186+ return m.theme.muted.Render("\n No uncommitted changes to " + m.relPath(tb.path) + "."), nil187+ }188+ key := fmt.Sprintf("%s|%d|%s", tb.path, tb.mod.UnixNano(), dirty)189+ if d, ok := m.diffs[key]; ok {190+ if d.err != nil {191+ return m.theme.remSt.Render("\n git diff failed: " + d.err.Error()), nil192+ }193+ return m.colorDiff(d.text), nil194+ }195+ if m.diffReq[key] {196+ return m.theme.muted.Render("\n Loading diff…"), nil197+ }198+ m.diffReq[key] = true199+ root, path := m.opts.Root, tb.path200+ untracked := dirty == "??"201+ return m.theme.muted.Render("\n Loading diff…"), func() tea.Msg {202+ text, err := gitx.Diff(root, path, untracked)203+ return diffMsg{key: key, text: text, err: err}204+ }205+}206+207+func (m Model) colorDiff(diff string) string {208+ t := m.theme209+ var sb strings.Builder210+ for line := range strings.SplitSeq(strings.TrimRight(diff, "\n"), "\n") {211+ switch {212+ case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),213+ strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "index "),214+ strings.HasPrefix(line, "new file"), strings.HasPrefix(line, "deleted file"):215+ line = t.muted.Render(line)216+ case strings.HasPrefix(line, "@@"):217+ line = t.diffHunk.Render(line)218+ case strings.HasPrefix(line, "+"):219+ line = t.diffAdd.Render(line)220+ case strings.HasPrefix(line, "-"):221+ line = t.diffDel.Render(line)222+ }223+ sb.WriteString(" ")224+ sb.WriteString(line)225+ sb.WriteString("\n")226+ }227+ return sb.String()228+}229+230+func (m Model) relPath(path string) string {231+ if rel, err := filepath.Rel(m.opts.Root, path); err == nil {232+ return rel233+ }234+ return path235+}236+237+// renderMain draws the main pane: item header, tab bar, and viewport.238+func (m Model) renderMain(r rect) string {239+ w := max(r.w-2, 0)240+ t := m.target()241+ focused := m.focus == panelMain242+243+ if m.showHelp {244+ return box(m.theme, m.theme.title.Render("Keys"), "", m.helpText(w), r.w, r.h, true)245+ }246+247+ var title string248+ var header []string249+ switch {250+ case !t.ok:251+ title = "lazyopenspec"252+ header = []string{"", "", ""}253+ case t.panel == panelSpecs:254+ title = "Spec " + m.theme.bold.Render(t.spec.ID)255+ header = m.specHeader(t.spec, w)256+ case t.change.Archived:257+ title = "Archived " + m.theme.bold.Render(t.change.Name)258+ header = m.changeHeader(t.change, w)259+ default:260+ title = "Change " + m.theme.bold.Render(t.change.ID)261+ header = m.changeHeader(t.change, w)262+ }263+264+ lines := append(header, m.tabBar(t, w), m.theme.subtle.Render(strings.Repeat("─", w)))265+ body := strings.Join(lines, "\n") + "\n" + m.vp.View()266+267+ footer := ""268+ if m.vp.TotalLineCount() > m.vp.Height() {269+ footer = m.theme.muted.Render(fmt.Sprintf("%3.f%%", m.vp.ScrollPercent()*100))270+ }271+ return box(m.theme, title, footer, body, r.w, r.h, focused)272+}273+274+func (m Model) changeHeader(c spec.Change, w int) []string {275+ th := m.theme276+ right := ""277+ if c.Archived {278+ right = "archived " + c.ArchivedOn279+ } else {280+ right = "edited " + ago(m.now, c.ModTime)281+ }282+ if c.Schema != "" {283+ right = c.Schema + " " + right284+ }285+ line1 := spread(" "+th.bold.Render(c.Name), th.muted.Render(right)+" ", w)286+287+ summary := c.Summary288+ if summary == "" {289+ summary = "(no proposal yet)"290+ }291+ line2 := " " + th.muted.Render(summary)292+293+ chip := func(label string, ok bool) string {294+ if ok {295+ return th.addSt.Render("✓ " + label)296+ }297+ return th.subtle.Render("○ " + label)298+ }299+ parts := []string{300+ chip("proposal", c.Has(spec.KindProposal)),301+ chip("design", c.Has(spec.KindDesign)),302+ chip("specs", c.Has(spec.KindDelta)),303+ chip("tasks", c.Has(spec.KindTasks)),304+ }305+ if c.Tasks.Total > 0 {306+ parts = append(parts, m.progressBar(c.Tasks, 12)+" "+m.taskCount(c.Tasks))307+ }308+ if d := c.Deltas; d.Total() > 0 {309+ parts = append(parts, m.deltaSummary(d))310+ }311+ line3 := " " + strings.Join(parts, " ")312+ return []string{line1, line2, line3}313+}314+315+func (m Model) specHeader(s spec.Spec, w int) []string {316+ th := m.theme317+ title := s.Title318+ if title == "" {319+ title = s.ID320+ }321+ line1 := spread(" "+th.bold.Render(title), th.muted.Render("edited "+ago(m.now, s.ModTime))+" ", w)322+ purpose := s.Purpose323+ if purpose == "" {324+ purpose = "(no purpose)"325+ }326+ line2 := " " + th.muted.Render(purpose)327+ stats := fmt.Sprintf("%d requirements %d scenarios", s.Requirements, s.Scenarios)328+ line3 := " " + th.muted.Render(stats)329+ if pending := m.pendingDeltas(s.ID); len(pending) > 0 {330+ ids := make([]string, len(pending))331+ for i, p := range pending {332+ ids[i] = strings.TrimPrefix(p.label, "Δ ")333+ }334+ line3 += " " + th.modSt.Render("Δ pending: "+strings.Join(ids, ", "))335+ }336+ return []string{line1, line2, line3}337+}338+339+func (m Model) tabBar(t target, w int) string {340+ th := m.theme341+ tabs := m.tabsFor(t)342+ cur, _ := m.currentTab(t)343+ var parts []string344+ for _, tb := range tabs {345+ // Style each piece separately: underlined styles re-wrap every rune346+ // and would mangle an already-styled substring.347+ st := th.tabInactive348+ if tb.key == cur.key {349+ st = th.tabActive350+ }351+ part := st.Render(tb.label)352+ if m.now.Sub(tb.mod) < freshWindow {353+ part = th.freshSt.Render("● ") + part354+ }355+ if mark := m.gitMark(tb.path); mark != "" {356+ part += " " + mark357+ }358+ parts = append(parts, part)359+ }360+ left := " " + strings.Join(parts, th.subtle.Render(" │ "))361+ right := ""362+ if m.diffMode {363+ right = th.modSt.Render("[diff vs HEAD]") + " "364+ }365+ return spread(left, right, w)366+}367+368+func (m Model) gitMark(path string) string {369+ code, ok := m.git.Dirty[path]370+ if !ok {371+ return ""372+ }373+ switch strings.TrimSpace(code) {374+ case "??", "A":375+ return m.theme.addSt.Render("+")376+ case "D":377+ return m.theme.remSt.Render("-")378+ default:379+ return m.theme.modSt.Render("M")380+ }381+}382+383+func (m Model) progressBar(ts spec.TaskStats, width int) string {384+ if ts.Total == 0 || width <= 0 {385+ return m.theme.subtle.Render(strings.Repeat("▱", max(width, 0)))386+ }387+ filled := ts.Done * width / ts.Total388+ st := m.theme.title389+ if ts.Complete() {390+ st = m.theme.addSt391+ }392+ return st.Render(strings.Repeat("▰", filled)) + m.theme.subtle.Render(strings.Repeat("▱", width-filled))393+}394+395+func (m Model) taskCount(ts spec.TaskStats) string {396+ s := fmt.Sprintf("%d/%d", ts.Done, ts.Total)397+ if ts.Complete() {398+ return m.theme.addSt.Render(s)399+ }400+ return m.theme.muted.Render(s)401+}402+403+func (m Model) deltaSummary(d spec.DeltaStats) string {404+ th := m.theme405+ var parts []string406+ if d.Added > 0 {407+ parts = append(parts, th.addSt.Render(fmt.Sprintf("+%d", d.Added)))408+ }409+ if d.Modified > 0 {410+ parts = append(parts, th.modSt.Render(fmt.Sprintf("~%d", d.Modified)))411+ }412+ if d.Removed > 0 {413+ parts = append(parts, th.remSt.Render(fmt.Sprintf("-%d", d.Removed)))414+ }415+ if d.Renamed > 0 {416+ parts = append(parts, th.renSt.Render(fmt.Sprintf("→%d", d.Renamed)))417+ }418+ return th.muted.Render("Δ ") + strings.Join(parts, " ")419+}420+421+// mdRenderer caches glamour renderers per style/width and rendered output422+// per file version, so redraws and repeat visits are cheap.423+type mdRenderer struct {424+ renderers map[string]*glamour.TermRenderer425+ cache map[string]string426+}427+428+func newMDRenderer() *mdRenderer {429+ return &mdRenderer{renderers: map[string]*glamour.TermRenderer{}, cache: map[string]string{}}430+}431+432+func (r *mdRenderer) render(path string, mod time.Time, src string, width int, style string) string {433+ key := fmt.Sprintf("%s|%d|%d|%s", path, mod.UnixNano(), width, style)434+ if out, ok := r.cache[key]; ok {435+ return out436+ }437+ rkey := fmt.Sprintf("%s|%d", style, width)438+ tr, ok := r.renderers[rkey]439+ if !ok {440+ var err error441+ tr, err = glamour.NewTermRenderer(442+ glamour.WithStandardStyle(style),443+ glamour.WithWordWrap(max(width-4, 20)),444+ )445+ if err != nil {446+ return src447+ }448+ r.renderers[rkey] = tr449+ }450+ out, err := tr.Render(src)451+ if err != nil {452+ out = src453+ }454+ if len(r.cache) > 256 {455+ clear(r.cache)456+ }457+ r.cache[key] = out458+ return out459+}
A internal/ui/model.go +582 -0
@@ -0,0 +1,582 @@
1+// Package ui is the Bubble Tea front end for lazyopenspec.2+package ui3+4+import (5+ "os"6+ "os/exec"7+ "path/filepath"8+ "sort"9+ "strings"10+ "time"11+12+ "charm.land/bubbles/v2/viewport"13+ tea "charm.land/bubbletea/v2"14+15+ "github.com/dami/lazyopenspec/internal/gitx"16+ "github.com/dami/lazyopenspec/internal/spec"17+)18+19+type panelID int20+21+const (22+ panelChanges panelID = iota23+ panelSpecs24+ panelArchive25+ panelActivity26+ panelMain27+28+ numLists = 429+)30+31+var panelTitles = [numLists]string{"Changes", "Specs", "Archive", "Activity"}32+33+const (34+ maxEvents = 50035+ gitEvery = 5 * time.Second36+ freshWindow = 15 * time.Second37+ warmWindow = 2 * time.Minute38+ scrollStep = 339+ defaultPollMs = 40040+)41+42+// Options configure the UI.43+type Options struct {44+ Root string45+ Interval time.Duration46+ Follow bool47+}48+49+type (50+ pollMsg time.Time51+ scannedMsg spec.Fingerprint52+ loadedMsg struct {53+ snap *spec.Snapshot54+ err error55+ }56+ gitMsg gitx.Status57+ diffMsg struct {58+ key string59+ text string60+ err error61+ }62+ editorDoneMsg struct{ err error }63+)64+65+// Model is the root Bubble Tea model.66+type Model struct {67+ opts Options68+ theme theme69+70+ width, height int71+ focus panelID72+ lastList panelID // list panel driving the main pane73+ cursors [numLists]int74+ offsets [numLists]int75+ selected [numLists]string // selected item ID per list, kept across reloads76+ sortRecent bool77+ follow bool78+ diffMode bool79+ zoom bool80+ showHelp bool81+82+ snap *spec.Snapshot83+ fp spec.Fingerprint84+ loading bool85+ err error86+ git gitx.Status87+ gitAt time.Time88+ events []spec.Event // newest first89+ now time.Time90+ lastSeen time.Time // time of the most recent event91+92+ vp viewport.Model93+ tabs map[string]string // target key -> selected tab key94+ scroll map[string]int // content key -> remembered y offset95+ mainKey string // identity of the content currently in vp96+ viewKey string // target/tab/mode shown in vp, for scroll memory97+ renderer *mdRenderer98+ diffs map[string]diffMsg99+ diffReq map[string]bool100+}101+102+// New constructs the root model.103+func New(opts Options) Model {104+ if opts.Interval <= 0 {105+ opts.Interval = defaultPollMs * time.Millisecond106+ }107+ vp := viewport.New()108+ vp.MouseWheelEnabled = false // the root model routes wheel events109+ return Model{110+ opts: opts,111+ theme: newTheme(true),112+ follow: opts.Follow,113+ vp: vp,114+ tabs: map[string]string{},115+ scroll: map[string]int{},116+ renderer: newMDRenderer(),117+ diffs: map[string]diffMsg{},118+ diffReq: map[string]bool{},119+ now: time.Now(),120+ }121+}122+123+// Init starts the first load, git read, and poll loop.124+func (m Model) Init() tea.Cmd {125+ return tea.Batch(tea.RequestBackgroundColor, m.scanCmd(), m.gitCmd())126+}127+128+func (m Model) scanCmd() tea.Cmd {129+ dir := filepath.Join(m.opts.Root, spec.DirName)130+ return func() tea.Msg { return scannedMsg(spec.Scan(dir)) }131+}132+133+func (m Model) loadCmd() tea.Cmd {134+ root := m.opts.Root135+ return func() tea.Msg {136+ snap, err := spec.Load(root)137+ return loadedMsg{snap, err}138+ }139+}140+141+func (m Model) gitCmd() tea.Cmd {142+ root := m.opts.Root143+ dir := filepath.Join(root, spec.DirName)144+ return func() tea.Msg { return gitMsg(gitx.Read(root, dir)) }145+}146+147+func (m Model) pollCmd() tea.Cmd {148+ return tea.Tick(m.opts.Interval, func(t time.Time) tea.Msg { return pollMsg(t) })149+}150+151+// Update handles messages.152+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {153+ var cmds []tea.Cmd154+ switch msg := msg.(type) {155+ case tea.WindowSizeMsg:156+ m.width, m.height = msg.Width, msg.Height157+158+ case tea.BackgroundColorMsg:159+ m.theme = newTheme(msg.IsDark())160+ m.mainKey = "" // re-render markdown with the new style161+162+ case scannedMsg:163+ // The fingerprint is only recorded when a load starts, so edits that164+ // land while a load is in flight are caught by the next scan.165+ fp := spec.Fingerprint(msg)166+ if !m.loading && (m.fp == nil || len(m.fp.Changed(fp)) > 0) {167+ m.fp = fp168+ m.loading = true169+ cmds = append(cmds, m.loadCmd())170+ }171+ cmds = append(cmds, m.pollCmd())172+173+ case pollMsg:174+ m.now = time.Time(msg)175+ cmds = append(cmds, m.scanCmd())176+ if m.now.Sub(m.gitAt) >= gitEvery {177+ m.gitAt = m.now178+ cmds = append(cmds, m.gitCmd())179+ }180+181+ case loadedMsg:182+ m.loading = false183+ m.err = msg.err184+ if msg.err == nil {185+ cmds = append(cmds, m.applySnapshot(msg.snap)...)186+ }187+188+ case gitMsg:189+ m.git = gitx.Status(msg)190+191+ case diffMsg:192+ m.diffs[msg.key] = msg193+ delete(m.diffReq, msg.key)194+ m.mainKey = ""195+196+ case editorDoneMsg:197+ if msg.err != nil {198+ m.err = msg.err199+ }200+201+ case tea.KeyPressMsg:202+ cmds = append(cmds, m.handleKey(msg))203+204+ case tea.MouseWheelMsg:205+ m.handleWheel(msg)206+207+ case tea.MouseClickMsg:208+ m.handleClick(msg)209+ }210+211+ cmds = append(cmds, m.syncMain())212+ return m, tea.Batch(cmds...)213+}214+215+// applySnapshot swaps in a freshly loaded snapshot, records activity, and216+// keeps each list's selection pinned to the same item ID.217+func (m *Model) applySnapshot(next *spec.Snapshot) []tea.Cmd {218+ prev := m.snap219+ m.snap = next220+ m.now = time.Now()221+ events := spec.Diff(prev, next, m.now)222+ for i := len(events) - 1; i >= 0; i-- {223+ m.events = append([]spec.Event{events[i]}, m.events...)224+ }225+ if len(m.events) > maxEvents {226+ m.events = m.events[:maxEvents]227+ }228+ if len(events) > 0 {229+ m.lastSeen = m.now230+ }231+ if m.cursors[panelActivity] > 0 || m.offsets[panelActivity] > 0 {232+ // Keep a scrolled-back activity cursor on the same event; at the top233+ // it keeps tracking the newest one.234+ m.cursors[panelActivity] += len(events)235+ m.offsets[panelActivity] += len(events)236+ }237+ for p := range panelID(numLists) {238+ if p == panelActivity {239+ continue240+ }241+ if i := m.indexOf(p, m.selected[p]); i >= 0 {242+ m.cursors[p] = i243+ }244+ }245+ if m.follow && len(events) > 0 {246+ m.followEvent(events[len(events)-1])247+ }248+ m.clampAll()249+ if prev != nil {250+ return []tea.Cmd{m.gitCmd()}251+ }252+ return nil253+}254+255+// followEvent moves the selection to the item an event refers to.256+func (m *Model) followEvent(e spec.Event) {257+ var p panelID258+ switch e.Item {259+ case spec.ItemChange:260+ p = panelChanges261+ case spec.ItemSpec:262+ p = panelSpecs263+ case spec.ItemArchive:264+ p = panelArchive265+ }266+ i := m.indexOf(p, e.ID)267+ if i < 0 {268+ return269+ }270+ m.cursors[p] = i271+ m.selected[p] = e.ID272+ m.lastList = p273+ if m.focus != panelMain {274+ m.focus = p275+ }276+ if e.Rel != "" {277+ m.tabs[targetKey(p, e.ID)] = e.Rel278+ }279+}280+281+func (m Model) changes() []spec.Change {282+ if m.snap == nil {283+ return nil284+ }285+ cs := m.snap.Changes286+ if m.sortRecent {287+ cs = append([]spec.Change(nil), cs...)288+ sort.SliceStable(cs, func(i, j int) bool { return cs[i].ModTime.After(cs[j].ModTime) })289+ }290+ return cs291+}292+293+func (m Model) count(p panelID) int {294+ if m.snap == nil && p != panelActivity {295+ return 0296+ }297+ switch p {298+ case panelChanges:299+ return len(m.snap.Changes)300+ case panelSpecs:301+ return len(m.snap.Specs)302+ case panelArchive:303+ return len(m.snap.Archive)304+ case panelActivity:305+ return len(m.events)306+ }307+ return 0308+}309+310+// idAt returns the item ID at index i of list p.311+func (m Model) idAt(p panelID, i int) string {312+ if i < 0 || i >= m.count(p) {313+ return ""314+ }315+ switch p {316+ case panelChanges:317+ return m.changes()[i].ID318+ case panelSpecs:319+ return m.snap.Specs[i].ID320+ case panelArchive:321+ return m.snap.Archive[i].ID322+ case panelActivity:323+ e := m.events[i]324+ return e.Time.Format(time.RFC3339Nano) + e.ID + e.Detail325+ }326+ return ""327+}328+329+func (m Model) indexOf(p panelID, id string) int {330+ if id == "" {331+ return -1332+ }333+ for i := range m.count(p) {334+ if m.idAt(p, i) == id {335+ return i336+ }337+ }338+ return -1339+}340+341+func (m *Model) clampAll() {342+ g := m.geometry()343+ for p := range panelID(numLists) {344+ n := m.count(p)345+ h := g.panels[p].h - 2346+ c := clamp(m.cursors[p], 0, max(n-1, 0))347+ off := m.offsets[p]348+ if c < off {349+ off = c350+ }351+ if h > 0 && c >= off+h {352+ off = c - h + 1353+ }354+ off = clamp(off, 0, max(n-h, 0))355+ m.cursors[p], m.offsets[p] = c, off356+ m.selected[p] = m.idAt(p, c)357+ }358+}359+360+func (m *Model) moveCursor(p panelID, delta int) {361+ if p >= numLists {362+ return363+ }364+ m.cursors[p] += delta365+ m.clampAll()366+ m.lastList = p367+}368+369+func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {370+ if m.showHelp {371+ m.showHelp = false372+ return nil373+ }374+ key := msg.String()375+ switch key {376+ case "q", "ctrl+c":377+ return tea.Quit378+ case "?":379+ m.showHelp = true380+ case "tab":381+ m.setFocus((m.focus + 1) % (numLists + 1))382+ case "shift+tab":383+ m.setFocus((m.focus + numLists) % (numLists + 1))384+ case "1", "2", "3", "4":385+ m.setFocus(panelID(key[0] - '1'))386+ case "0":387+ m.setFocus(panelMain)388+ case "enter", "l", "right":389+ if m.focus == panelActivity {390+ if i := m.cursors[panelActivity]; i < len(m.events) {391+ m.jumpTo(m.events[i])392+ }393+ } else if m.focus < numLists {394+ m.setFocus(panelMain)395+ }396+ case "esc", "h", "left":397+ if m.focus == panelMain {398+ m.setFocus(m.lastList)399+ }400+ case "j", "down":401+ m.scrollOrMove(1)402+ case "k", "up":403+ m.scrollOrMove(-1)404+ case "g", "home":405+ m.scrollOrMove(-1 << 20)406+ case "G", "end":407+ m.scrollOrMove(1 << 20)408+ case "J", "ctrl+d", "pgdown":409+ m.vp.HalfPageDown()410+ case "K", "ctrl+u", "pgup":411+ m.vp.HalfPageUp()412+ case "]":413+ m.cycleTab(1)414+ case "[":415+ m.cycleTab(-1)416+ case "d":417+ m.diffMode = !m.diffMode418+ case "f", "F":419+ m.follow = !m.follow420+ case "s":421+ m.sortRecent = !m.sortRecent422+ if i := m.indexOf(panelChanges, m.selected[panelChanges]); i >= 0 {423+ m.cursors[panelChanges] = i424+ }425+ m.clampAll()426+ case "z":427+ m.zoom = !m.zoom428+ m.clampAll()429+ case "r":430+ m.loading = true431+ return tea.Batch(m.loadCmd(), m.gitCmd())432+ case "c":433+ m.events = nil434+ m.cursors[panelActivity] = 0435+ m.clampAll()436+ case "e":437+ return m.editCmd()438+ }439+ return nil440+}441+442+func (m *Model) setFocus(p panelID) {443+ m.focus = p444+ if p < numLists && p != panelActivity {445+ m.lastList = p446+ }447+ if p == panelActivity {448+ if i := m.cursors[panelActivity]; i < len(m.events) {449+ m.peekEvent(m.events[i])450+ }451+ }452+}453+454+func (m *Model) scrollOrMove(delta int) {455+ if m.focus == panelMain {456+ switch {457+ case delta <= -1<<20:458+ m.vp.GotoTop()459+ case delta >= 1<<20:460+ m.vp.GotoBottom()461+ case delta > 0:462+ m.vp.ScrollDown(delta)463+ default:464+ m.vp.ScrollUp(-delta)465+ }466+ return467+ }468+ m.cursors[m.focus] += delta469+ m.clampAll()470+ if m.focus == panelActivity {471+ if i := m.cursors[panelActivity]; i < len(m.events) {472+ m.peekEvent(m.events[i])473+ }474+ return475+ }476+ m.lastList = m.focus477+}478+479+// peekEvent shows an activity item in the main pane without moving focus.480+func (m *Model) peekEvent(e spec.Event) {481+ p := map[spec.ItemKind]panelID{482+ spec.ItemChange: panelChanges, spec.ItemSpec: panelSpecs, spec.ItemArchive: panelArchive,483+ }[e.Item]484+ if i := m.indexOf(p, e.ID); i >= 0 {485+ m.cursors[p] = i486+ m.selected[p] = e.ID487+ m.lastList = p488+ if e.Rel != "" {489+ m.tabs[targetKey(p, e.ID)] = e.Rel490+ }491+ m.clampAll()492+ }493+}494+495+// jumpTo focuses the list containing an event's item.496+func (m *Model) jumpTo(e spec.Event) {497+ m.peekEvent(e)498+ m.focus = m.lastList499+}500+501+func (m *Model) handleWheel(msg tea.MouseWheelMsg) {502+ mouse := msg.Mouse()503+ delta := 0504+ switch mouse.Button {505+ case tea.MouseWheelDown:506+ delta = 1507+ case tea.MouseWheelUp:508+ delta = -1509+ default:510+ return511+ }512+ g := m.geometry()513+ if g.main.contains(mouse.X, mouse.Y) {514+ if delta > 0 {515+ m.vp.ScrollDown(scrollStep)516+ } else {517+ m.vp.ScrollUp(scrollStep)518+ }519+ return520+ }521+ for p := range panelID(numLists) {522+ if g.panels[p].contains(mouse.X, mouse.Y) {523+ m.setFocus(p)524+ m.scrollOrMove(delta)525+ return526+ }527+ }528+}529+530+func (m *Model) handleClick(msg tea.MouseClickMsg) {531+ mouse := msg.Mouse()532+ if mouse.Button != tea.MouseLeft {533+ return534+ }535+ g := m.geometry()536+ if g.main.contains(mouse.X, mouse.Y) {537+ m.setFocus(panelMain)538+ return539+ }540+ for p := range panelID(numLists) {541+ r := g.panels[p]542+ if !r.contains(mouse.X, mouse.Y) {543+ continue544+ }545+ row := mouse.Y - r.y - 1546+ if row >= 0 && row < r.h-2 {547+ if i := m.offsets[p] + row; i < m.count(p) {548+ m.cursors[p] = i549+ }550+ }551+ m.clampAll()552+ m.setFocus(p)553+ return554+ }555+}556+557+func (m *Model) editCmd() tea.Cmd {558+ t := m.target()559+ tab, ok := m.currentTab(t)560+ if !ok {561+ return nil562+ }563+ editor := os.Getenv("VISUAL")564+ if editor == "" {565+ editor = os.Getenv("EDITOR")566+ }567+ if editor == "" {568+ editor = "vi"569+ }570+ args := append(strings.Fields(editor), tab.path)571+ c := exec.Command(args[0], args[1:]...) //nolint:gosec // user-chosen editor572+ return tea.ExecProcess(c, func(err error) tea.Msg { return editorDoneMsg{err} })573+}574+575+// View renders the UI.576+func (m Model) View() tea.View {577+ v := tea.NewView(m.render())578+ v.AltScreen = true579+ v.MouseMode = tea.MouseModeCellMotion580+ v.WindowTitle = "lazyopenspec - " + filepath.Base(m.opts.Root)581+ return v582+}
A internal/ui/render.go +454 -0
@@ -0,0 +1,454 @@
1+package ui2+3+import (4+ "fmt"5+ "os"6+ "path/filepath"7+ "strings"8+ "time"9+10+ "charm.land/lipgloss/v2"11+ "github.com/charmbracelet/x/ansi"12+13+ "github.com/dami/lazyopenspec/internal/spec"14+)15+16+func (m Model) render() string {17+ if m.width <= 0 || m.height <= 0 {18+ return ""19+ }20+ g := m.geometry()21+ var cols []string22+ if !m.zoom {23+ panels := make([]string, 0, numLists)24+ for p := range panelID(numLists) {25+ panels = append(panels, m.renderPanel(p, g.panels[p]))26+ }27+ cols = append(cols, lipgloss.JoinVertical(lipgloss.Left, panels...))28+ }29+ cols = append(cols, m.renderMain(g.main))30+ body := lipgloss.JoinHorizontal(lipgloss.Top, cols...)31+32+ lines := strings.Split(m.renderHeader()+"\n"+body, "\n")33+ if len(lines) > m.height-footerRows {34+ lines = lines[:max(m.height-footerRows, 0)]35+ }36+ for len(lines) < m.height-footerRows {37+ lines = append(lines, "")38+ }39+ return strings.Join(append(lines, m.renderFooter()), "\n")40+}41+42+func (m Model) renderHeader() string {43+ th := m.theme44+ sep := th.subtle.Render(" │ ")45+ left := []string{th.title.Render(" lazyopenspec"), th.bold.Render(tildePath(m.opts.Root))}46+ if m.git.Branch != "" {47+ branch := " " + m.git.Branch48+ if n := len(m.git.Dirty); n > 0 {49+ branch += th.modSt.Render(fmt.Sprintf(" +%d uncommitted", n))50+ }51+ left = append(left, th.renSt.Render(branch))52+ }53+ if m.snap != nil && m.snap.Exists {54+ active, done := 0, 055+ for _, c := range m.snap.Changes {56+ active++57+ if c.Tasks.Complete() {58+ done++59+ }60+ }61+ stats := fmt.Sprintf("%d active", active)62+ if done > 0 {63+ stats += fmt.Sprintf(", %d ready to archive", done)64+ }65+ left = append(left, th.muted.Render(stats))66+ }67+68+ var right []string69+ if m.follow {70+ right = append(right, th.key.Render("FOLLOW"))71+ }72+ live := th.subtle.Render("●")73+ switch {74+ case m.loading:75+ live = th.modSt.Render("●")76+ case !m.lastSeen.IsZero() && m.now.Sub(m.lastSeen) < freshWindow:77+ live = th.freshSt.Render("●")78+ }79+ status := "watching"80+ if !m.lastSeen.IsZero() {81+ status = "last edit " + ago(m.now, m.lastSeen)82+ }83+ right = append(right, live+" "+th.muted.Render(status)+" ")84+ return spread(strings.Join(left, sep), strings.Join(right, sep), m.width)85+}86+87+func (m Model) renderFooter() string {88+ th := m.theme89+ if m.err != nil {90+ return th.remSt.Render(" error: " + m.err.Error())91+ }92+ hint := func(k, d string) string { return th.key.Render(k) + " " + th.keyDesc.Render(d) }93+ var hints []string94+ switch m.focus {95+ case panelMain:96+ hints = []string{hint("j/k", "scroll"), hint("[ ]", "tab"), hint("d", "diff"), hint("e", "edit"), hint("esc", "back")}97+ case panelActivity:98+ hints = []string{hint("j/k", "move"), hint("enter", "jump"), hint("c", "clear"), hint("[ ]", "tab")}99+ default:100+ hints = []string{hint("j/k", "move"), hint("enter", "read"), hint("[ ]", "tab"), hint("d", "diff"), hint("e", "edit")}101+ if m.focus == panelChanges {102+ hints = append(hints, hint("s", "sort"))103+ }104+ }105+ hints = append(hints, hint("f", "follow"), hint("z", "zoom"), hint("?", "help"), hint("q", "quit"))106+ return ansi.Truncate(" "+strings.Join(hints, " "), m.width, "…")107+}108+109+func (m Model) helpText(w int) string {110+ th := m.theme111+ rows := [][2]string{112+ {"tab / shift+tab", "cycle panels"},113+ {"1 2 3 4 / 0", "jump to Changes, Specs, Archive, Activity / main"},114+ {"j k / up down", "move selection, or scroll when main is focused"},115+ {"g G", "top / bottom"},116+ {"enter / l", "focus main pane (on Activity: jump to item)"},117+ {"esc / h", "back to list"},118+ {"J K / ctrl+d ctrl+u", "scroll main pane by half a page"},119+ {"[ ]", "previous / next file tab"},120+ {"d", "toggle git diff vs HEAD for the current file"},121+ {"e", "open the current file in $VISUAL / $EDITOR"},122+ {"f", "follow mode: jump to whatever the agents touch next"},123+ {"s", "sort changes by name / most recently edited"},124+ {"z", "zoom main pane"},125+ {"c", "clear activity log"},126+ {"r", "force reload"},127+ {"q", "quit"},128+ }129+ var sb strings.Builder130+ sb.WriteString("\n")131+ for _, r := range rows {132+ sb.WriteString(" " + th.key.Render(fmt.Sprintf("%-22s", r[0])) + th.keyDesc.Render(r[1]) + "\n")133+ }134+ sb.WriteString("\n " + th.muted.Render("Legend: ") +135+ th.freshSt.Render("●") + th.muted.Render(" edited <15s ") +136+ th.warmSt.Render("●") + th.muted.Render(" edited <2m ") +137+ th.modSt.Render("M") + th.muted.Render(" uncommitted ") +138+ th.muted.Render("P D S T = proposal, design, spec deltas, tasks"))139+ sb.WriteString("\n\n " + th.subtle.Render("press any key to close"))140+ return sb.String()141+}142+143+func (m Model) renderPanel(p panelID, r rect) string {144+ th := m.theme145+ focused := m.focus == p146+ n := m.count(p)147+ title := fmt.Sprintf("[%d] %s", p+1, panelTitles[p])148+ if p == panelChanges && m.sortRecent {149+ title += " (recent)"150+ }151+ if focused {152+ title = th.title.Render(title)153+ } else {154+ title = th.muted.Render(title)155+ }156+ footer := ""157+ if n > 0 {158+ footer = th.muted.Render(fmt.Sprintf("%d of %d", m.cursors[p]+1, n))159+ }160+161+ w, h := r.w-2, r.h-2162+ var rows []string163+ if n == 0 {164+ rows = append(rows, " "+th.subtle.Render(emptyText(p, m.snap)))165+ }166+ for i := m.offsets[p]; i < min(n, m.offsets[p]+h); i++ {167+ sel := i == m.cursors[p]168+ active := sel && (focused || (p == m.lastList && m.focus == panelMain))169+ rows = append(rows, m.renderRow(p, i, w, sel, active))170+ }171+ return box(th, title, footer, strings.Join(rows, "\n"), r.w, r.h, focused)172+}173+174+func emptyText(p panelID, snap *spec.Snapshot) string {175+ if snap == nil {176+ return "loading…"177+ }178+ switch p {179+ case panelChanges:180+ return "no active changes"181+ case panelSpecs:182+ return "no specs yet"183+ case panelArchive:184+ return "nothing archived"185+ default:186+ return "watching for edits…"187+ }188+}189+190+// painter renders styled cells, adding the selection background when the row191+// is selected so inner style resets don't punch holes in the highlight.192+type painter struct {193+ bg bool194+ th theme195+ w int196+ out strings.Builder197+}198+199+func (p *painter) put(st lipgloss.Style, s string) {200+ if p.bg {201+ st = st.Background(p.th.selBg)202+ }203+ p.out.WriteString(st.Render(s))204+}205+206+func (p *painter) String() string {207+ s := ansi.Truncate(p.out.String(), p.w, "…")208+ if pad := p.w - lipgloss.Width(s); pad > 0 {209+ st := lipgloss.NewStyle()210+ if p.bg {211+ st = st.Background(p.th.selBg)212+ }213+ s += st.Render(strings.Repeat(" ", pad))214+ }215+ return s216+}217+218+func (m Model) renderRow(p panelID, i, w int, sel, active bool) string {219+ th := m.theme220+ pt := &painter{bg: sel, th: th, w: w}221+ nameSt := lipgloss.NewStyle().Foreground(th.text)222+ if active {223+ nameSt = nameSt.Bold(true).Foreground(th.accent)224+ }225+ cursor := " "226+ if active {227+ cursor = "▌"228+ }229+ pt.put(lipgloss.NewStyle().Foreground(th.accent), cursor)230+231+ switch p {232+ case panelChanges, panelArchive:233+ var c spec.Change234+ if p == panelChanges {235+ c = m.changes()[i]236+ } else {237+ c = m.snap.Archive[i]238+ }239+ label, used := c.ID, 3240+ if p == panelArchive {241+ label = c.Name242+ if c.ArchivedOn != "" {243+ pt.put(th.muted, c.ArchivedOn[5:]+" ")244+ used += 6245+ }246+ }247+ pt.put(m.freshStyle(c.ModTime), m.freshDot(c.ModTime))248+ putLabeled(pt, w, used, label, nameSt, m.dirMark(c.Dir), m.dirMarkStyle(c.Dir), m.changeRowRight(c, w))249+250+ case panelSpecs:251+ s := m.snap.Specs[i]252+ pt.put(m.freshStyle(s.ModTime), m.freshDot(s.ModTime))253+ var right segments254+ if pending := len(m.pendingDeltas(s.ID)); pending > 0 {255+ right = append(right, segment{th.modSt, fmt.Sprintf("Δ%d ", pending)})256+ }257+ right = append(right, segment{th.muted, fmt.Sprintf("%3d req", s.Requirements)})258+ putLabeled(pt, w, 3, s.ID, nameSt, m.fileMark(s.Path), th.modSt, right)259+260+ case panelActivity:261+ e := m.events[i]262+ ts := th.muted263+ if m.now.Sub(e.Time) < freshWindow {264+ ts = th.freshSt265+ }266+ pt.put(ts, e.Time.Format("15:04:05")+" ")267+ icon, st := eventIcon(th, e.Kind)268+ pt.put(st, icon+" ")269+ idW := clamp(w/3, 8, 24)270+ pt.put(nameSt, fmt.Sprintf("%-*s", idW, ansi.Truncate(e.ID, idW, "…"))+" ")271+ pt.put(th.muted, e.Detail)272+ }273+ return pt.String()274+}275+276+// putLabeled writes a left-aligned label and right-aligned segments, with an277+// optional git mark just before the segments. used is the width already278+// written on the row.279+func putLabeled(pt *painter, w, used int, label string, nameSt lipgloss.Style, mark string, markSt lipgloss.Style, right segments) {280+ if mark != "" {281+ mark += " "282+ }283+ rightW := right.Width() + lipgloss.Width(mark)284+ label = ansi.Truncate(label, max(w-used-rightW-1, 1), "…")285+ pt.put(nameSt, label)286+ pt.put(lipgloss.NewStyle(), strings.Repeat(" ", max(w-used-lipgloss.Width(label)-rightW, 1)))287+ if mark != "" {288+ pt.put(markSt, mark)289+ }290+ for _, seg := range right {291+ pt.put(seg.st, seg.s)292+ }293+}294+295+type segment struct {296+ st lipgloss.Style297+ s string298+}299+300+// changeRowRight is the "PDST ▰▰▱ 3/5" block on a change row.301+func (m Model) changeRowRight(c spec.Change, w int) segments {302+ th := m.theme303+ var segs segments304+ for _, k := range []struct {305+ kind spec.ArtifactKind306+ letter string307+ }{{spec.KindProposal, "P"}, {spec.KindDesign, "D"}, {spec.KindDelta, "S"}, {spec.KindTasks, "T"}} {308+ st := th.subtle309+ if c.Has(k.kind) {310+ st = th.title311+ }312+ segs = append(segs, segment{st, k.letter})313+ }314+ if w >= 36 {315+ segs = append(segs, segment{lipgloss.NewStyle(), " "})316+ bar := 5317+ filled := 0318+ if c.Tasks.Total > 0 {319+ filled = c.Tasks.Done * bar / c.Tasks.Total320+ }321+ fill := th.title322+ if c.Tasks.Complete() {323+ fill = th.addSt324+ }325+ segs = append(segs, segment{fill, strings.Repeat("▰", filled)}, segment{th.subtle, strings.Repeat("▱", bar-filled)})326+ }327+ count := ""328+ if c.Tasks.Total > 0 {329+ count = fmt.Sprintf("%d/%d", c.Tasks.Done, c.Tasks.Total)330+ }331+ st := th.muted332+ if c.Tasks.Complete() {333+ st = th.addSt334+ }335+ segs = append(segs, segment{st, fmt.Sprintf(" %5s", count)})336+ return segs337+}338+339+type segments []segment340+341+func (s segments) Width() int {342+ n := 0343+ for _, seg := range s {344+ n += lipgloss.Width(seg.s)345+ }346+ return n347+}348+349+func eventIcon(th theme, k spec.EventKind) (string, lipgloss.Style) {350+ switch k {351+ case spec.EventCreated:352+ return "+", th.addSt353+ case spec.EventProgress:354+ return "▸", th.title355+ case spec.EventArchived:356+ return "✓", th.renSt357+ case spec.EventRemoved:358+ return "-", th.remSt359+ default:360+ return "~", th.modSt361+ }362+}363+364+func (m Model) freshDot(t time.Time) string {365+ if m.now.Sub(t) < warmWindow {366+ return "● "367+ }368+ return " "369+}370+371+func (m Model) freshStyle(t time.Time) lipgloss.Style {372+ if m.now.Sub(t) < freshWindow {373+ return m.theme.freshSt374+ }375+ return m.theme.warmSt376+}377+378+// dirMark summarizes git status for files under a change dir.379+func (m Model) dirMark(dir string) string {380+ prefix := dir + string(filepath.Separator)381+ untracked, modified := false, false382+ for path, code := range m.git.Dirty {383+ if !strings.HasPrefix(path, prefix) {384+ continue385+ }386+ if code == "??" {387+ untracked = true388+ } else {389+ modified = true390+ }391+ }392+ switch {393+ case modified:394+ return "M"395+ case untracked:396+ return "+"397+ }398+ return ""399+}400+401+func (m Model) dirMarkStyle(dir string) lipgloss.Style {402+ if m.dirMark(dir) == "+" {403+ return m.theme.addSt404+ }405+ return m.theme.modSt406+}407+408+func (m Model) fileMark(path string) string {409+ switch code, ok := m.git.Dirty[path]; {410+ case !ok:411+ return ""412+ case code == "??":413+ return "+"414+ default:415+ return "M"416+ }417+}418+419+// spread places left and right on one line of width w.420+func spread(left, right string, w int) string {421+ gap := w - lipgloss.Width(left) - lipgloss.Width(right)422+ if gap < 1 {423+ return ansi.Truncate(left, max(w-lipgloss.Width(right)-1, 0), "…") + " " + right424+ }425+ return left + strings.Repeat(" ", gap) + right426+}427+428+func ago(now, t time.Time) string {429+ if t.IsZero() {430+ return "never"431+ }432+ d := now.Sub(t)433+ switch {434+ case d < 2*time.Second:435+ return "just now"436+ case d < time.Minute:437+ return fmt.Sprintf("%ds ago", int(d.Seconds()))438+ case d < time.Hour:439+ return fmt.Sprintf("%dm ago", int(d.Minutes()))440+ case d < 48*time.Hour:441+ return fmt.Sprintf("%dh ago", int(d.Hours()))442+ default:443+ return t.Format("2006-01-02")444+ }445+}446+447+func tildePath(p string) string {448+ if home, err := os.UserHomeDir(); err == nil && home != "" {449+ if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {450+ return filepath.Join("~", rel)451+ }452+ }453+ return p454+}
A internal/ui/theme.go +69 -0
@@ -0,0 +1,69 @@
1+package ui2+3+import (4+ "image/color"5+6+ "charm.land/lipgloss/v2"7+)8+9+// theme holds every style the UI uses, resolved for a light or dark terminal.10+type theme struct {11+ isDark bool12+13+ accent, dim, faint, text color.Color14+ added, modified, removed, renamed color.Color15+ fresh, warm, selBg color.Color16+17+ title, muted, subtle, bold lipgloss.Style18+ sel, selDim lipgloss.Style19+ tabActive, tabInactive lipgloss.Style20+ key, keyDesc lipgloss.Style21+ addSt, modSt, remSt, renSt lipgloss.Style22+ freshSt, warmSt lipgloss.Style23+ diffAdd, diffDel, diffHunk lipgloss.Style24+}25+26+func newTheme(isDark bool) theme {27+ ld := lipgloss.LightDark(isDark)28+ t := theme{29+ isDark: isDark,30+ accent: ld(lipgloss.Color("#007F5F"), lipgloss.Color("#5FD7AF")),31+ dim: ld(lipgloss.Color("#8A8A8A"), lipgloss.Color("#6C6C6C")),32+ faint: ld(lipgloss.Color("#BCBCBC"), lipgloss.Color("#3A3A3A")),33+ text: ld(lipgloss.Color("#1C1C1C"), lipgloss.Color("#E4E4E4")),34+ added: ld(lipgloss.Color("#2E7D32"), lipgloss.Color("#87D75F")),35+ modified: ld(lipgloss.Color("#B26A00"), lipgloss.Color("#FFD75F")),36+ removed: ld(lipgloss.Color("#C62828"), lipgloss.Color("#FF5F87")),37+ renamed: ld(lipgloss.Color("#1565C0"), lipgloss.Color("#5FAFFF")),38+ fresh: ld(lipgloss.Color("#00A86B"), lipgloss.Color("#5FFF87")),39+ warm: ld(lipgloss.Color("#7FBF9F"), lipgloss.Color("#3F8F5F")),40+ selBg: ld(lipgloss.Color("#E0E8E4"), lipgloss.Color("#26323A")),41+ }42+ t.title = lipgloss.NewStyle().Foreground(t.accent).Bold(true)43+ t.muted = lipgloss.NewStyle().Foreground(t.dim)44+ t.subtle = lipgloss.NewStyle().Foreground(t.faint)45+ t.bold = lipgloss.NewStyle().Foreground(t.text).Bold(true)46+ t.sel = lipgloss.NewStyle().Background(t.selBg).Foreground(t.text).Bold(true)47+ t.selDim = lipgloss.NewStyle().Background(t.selBg)48+ t.tabActive = lipgloss.NewStyle().Foreground(t.accent).Bold(true).Underline(true)49+ t.tabInactive = lipgloss.NewStyle().Foreground(t.dim)50+ t.key = lipgloss.NewStyle().Foreground(t.accent).Bold(true)51+ t.keyDesc = lipgloss.NewStyle().Foreground(t.dim)52+ t.addSt = lipgloss.NewStyle().Foreground(t.added)53+ t.modSt = lipgloss.NewStyle().Foreground(t.modified)54+ t.remSt = lipgloss.NewStyle().Foreground(t.removed)55+ t.renSt = lipgloss.NewStyle().Foreground(t.renamed)56+ t.freshSt = lipgloss.NewStyle().Foreground(t.fresh).Bold(true)57+ t.warmSt = lipgloss.NewStyle().Foreground(t.warm)58+ t.diffAdd = lipgloss.NewStyle().Foreground(t.added)59+ t.diffDel = lipgloss.NewStyle().Foreground(t.removed)60+ t.diffHunk = lipgloss.NewStyle().Foreground(t.renamed)61+ return t62+}63+64+func (t theme) glamourStyle() string {65+ if t.isDark {66+ return "dark"67+ }68+ return "light"69+}
A main.go +49 -0
A testdata/fixture/openspec/changes/add-csv-export-filters/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/add-csv-export-filters/proposal.md +22 -0
A testdata/fixture/openspec/changes/add-csv-export-filters/specs/data-export/spec.md +38 -0
@@ -0,0 +1,38 @@
1+## ADDED Requirements2+3+### Requirement: Export filters4+The CSV export SHALL accept optional `status`, `assignee`, and `updated_since` filters, and MUST include only tasks matching every supplied filter.5+6+#### Scenario: Filter by status and assignee7+- **WHEN** an owner exports with `status=done&assignee=bob`8+- **THEN** the CSV contains only tasks that are `done` and assigned to Bob9+10+#### Scenario: Filtered file name11+- **WHEN** an owner exports project "website" with `status=done`12+- **THEN** the downloaded file is named `website-tasks-status-done.csv`13+14+#### Scenario: Invalid date filter15+- **WHEN** an owner exports with `updated_since=yesterday`16+- **THEN** the system responds with a 422 validation error naming the `updated_since` parameter17+18+## MODIFIED Requirements19+20+### Requirement: CSV export21+The system SHALL export a project's tasks as an RFC 4180 CSV file with a header row. By default the columns are `id`, `title`, `status`, `assignee`, `created_at`, and `updated_at`; when a `columns` parameter is supplied the system SHALL emit exactly those columns in the given order.22+23+#### Scenario: Export a project24+- **WHEN** a project owner requests a CSV export of a project with 3 tasks25+- **THEN** the system returns a CSV file with a header row and 3 data rows26+- **AND** the response has content type `text/csv; charset=utf-8`27+28+#### Scenario: Escape commas and quotes29+- **WHEN** a task title contains a comma or a double quote30+- **THEN** the field is wrapped in double quotes and embedded quotes are doubled31+32+#### Scenario: Custom column selection33+- **WHEN** an owner exports with `columns=title,status`34+- **THEN** the CSV header row is exactly `title,status` and each row has two fields35+36+#### Scenario: Unknown column37+- **WHEN** an owner exports with `columns=title,priority`38+- **THEN** the system responds with a 422 validation error naming `priority`
A testdata/fixture/openspec/changes/add-csv-export-filters/tasks.md +16 -0
A testdata/fixture/openspec/changes/add-due-dates/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/add-due-dates/README.md +3 -0
A testdata/fixture/openspec/changes/add-due-dates/proposal.md +24 -0
A testdata/fixture/openspec/changes/add-task-search/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/add-task-search/proposal.md +25 -0
A testdata/fixture/openspec/changes/add-task-search/specs/search/spec.md +27 -0
A testdata/fixture/openspec/changes/add-task-search/specs/task-management/spec.md +21 -0
A testdata/fixture/openspec/changes/add-workspace-invites/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/design.md +26 -0
A testdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/proposal.md +25 -0
A testdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/specs/user-auth/spec.md +44 -0
@@ -0,0 +1,44 @@
1+## ADDED Requirements2+3+### Requirement: Password login4+The system SHALL authenticate users with an email address and password, storing only argon2id password hashes.5+6+#### Scenario: Valid credentials7+- **WHEN** a user submits a registered email and the correct password8+- **THEN** the system creates a session and sets an `HttpOnly`, `Secure` session cookie9+- **AND** redirects the user to their last visited project10+11+#### Scenario: Invalid credentials12+- **WHEN** a user submits an unknown email or a wrong password13+- **THEN** the system responds with the generic message "Invalid email or password"14+- **AND** does not reveal which field was wrong15+16+### Requirement: Session expiry17+Sessions SHALL expire after 30 days without activity, after which the user MUST log in again.18+19+#### Scenario: Expired session20+- **WHEN** a request arrives with a session cookie last used more than 30 days ago21+- **THEN** the system deletes the session and redirects to the login page22+23+#### Scenario: Activity extends session24+- **WHEN** a request arrives with a session last used 29 days ago25+- **THEN** the request succeeds and the session's last-used time is updated26+27+### Requirement: Remember me28+The login form SHALL offer a "Remember me" option that extends the session inactivity timeout to 90 days.29+30+#### Scenario: Remember me selected31+- **WHEN** a user logs in with "Remember me" checked32+- **THEN** the session inactivity timeout is 90 days instead of 3033+34+### Requirement: Account lockout35+The system MUST lock an account for 15 minutes after 5 consecutive failed login attempts.36+37+#### Scenario: Fifth failed attempt38+- **WHEN** a user fails to log in 5 times in a row39+- **THEN** further login attempts for that account are rejected for 15 minutes40+- **AND** the response is indistinguishable from an invalid-credentials response41+42+#### Scenario: Successful login resets counter43+- **WHEN** a user fails to log in 3 times and then logs in successfully44+- **THEN** the failed-attempt counter resets to zero
A testdata/fixture/openspec/changes/archive/2026-08-19-add-user-auth/tasks.md +18 -0
A testdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/proposal.md +25 -0
A testdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/specs/notifications/spec.md +23 -0
A testdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/specs/task-management/spec.md +19 -0
A testdata/fixture/openspec/changes/archive/2026-09-23-add-email-notifications/tasks.md +15 -0
A testdata/fixture/openspec/changes/harden-session-security/.openspec.yaml +2 -0
A testdata/fixture/openspec/changes/harden-session-security/design.md +39 -0
@@ -0,0 +1,39 @@
1+## Context2+3+Sessions are opaque ids stored in Postgres (`sessions` table, see add-user-auth). Expiry is enforced only by `last_used_at + timeout`. There is no absolute cap and no user-facing session management. The pentest report (PT-2026-09, finding H-2) recommends idle <= 12h and absolute <= 7d.4+5+## Goals / Non-Goals6+7+**Goals:**8+- Meet the pentest recommendation for idle and absolute session lifetime9+- Give users self-service revocation of other sessions10+- Ship without forcing everyone to log in at deploy time11+12+**Non-Goals:**13+- Device fingerprinting or anomaly detection14+- Changing the session id format or cookie attributes15+- Admin-initiated revocation of other users' sessions (follow-up)16+17+## Decisions18+19+- **Store `absolute_expires_at` instead of deriving from `created_at`**: lets us backfill existing sessions to `now() + 7d` so nobody is logged out on deploy, and leaves room for per-org policies later. Alternative: compute from `created_at` - rejected because it would instantly expire most existing sessions.20+- **Revocation deletes the row**: simpler than a `revoked_at` tombstone; audit log already records the event.21+- **Drop `remember_me` in two steps**: stop reading it now, drop the column next release, so a rollback does not need a down-migration.22+23+## Risks / Trade-offs24+25+- [More frequent logins annoy users] → Ship with in-app notice; measure login volume for two weeks.26+- [Backfill touches every session row] → Run in batches of 5,000 during low traffic.27+- [Clock skew between app servers] → All comparisons use database `now()`.28+29+## Migration Plan30+31+1. Deploy migration 0042 (add `absolute_expires_at`, nullable) and backfill.32+2. Deploy middleware enforcing both timeouts; treat NULL as `created_at + 7d`.33+3. Next release: make the column NOT NULL and drop `remember_me`.34+35+Rollback: revert middleware deploy; the extra column is harmless.36+37+## Open Questions38+39+- Should revoking the current session from the sessions page log the user out immediately, or require confirmation?
A testdata/fixture/openspec/changes/harden-session-security/proposal.md +25 -0
A testdata/fixture/openspec/changes/harden-session-security/specs/user-auth/spec.md +41 -0
@@ -0,0 +1,41 @@
1+## ADDED Requirements2+3+### Requirement: Session revocation4+Users SHALL be able to list their active sessions and revoke any of them, and a revoked session MUST be rejected on its next request.5+6+#### Scenario: Revoke another device7+- **WHEN** a user revokes a session belonging to another device from the Active sessions page8+- **THEN** the next request using that session is redirected to the login page9+10+#### Scenario: Session list contents11+- **WHEN** a user opens the Active sessions page12+- **THEN** each session shows its creation time, last-used time, and user agent13+- **AND** the current session is labelled "This device"14+15+## MODIFIED Requirements16+17+### Requirement: Session expiry18+Sessions SHALL expire after 12 hours without activity and MUST expire no later than 7 days after creation regardless of activity.19+20+#### Scenario: Idle timeout21+- **WHEN** a request arrives with a session last used more than 12 hours ago22+- **THEN** the system deletes the session and redirects to the login page23+24+#### Scenario: Activity extends idle window25+- **WHEN** a request arrives with a session last used 11 hours ago26+- **THEN** the request succeeds and the session's last-used time is updated27+28+#### Scenario: Absolute lifetime reached29+- **WHEN** a request arrives with a session created more than 7 days ago, even if it was used within the last hour30+- **THEN** the system deletes the session and redirects to the login page31+32+## REMOVED Requirements33+34+### Requirement: Remember me35+**Reason**: Long-lived sessions were flagged as high risk in pentest PT-2026-09 (finding H-2).36+**Migration**: None for users; all sessions now follow the standard expiry rules. The `remember_me` column is dropped in the following release.37+38+## RENAMED Requirements39+40+- FROM: `### Requirement: Password login`41+- TO: `### Requirement: Password authentication`
A testdata/fixture/openspec/changes/harden-session-security/tasks.md +23 -0
A testdata/fixture/openspec/config.yaml +20 -0
A testdata/fixture/openspec/specs/data-export/spec.md +34 -0
@@ -0,0 +1,34 @@
1+# Data Export Specification2+3+## Purpose4+5+Lets project owners take their data out of Tally in open formats, either for reporting in spreadsheets or for migrating to another tool. Exports always reflect the project's tasks at the moment the export is requested.6+7+## Requirements8+9+### Requirement: CSV export10+The system SHALL export all of a project's tasks as an RFC 4180 CSV file with a header row containing `id`, `title`, `status`, `assignee`, `created_at`, and `updated_at`.11+12+#### Scenario: Export a project13+- **WHEN** a project owner requests a CSV export of a project with 3 tasks14+- **THEN** the system returns a CSV file with a header row and 3 data rows15+- **AND** the response has content type `text/csv; charset=utf-8`16+17+#### Scenario: Escape commas and quotes18+- **WHEN** a task title contains a comma or a double quote19+- **THEN** the field is wrapped in double quotes and embedded quotes are doubled20+21+### Requirement: JSON export22+The system SHALL export a project's tasks as a JSON array where each element contains every task field, including the full description.23+24+#### Scenario: Export as JSON25+- **WHEN** a project owner requests a JSON export26+- **THEN** the system returns `application/json` containing an array of task objects27+28+### Requirement: Export authorization29+Only project owners MUST be able to request an export of a project's data.30+31+#### Scenario: Non-owner attempts export32+- **WHEN** a project member who is not an owner requests an export33+- **THEN** the system rejects the request with a 403 error34+- **AND** no export file is generated
A testdata/fixture/openspec/specs/notifications/spec.md +27 -0
A testdata/fixture/openspec/specs/task-management/spec.md +66 -0
@@ -0,0 +1,66 @@
1+# Task Management Specification2+3+## Purpose4+5+Defines how Tally members create, update, assign, and list tasks within a project. Tasks are the core unit of work in Tally; every other capability (notifications, export, search) builds on the task model described here.6+## Requirements7+### Requirement: Task creation8+The system SHALL allow an authenticated project member to create a task with a title of 1-200 characters and an optional Markdown description of up to 10,000 characters.9+10+#### Scenario: Create task with title only11+- **WHEN** a member submits a new task with title "Write release notes" and no description12+- **THEN** the system creates the task with status `todo`13+- **AND** returns the task with a generated id and `created_at` timestamp14+15+#### Scenario: Reject empty title16+- **WHEN** a member submits a new task with an empty or whitespace-only title17+- **THEN** the system rejects the request with a 422 validation error18+- **AND** no task is created19+20+### Requirement: Task status transitions21+Tasks SHALL move through the statuses `todo`, `in_progress`, and `done`, and the system MUST reject any transition to a status outside that set.22+23+#### Scenario: Complete a task24+- **WHEN** a member sets the status of an `in_progress` task to `done`25+- **THEN** the task status becomes `done`26+- **AND** `completed_at` is set to the current time27+28+#### Scenario: Reopen a completed task29+- **WHEN** a member sets the status of a `done` task to `todo`30+- **THEN** the task status becomes `todo`31+- **AND** `completed_at` is cleared32+33+#### Scenario: Unknown status34+- **WHEN** a member sets a task status to `blocked`35+- **THEN** the system rejects the request with a 422 validation error36+37+### Requirement: Task assignment38+A task SHALL have at most one assignee, and the assignee MUST be a member of the task's project. Changing the assignee SHALL emit an assignment event consumed by the notifications capability.39+40+#### Scenario: Assign to project member41+- **WHEN** a member assigns a task to another member of the same project42+- **THEN** the task's assignee is updated43+- **AND** an assignment event is recorded for the new assignee44+45+#### Scenario: Assign to non-member46+- **WHEN** a member assigns a task to a user who is not in the project47+- **THEN** the system rejects the request with a 403 error48+- **AND** the existing assignee is unchanged49+50+#### Scenario: Unassign a task51+- **WHEN** a member clears the assignee of a task52+- **THEN** the task has no assignee53+- **AND** no assignment event is recorded54+55+### Requirement: Task listing56+The system SHALL list a project's tasks ordered by most recently updated first, paginated at 50 tasks per page.57+58+#### Scenario: First page of tasks59+- **WHEN** a member requests the task list for a project with 120 tasks60+- **THEN** the system returns the 50 most recently updated tasks61+- **AND** includes a `next_cursor` for the following page62+63+#### Scenario: Empty project64+- **WHEN** a member requests the task list for a project with no tasks65+- **THEN** the system returns an empty list and no `next_cursor`66+
A testdata/fixture/openspec/specs/user-auth/spec.md +48 -0
@@ -0,0 +1,48 @@
1+# user-auth Specification2+3+## Purpose4+Covers how people sign in to Tally, how long their sessions last, and how the system protects accounts from password guessing.5+## Requirements6+### Requirement: Password login7+The system SHALL authenticate users with an email address and password, storing only argon2id password hashes.8+9+#### Scenario: Valid credentials10+- **WHEN** a user submits a registered email and the correct password11+- **THEN** the system creates a session and sets an `HttpOnly`, `Secure` session cookie12+- **AND** redirects the user to their last visited project13+14+#### Scenario: Invalid credentials15+- **WHEN** a user submits an unknown email or a wrong password16+- **THEN** the system responds with the generic message "Invalid email or password"17+- **AND** does not reveal which field was wrong18+19+### Requirement: Session expiry20+Sessions SHALL expire after 30 days without activity, after which the user MUST log in again.21+22+#### Scenario: Expired session23+- **WHEN** a request arrives with a session cookie last used more than 30 days ago24+- **THEN** the system deletes the session and redirects to the login page25+26+#### Scenario: Activity extends session27+- **WHEN** a request arrives with a session last used 29 days ago28+- **THEN** the request succeeds and the session's last-used time is updated29+30+### Requirement: Remember me31+The login form SHALL offer a "Remember me" option that extends the session inactivity timeout to 90 days.32+33+#### Scenario: Remember me selected34+- **WHEN** a user logs in with "Remember me" checked35+- **THEN** the session inactivity timeout is 90 days instead of 3036+37+### Requirement: Account lockout38+The system MUST lock an account for 15 minutes after 5 consecutive failed login attempts.39+40+#### Scenario: Fifth failed attempt41+- **WHEN** a user fails to log in 5 times in a row42+- **THEN** further login attempts for that account are rejected for 15 minutes43+- **AND** the response is indistinguishable from an invalid-credentials response44+45+#### Scenario: Successful login resets counter46+- **WHEN** a user fails to log in 3 times and then logs in successfully47+- **THEN** the failed-attempt counter resets to zero48+