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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,23 @@ jobs:
uv venv --seed .import-check
.import-check/bin/python -m pip install --quiet dist/*.whl
.import-check/bin/python -c "import openspec_ui; from openspec_ui.main import main; assert callable(main); print('import OK')"

# The stylesheet is package data, not a module, so nothing above would
# catch it going missing from the wheel — the app would install fine and
# then fail at startup with no CSS. Resolve it the way Textual does, from
# the installed package rather than from the source checkout.
- name: Stylesheet ships in the wheel
run: |
.import-check/bin/python - <<'PY'
from importlib.resources import files

from openspec_ui.app import OpenSpecUIApp

stylesheet = files("openspec_ui").joinpath(OpenSpecUIApp.CSS_PATH)
if not stylesheet.is_file():
raise SystemExit(f"::error::{OpenSpecUIApp.CSS_PATH} missing from the wheel")
text = stylesheet.read_text(encoding="utf-8")
if "StatusBar" not in text:
raise SystemExit("::error::stylesheet is present but looks truncated")
print(f"stylesheet OK ({len(text)} bytes)")
PY
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,57 @@ uvx openspec-ui
Run it from inside any directory containing an `openspec/` root (or a
subdirectory of one). Requires the `openspec` CLI on PATH.

Requires Python 3.10+ and [Textual](https://textual.textualize.io/) 1.0 or
newer (installed automatically).

## What it does

- **Dashboard** — lists changes (draft / in-progress / completed / archived) with
task progress bars, and specs with requirement counts.
- **Kanban** — a pipeline view of changes by stage, for at-a-glance status.
- **Detail view** — renders a change's artifacts (`proposal.md`, `design.md`,
`specs/**`, `tasks.md`) read-only as markdown (ASCII diagrams and code
blocks stay aligned in monospace). Switch artifacts with arrow keys.
- **Dashboard** — an aligned table of changes (stage, name, progress, tasks,
last modified) over headline counts of specs and of changes per stage.
Specs are listed with their requirement counts.
- **Kanban** — a pipeline view of changes by stage, each column and card
carrying that stage's colour, for at-a-glance status.
- **Change detail** — renders a change's artifacts (`proposal.md`,
`design.md`, `specs/**`) as scrollable markdown with a navigable table of
contents; ASCII diagrams and code blocks stay aligned in monospace. When an
artifact resolves to several files, each gets its own tab.
- **Tasks** — a change's `tasks.md` rendered as its checklist, one section per
heading with a done/total count. Read-only; edits go through `$EDITOR`.
- **Spec browser** — open a spec to read its overview and requirements, each
expandable to reveal its scenarios.
- **Filter** — press `/` to narrow the changes and specs by name.
- **Edit** — press `e` to open the current artifact in `$EDITOR`; the view
reloads from disk when the editor exits.
- **New change** — press `n` to create a change via `openspec new change`.
- **Command palette** — `Ctrl+P` for actions.
- **Command palette** — `Ctrl+P` for fuzzy-searchable actions, including
jumping straight to any change or spec by name, and switching theme.
- **Live refresh** — open views re-read from disk as things change on disk;
press `p` to pause.

### Keys

Press `?` at any time for the bindings available on the current screen.

| Key | Does |
| --- | --- |
| `?` | Show key bindings for the current screen |
| `Ctrl+P` | Command palette (actions, jump to change/spec, theme) |
| `/` | Filter the current listing by name (`escape` clears) |
| `enter` | Open the selected change or spec |
| `k` / `d` | Kanban / dashboard |
| `n` | New change |
| `e` | Edit the current artifact in `$EDITOR` |
| `r` | Refresh now |
| `p` | Pause or resume live refresh |
| `t` | Toggle the table of contents (change detail) |
| `q` | Quit |

### Theming

`openspec-ui` ships no palette of its own — all colour comes from the active
Textual theme, so it follows your terminal. Switch themes in-session from the
command palette (`Ctrl+P` → *Theme*). Nothing is written to disk: the app
persists no state, theme included.

## Design

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/revamp-tui-experience/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
152 changes: 152 additions & 0 deletions openspec/changes/revamp-tui-experience/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
## Context

See `proposal.md` — Why. The constraints that shape this design:

- **Thin client, no owned state** (existing `openspec-tui` requirement). Nothing may be persisted outside the openspec root. That rules out a config file for theme, filter, or layout preferences: all such state is session-only, held on the `App` instance, and lost on exit.
- **Styling is currently inline.** `App.CSS`, `CommandPaletteScreen.CSS` and `NewChangeScreen.CSS` each carry their own string. There is no shared token vocabulary, so `$primary-background` is reused for every kanban column heading and stage carries no colour.
- **All reads are synchronous subprocess calls.** `OpenSpecClient` shells out to `openspec ... --json` on the UI thread. It also carries two mutable caches (`_root_cache`, `_mtime_cache`) that the live-refresh poller mutates, so it is not safe to call concurrently from several threads.
- **The CLI already exposes more than the app reads.** `openspec show <spec> --json --type spec` returns `overview`, `requirementCount`, and `requirements[].{text, scenarios[].rawText}` — everything the spec browser needs. `tasks.md` is a plain markdown checklist on disk.
- **Textual 8.2.x is what is installed**; the declared floor is `textual>=0.86.0`. Themes, `Provider`-based command palette, `MarkdownViewer`, `Collapsible`, `Digits`, `Widget.loading` and `App.action_show_help_panel` are all available in the installed version.

## Goals / Non-Goals

**Goals:**

- One styling vocabulary, expressed once, in one file, in semantic tokens.
- Stage colour that means the same thing everywhere it appears.
- The interface never blocks on a subprocess, and says so when it is waiting.
- Specs and tasks become navigable content rather than counts.
- Net removal of hand-rolled code where Textual already ships the behaviour (command palette, markdown viewing, notifications, help).

**Non-Goals:**

- No branded palette. The application contributes zero colour values; colour comes from whichever built-in theme is active, and the user switches theme in-session through the palette's own theme command.
- No mouse-first redesign. Keyboard remains the primary input; mouse support is whatever the widgets give for free.
- No write path beyond what exists (`openspec new change` and `$EDITOR`). The tasks view is read-only; toggling a checkbox in-app is explicitly out of scope.
- No caching layer or local index to make the new views fast. Reads stay direct.

## Decisions

### Styling: one external `app.tcss`, semantic tokens only

All three inline `CSS` strings collapse into `openspec_ui/app.tcss`, loaded with `CSS_PATH`. Rules reference only Textual's theme-provided variables (`$surface`, `$panel`, `$boost`, `$accent`, `$success`, `$warning`, `$error`, `$foreground`, `$text-muted`) plus opacity modifiers. No hex literals, no `Color(...)` calls.

*Why:* a stylesheet is the artifact a Textual app is meant to have — editable without touching Python, diffable, and reloadable with Textual's `--dev` live CSS. Restricting to semantic tokens is what makes "follow the terminal's theme" true rather than aspirational: any built-in theme, including the ANSI ones that map to the terminal's own 16 colours, renders correctly because nothing assumes a value the theme does not define.

*Alternative considered:* keep inline CSS and just add colours. Rejected — it preserves the duplication that caused the drift, and per-screen strings cannot share tokens.

*Packaging consequence:* `app.tcss` is a non-Python file inside the package. The wheel build must include it, and a smoke check must assert the file resolves at runtime from an installed wheel (not just from a source checkout), because a missing stylesheet fails at app start.

### Stage colour maps onto semantic roles, not chosen colours

| Stage | Token | Reading |
|---|---|---|
| Draft | `$text-muted` | not started |
| In Progress | `$accent` | the live one |
| Completed | `$success` | done |
| Archived | `$foreground 40%` | receded |

A single `STAGE_STYLE` mapping in one module feeds the kanban column heading, the card's left border, and the dashboard's stage badge.

*Why:* the mapping is semantic, so it survives a theme change and reads correctly in light and dark. Defining it once is what makes the "same colour everywhere" requirement mechanically true instead of a convention three call sites must remember.

*Alternative considered:* four fixed colours (e.g. blue/amber/green/grey). Rejected — it breaks under ANSI themes and light backgrounds, and it is exactly the hard-coded palette the theming decision excludes.

### Dashboard listing becomes a `DataTable`

The change list moves from `ListView` of pre-formatted `Label` strings to a `DataTable` with columns: stage badge, name, progress bar, tasks, modified. Row keys are change names, so selection maps back to a change without parsing a label. Progress renders as block glyphs (`█`/`░`) coloured by stage; the existing `progress_bar()` helper survives, restyled.

*Why:* alignment is the whole point — a `ListView` of strings cannot line up columns when names differ in length, which is the single biggest reason the current dashboard reads as unstructured. `DataTable` also brings row cursor, scrolling and zebra striping for free.

*Alternatives considered:* `ListView` with a custom compose-based card widget (prettier per row, but re-introduces manual alignment and costs one widget per row); `OptionList` (single-column, same alignment problem).

*Trade-off:* `DataTable` renders Rich renderables, not widgets, so a row cannot host a live sub-widget later. Acceptable — rows are static content refreshed wholesale on reload.

The spec list stays a `ListView` — it is two fields, and its rows become the entry point to the spec browser.

### Headline counts via `Digits`, auto-hidden on short terminals

A horizontal row of `Digits` (specs / draft / active / completed / archived) replaces the dim one-line summary, and the row is hidden when the terminal is shorter than a threshold (~24 rows) so the listing keeps its space.

*Why:* the counts are the first thing a dashboard should answer, and `Digits` is the widget Textual ships for exactly this. The height guard is because the row costs three rows plus labels, which is a real cost in a small pane.

### Command palette: delete `palette.py`, register `Provider`s

`CommandPaletteScreen` is removed. Two providers are registered in `App.COMMANDS`:

- a static provider for the global actions (dashboard, kanban, new change, refresh, quit), and
- a dynamic provider that enumerates changes and specs so the user can jump to one by name.

Both implement `search()` (fuzzy-matched hits) and `discover()` (what shows with no input typed).

*Why:* the built-in palette is fuzzy-matched, already bound to `ctrl+p`, and already carries the system commands — including the theme switcher, which is what makes in-session theme changes work without the app owning any theme code. Roughly 66 lines of bespoke modal disappear.

*Trade-off:* the dynamic provider needs the change/spec list, which is a subprocess call. Provider `search()` is async and runs off the render path, so the call is made there rather than cached on the app — consistent with the thin-client invariant.

### Reads move to thread workers, serialised by one exclusive group

Each screen's `reload()` becomes a `@work(thread=True, exclusive=True, group="openspec-read")` method that calls the client, then hands results back to the UI thread to render. The target widget's `loading` flag is set while in flight.

*Why:* `OpenSpecClient` mutates `_root_cache` and `_mtime_cache`, so concurrent calls from several workers would race the poller's change detection. A single exclusive group means at most one read runs at a time, which removes the race without introducing a lock — and it also makes rapid `r` presses collapse to the latest read instead of queueing.

*Alternative considered:* a `threading.Lock` inside the client. Rejected — it serialises but still lets stale results land out of order; `exclusive=True` cancels the superseded worker instead.

*Interaction with live refresh:* the 1-second `_tick` keeps its cheap stat-only `has_changed()` probe on the UI thread (it does no subprocess work); only the reload it triggers goes to the worker. The existing "skip while in `$EDITOR` / not the top screen" guards are unchanged.

### Detail panes use `MarkdownViewer`; multi-file groups get nested tabs

Single-artifact panes render with `MarkdownViewer(show_table_of_contents=True)`. An artifact group resolving to several files becomes a nested `TabbedContent`, one `MarkdownViewer` per file, labelled by filename.

*Why:* this deletes the "Static can't compose several Markdown documents cleanly" compromise that the current code documents in its own docstring, and it upgrades every pane with independent scrolling, syntax-highlighted fences and a TOC. The monospace/ASCII-preservation requirement is unaffected — fenced and indented blocks still render monospace.

*Trade-off:* the TOC panel costs horizontal space. It is hidden below a width threshold (~100 columns) and toggleable, rather than always on.

### Spec browser: a screen of `Collapsible` requirements

A new `SpecDetailScreen` reads `client.show_spec(name)` and renders the overview, then one `Collapsible` per requirement titled with the requirement text, containing its scenarios' `rawText` rendered as markdown.

*Why:* `Collapsible` is the right primitive for "list of things, each with detail you usually don't want" — a spec with nine requirements and thirty scenarios is unreadable flat. Reading through `openspec show --json` rather than parsing `spec.md` keeps the app on the CLI's contract, consistent with the thin-client invariant.

### Tasks pane: parse the checklist, fall back to markdown

`OpenSpecClient` gains a small `tasks.md` parser producing `[Section(title, tasks=[Task(text, done)])]` from `## <heading>` and `- [ ] / - [x]` lines. The Tasks pane renders one `Collapsible` per section titled `<heading> <done>/<total>`, listing tasks with a completed indicator styled via the stage tokens.

*Why:* a change's tasks are the most-consulted artifact during implementation, and the current rendering (raw markdown, or an `x/y` string on a card) serves neither reading nor scanning. Parsing is cheap and file-local — no subprocess.

*Fallback:* if a `tasks.md` yields zero checkbox items, the pane renders the file as markdown instead. This keeps the pane correct for hand-written or non-conforming task files rather than showing a misleading empty checklist.

*Rejected:* making checkboxes interactive. It would make the app a writer of openspec content outside the CLI/`$EDITOR` path, which the thin-client requirement forbids.

### Filter is a client-side narrowing of already-loaded data

`/` reveals and focuses an `Input`; typing narrows the rendered rows by case-insensitive substring match against the name. `escape` clears it and restores the full listing. The filter string lives on the screen instance and is never persisted or re-queried.

*Why:* filtering derived, already-in-memory data costs nothing and cannot desynchronise from disk — the next live-refresh reload re-applies the current filter to fresh data. Re-querying the CLI per keystroke would be both slow and pointless, since the CLI has no name filter.

### Help panel and status bar

`?` binds to Textual's built-in `action_show_help_panel()`, which already lists the active bindings for the focused screen — no bespoke help screen. A small `StatusBar` widget (root path + live/paused indicator) is docked above the `Footer` and mounted by every screen, taking over the job the ad-hoc `#title` `Static` was doing for state, while `App.notify()` toasts take over the job it was doing for messages. Screen titles go back to naming the screen.

### Dependency floor

`textual>=0.86.0` rises to `textual>=1.0.0`. The features relied on (themes and semantic tokens, `Provider` discovery hits, `Widget.loading`, `action_show_help_panel`, `Digits`) landed across the 0.8x series; pinning to the 1.x line avoids asserting an exact minor for each and matches what is actually tested (8.2.x).

## Risks / Trade-offs

- **The dashboard rewrite changes a well-worn interaction surface** → keep every existing binding's meaning (`enter` opens, `k`/`d` navigate, `r` refresh, `p` live, `n` new); only the rendering changes. Verify `enter` on a `DataTable` row still opens the right change via row keys, not row indices.
- **`app.tcss` missing from the wheel breaks startup entirely** → add an explicit build-artifact check plus a test that loads the stylesheet through `importlib.resources` so a packaging regression fails in CI rather than for a `uvx` user.
- **Worker refactor could let a stale result overwrite a newer render** → the single exclusive worker group cancels superseded reads; renders happen only on the UI thread from the winning worker.
- **`Digits`, `Collapsible` and the TOC each consume space** → each is behind a size guard (stat row hidden under ~24 rows, TOC hidden under ~100 columns); the app must stay usable in an 80×24 terminal, which is the size to test against.
- **The `tasks.md` parser is a heuristic over free-form markdown** → the markdown fallback means a parse that finds nothing degrades to today's behaviour rather than to an empty screen.
- **Scope is wide (every screen, plus three new surfaces)** → the task breakdown sequences it so the foundation (stylesheet, tokens, status bar, notifications, workers) lands before the new views, and each phase leaves the app runnable.

## Migration Plan

No data or config migration — the app persists nothing, so there is no stored state to convert and no backward compatibility surface. Delivery follows the project's convention: a `feat/tui-experience` branch, incremental conventional commits, one pull request referencing this change.

Rollback is reverting the branch. The one externally visible contract change is the `textual>=1.0.0` floor, which affects resolution for users on a pinned older Textual; it is called out in the release notes and the README key-binding table is updated for `?` and `/`.

## Open Questions

- Should the filter also match spec requirement text, not just names? Name matching ships first; extending the predicate later changes no requirement, no screen structure, and no task boundary.
- Should archived changes be hidden from the dashboard behind a toggle once filtering exists? Deferrable — today they are listed last, and the filter already gives a way to exclude them.
Loading
Loading