diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d772c0b..bb85029 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index d7a58eb..ba13e3c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/openspec/changes/revamp-tui-experience/.openspec.yaml b/openspec/changes/revamp-tui-experience/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/revamp-tui-experience/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/revamp-tui-experience/design.md b/openspec/changes/revamp-tui-experience/design.md new file mode 100644 index 0000000..6848e60 --- /dev/null +++ b/openspec/changes/revamp-tui-experience/design.md @@ -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 --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 `## ` and `- [ ] / - [x]` lines. The Tasks pane renders one `Collapsible` per section titled ` /`, 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. diff --git a/openspec/changes/revamp-tui-experience/proposal.md b/openspec/changes/revamp-tui-experience/proposal.md new file mode 100644 index 0000000..db13fc9 --- /dev/null +++ b/openspec/changes/revamp-tui-experience/proposal.md @@ -0,0 +1,51 @@ +## Why + +The TUI is functionally complete but visually flat and structurally shallow: every kanban column wears the same header colour, the dashboard is a `ListView` of pre-formatted label strings that cannot align into columns, notifications hijack the title bar and ring the terminal bell, and the command palette is a hand-rolled modal list instead of Textual's fuzzy one. Meanwhile every `openspec` subprocess call runs on the UI thread, so a slow CLI invocation freezes the interface with no loading affordance. + +Beyond looks, the app under-uses the data it already has: specs are listed only as a name and a requirement count with nothing to open, a change's tasks are reduced to an `x/y` string even though `tasks.md` is a parseable checklist, and there is no way to filter a long list of changes or specs. This change makes the interface look and feel like a well-made Textual app and gives the two under-served nouns — specs and tasks — real views. + +## What Changes + +**Presentation** + +- Move all styling out of inline Python `CSS` strings into a single external `app.tcss` loaded via `CSS_PATH`, expressed entirely in Textual's semantic design tokens (`$surface`, `$panel`, `$accent`, `$success`, `$warning`, `$text-muted`) so the app inherits the terminal's light/dark theme rather than shipping its own palette. +- Give each pipeline stage a distinct accent colour applied consistently everywhere it appears — kanban column header, card left border, dashboard stage badge — so stage is readable at a glance without reading text. +- Replace the dashboard's string-formatted `ListView` with an aligned, column-based table: stage badge, change name, progress bar, task count, last modified. +- Add a headline stat row to the dashboard (specs / draft / active / completed / archived) rendered as large figures rather than a dim one-line summary. +- Replace title-bar text mutation plus `App.bell()` with Textual toast notifications (`App.notify`) for all transient feedback (edit cancelled, editor fallback, reload complete, errors). +- Add a persistent status bar carrying the resolved root and the live/paused indicator, so screen titles no longer double as a notification surface. +- Render empty states as centred, actionable placeholders instead of dim inline text. + +**Structure** + +- Replace the bespoke `CommandPaletteScreen` with Textual's built-in command palette backed by a custom `Provider`, gaining fuzzy search and discovery hits for free; existing actions plus jump-to-change and jump-to-spec become palette commands. +- Render single-artifact detail panes with Textual's `MarkdownViewer` (scroll-aware, syntax-highlighted code blocks, navigable table of contents) instead of `rich.markdown.Markdown` inside a `Static`, and give a multi-file specs pane one nested tab per file instead of concatenating them as plain text. +- Move every `openspec` subprocess call off the UI thread into Textual thread workers, with a loading indicator while a read is in flight, so the interface never blocks on the CLI. +- Add a help panel bound to `?` listing the active key bindings for the current screen. + +**New views** + +- **Spec browser**: opening a spec from the dashboard shows its overview and its requirements, each expandable to reveal its scenarios, sourced from `openspec show --json --type spec`. +- **Task checklist pane**: a change's detail view gains a Tasks pane that renders `tasks.md` as its parsed section/checkbox tree with per-section progress, read-only (edits still go through `$EDITOR`). +- **Filter**: `/` opens an inline filter on the dashboard, kanban and spec browser that narrows the visible changes/specs by substring match, cleared with `escape`. + +No behaviour is removed. Existing key bindings keep their meanings; live refresh, `$EDITOR` editing, change creation and the thin-client invariant are all preserved. + +## Capabilities + +### New Capabilities + +None. Every addition here extends the existing `openspec-tui` capability rather than introducing a separate one. + +### Modified Capabilities + +- `openspec-tui`: adds requirements for consistent themed presentation that follows the terminal, stage-identifying colour, transient feedback via toast notifications, non-blocking CLI reads with a loading indicator, a discoverable key-binding help panel, a spec detail/requirements browser, a task checklist pane, and filtering of changes and specs. Modifies the existing dashboard requirement (aligned columnar listing plus headline counts), the artifact detail requirement (markdown viewer with table of contents, one tab per spec file), and the live-refresh indicator requirement (indicator lives in a dedicated status bar). + +## Impact + +- **Code**: `openspec_ui/app.py` (theme wiring, `CSS_PATH`, command `Provider`, worker plumbing, palette removal), `openspec_ui/screens/dashboard.py`, `kanban.py`, `detail.py`, `new_change.py`, `confirm.py`; `openspec_ui/screens/palette.py` is deleted; new `openspec_ui/app.tcss`, new `openspec_ui/screens/spec_detail.py`, new widgets module for the shared card/badge/progress widgets. +- **Client**: `openspec_ui/client.py` gains `show_spec(name)` (parsing `openspec show --json --type spec`) and a `tasks.md` checklist parser; existing reads are wrapped so they can be called from worker threads. +- **Packaging**: `pyproject.toml` must ship `app.tcss` as package data in the wheel; the `textual>=0.86.0` floor rises to a version providing themes, `MarkdownViewer`, and the command `Provider` API. +- **Dependencies**: no new runtime dependencies. +- **Docs**: `README.md` screenshots and key-binding table need updating; `?` and `/` are new bindings. +- **Risk**: the largest surface is the dashboard rewrite and the worker refactor; both are internal to the app and the thin-client invariant (no owned, persisted state) is unaffected. diff --git a/openspec/changes/revamp-tui-experience/specs/openspec-tui/spec.md b/openspec/changes/revamp-tui-experience/specs/openspec-tui/spec.md new file mode 100644 index 0000000..00befea --- /dev/null +++ b/openspec/changes/revamp-tui-experience/specs/openspec-tui/spec.md @@ -0,0 +1,250 @@ +## ADDED Requirements + +### Requirement: Presentation follows the terminal's theme + +The application SHALL derive every colour it renders from the active terminal-interface theme's semantic roles (surface, panel, accent, success, warning, muted text) rather than from hard-coded colour values, SHALL NOT ship or define a palette of its own, and SHALL NOT persist a theme preference anywhere. The interface SHALL remain legible under both light and dark themes, including themes that render using the terminal's own ANSI palette. + +#### Scenario: Legible under a light theme + +- **WHEN** the application is displayed under a light theme +- **THEN** all text, borders, badges, and progress indicators SHALL remain legible against their backgrounds, with no element rendered in a colour that assumes a dark background + +#### Scenario: Legible under a terminal-palette theme + +- **WHEN** the application is displayed under a theme that maps colours to the terminal's own ANSI palette +- **THEN** the interface SHALL render without missing or invisible elements, because no styling depends on a colour value the theme does not define + +#### Scenario: No theme state written + +- **WHEN** the user changes the active theme during a session and then quits and relaunches the application +- **THEN** the application SHALL start under its default theme, having written no theme preference to disk + +### Requirement: Pipeline stage is identified by colour + +The application SHALL assign each pipeline stage a distinct semantic colour and SHALL apply that same colour to every representation of that stage — kanban column heading, change card accent, and dashboard stage badge — so the stage of a change is identifiable without reading its text. + +#### Scenario: Consistent stage colour across views + +- **WHEN** a change is displayed in both the dashboard and the kanban view +- **THEN** its stage badge in the dashboard and its card accent and column heading in the kanban view SHALL use the same stage colour + +#### Scenario: Stages are visually distinguishable + +- **WHEN** the kanban view is displayed with changes in more than one stage +- **THEN** each stage's column heading SHALL be rendered in a colour distinct from the other stages' headings + +### Requirement: Transient feedback via notifications + +The application SHALL report transient outcomes — a cancelled edit, an editor fallback, a completed reload, a failed operation — as dismissible notifications rendered outside the content area, and SHALL NOT convey them by overwriting a screen's title or by ringing the terminal bell. + +#### Scenario: Cancelled edit reported + +- **WHEN** the user cancels the edit confirmation +- **THEN** the application SHALL show a notification stating the edit was cancelled +- **AND** the screen's title SHALL continue to show the change being viewed + +#### Scenario: Failure reported + +- **WHEN** an operation fails (for example the openspec CLI returns an error) +- **THEN** the application SHALL show a notification carrying the error message, styled to indicate failure +- **AND** the application SHALL remain usable rather than exiting + +### Requirement: Non-blocking reads with loading indication + +The application SHALL perform reads that invoke the openspec CLI without blocking user input, and SHALL display a loading indicator on the affected view while such a read is in flight. + +#### Scenario: Interface stays responsive during a slow read + +- **WHEN** a view triggers a read that takes noticeable time to return +- **THEN** the application SHALL continue to accept and process key presses while the read is in progress +- **AND** SHALL display a loading indicator on the view awaiting data + +#### Scenario: Indicator cleared on completion + +- **WHEN** an in-flight read completes, whether successfully or with an error +- **THEN** the application SHALL remove the loading indicator and render either the loaded content or the error + +### Requirement: Discoverable key bindings + +The application SHALL provide a help view, reachable by a documented key from any screen, that lists the key bindings active on the current screen with a description of each. + +#### Scenario: Opening help + +- **WHEN** the user presses the help key on any screen +- **THEN** the application SHALL display the key bindings available on that screen, each with its description + +#### Scenario: Help reflects the current screen + +- **WHEN** the user opens help on a screen with screen-specific bindings +- **THEN** the listed bindings SHALL include that screen's bindings, not only the application-wide ones + +### Requirement: Fuzzy command palette + +The application SHALL provide a command palette that filters available commands by fuzzy matching against typed text, SHALL list its commands when opened with no text typed, and SHALL offer navigation to a specific change or spec by name in addition to its global actions. + +#### Scenario: Filtering commands by typing + +- **WHEN** the user opens the command palette and types characters +- **THEN** the palette SHALL show only commands matching the typed text, ranked by match quality + +#### Scenario: Jumping to a change by name + +- **WHEN** the user opens the command palette and types part of an existing change's name +- **THEN** the palette SHALL offer a command that opens that change's detail view +- **AND** selecting it SHALL open that change + +#### Scenario: Discovery with no input + +- **WHEN** the user opens the command palette and has typed nothing +- **THEN** the palette SHALL list the available commands rather than an empty result + +### Requirement: Spec detail browser + +The application SHALL provide a detail view for a specification, opened by selecting a spec, that displays the spec's overview and each of its requirements, and that allows the user to expand a requirement to read its scenarios. + +#### Scenario: Opening a spec + +- **WHEN** the user selects a spec in the dashboard +- **THEN** the application SHALL open a detail view showing that spec's overview and the text of each of its requirements + +#### Scenario: Expanding a requirement + +- **WHEN** the user expands a requirement in the spec detail view +- **THEN** the application SHALL display that requirement's scenarios +- **AND** collapsing it SHALL hide them again + +#### Scenario: Spec with no requirements + +- **WHEN** the user opens a spec that declares no requirements +- **THEN** the application SHALL render an empty state saying so, without erroring + +### Requirement: Task checklist view + +The application SHALL provide, within a change's detail view, a read-only checklist rendering of that change's `tasks.md` showing its sections, its individual tasks with their completed state, and each section's completed/total count. The view SHALL NOT allow tasks to be toggled from within the application; task edits SHALL continue to go through the external editor. + +#### Scenario: Tasks rendered as a checklist + +- **WHEN** the user views the tasks pane of a change whose `tasks.md` contains sections of checkbox items +- **THEN** the application SHALL display each section with its heading and completed/total count +- **AND** SHALL display each task with an indicator of whether it is completed + +#### Scenario: Tasks pane is read-only + +- **WHEN** the user attempts to toggle a task's completed state from the tasks pane +- **THEN** the application SHALL NOT modify `tasks.md` + +#### Scenario: Change without tasks + +- **WHEN** the user views the tasks pane of a change that has no `tasks.md` +- **THEN** the application SHALL render an empty state indicating no tasks are defined, without erroring + +### Requirement: Filtering changes and specs + +The application SHALL let the user narrow the changes and specs visible in the dashboard, kanban and spec listings by typing a filter, matching entries whose name contains the typed text, and SHALL let the user clear the filter and restore the full listing. + +#### Scenario: Narrowing a listing + +- **WHEN** the user activates the filter and types text +- **THEN** the view SHALL display only the changes or specs whose names contain that text +- **AND** the remaining entries SHALL keep their stage grouping and progress information + +#### Scenario: Clearing the filter + +- **WHEN** the user clears or dismisses the filter +- **THEN** the view SHALL restore the full listing + +#### Scenario: Filter matches nothing + +- **WHEN** the typed filter matches no change or spec +- **THEN** the view SHALL render an empty state indicating nothing matched, without erroring + +#### Scenario: Filter is not persisted + +- **WHEN** the user filters a view and then relaunches the application +- **THEN** the view SHALL open unfiltered, the filter having been session-only state + +## MODIFIED Requirements + +### Requirement: Dashboard view of changes and specs + +The application SHALL provide an interactive dashboard listing changes and specifications for the resolved root, grouped by status (draft, active, completed), with task progress indicators. Changes SHALL be presented in aligned columns — stage, name, progress, task count, last modified — so values in the same column line up across rows. The dashboard SHALL also present headline counts of specs and of changes per stage as a prominent summary. While the dashboard is open and live refresh is not paused, the view SHALL periodically re-read state from disk and re-render so counts and progress bars stay current without a manual keypress. + +#### Scenario: Active changes shown with progress + +- **WHEN** the dashboard is displayed and one or more active changes exist +- **THEN** each active change SHALL be listed with its name and a progress indicator reflecting completed/total tasks + +#### Scenario: Change attributes align across rows + +- **WHEN** the dashboard lists two or more changes whose names differ in length +- **THEN** each change attribute SHALL appear in the same column position on every row, so progress indicators and task counts line up vertically + +#### Scenario: Headline counts shown + +- **WHEN** the dashboard is displayed +- **THEN** it SHALL show the total number of specs and the number of changes in each stage as prominent figures + +#### Scenario: Empty root + +- **WHEN** the resolved root contains no changes and no specs +- **THEN** the dashboard SHALL render an empty state that tells the user no changes or specs exist, without erroring + +#### Scenario: External change reflected while dashboard is open + +- **WHEN** the dashboard is open and live refresh is active, and an external process modifies a change's tasks or adds/removes a change or spec +- **THEN** the dashboard SHALL, within its polling interval, re-read from disk and update counts, progress bars, and listings to match the current state, without any user keypress + +### Requirement: Read-only artifact detail view + +The application SHALL provide a detail view that renders the markdown artifacts of a change or spec (`proposal.md`, `design.md`, `specs/**`, `tasks.md`) read-only, preserving monospace and ASCII diagram formatting. Rendered artifacts SHALL be independently scrollable and SHALL offer a navigable table of contents of the artifact's headings. When an artifact group resolves to more than one file, each file SHALL be presented in its own selectable tab rather than concatenated into a single view. + +#### Scenario: Rendered markdown preserves formatting + +- **WHEN** the user opens a change's detail view for an artifact containing ASCII box diagrams or code blocks +- **THEN** the rendered view SHALL preserve the monospace alignment of that content so diagrams remain legible + +#### Scenario: Switching between artifacts of a change + +- **WHEN** the user is viewing a change's detail view +- **THEN** the application SHALL let the user switch between that change's artifacts (proposal, design, specs, tasks) + +#### Scenario: Navigating a long artifact + +- **WHEN** the user views an artifact longer than the viewport +- **THEN** the application SHALL let the user scroll the artifact +- **AND** SHALL offer a table of contents of its headings that jumps to the selected heading + +#### Scenario: Artifact group with several files + +- **WHEN** the user opens an artifact group that resolves to more than one file (for example a change's delta specs) +- **THEN** each file SHALL be selectable in its own tab, labelled with that file's name +- **AND** the selected file SHALL be rendered with the same markdown formatting as a single-file artifact + +### Requirement: Live refresh with pause and indicator + +The application SHALL periodically refresh open views (kanban and dashboard) from disk while live refresh is active, SHALL let the user pause and resume live refresh, and SHALL display a visible indicator of whether live refresh is currently active or paused. The indicator SHALL live in a persistent status area that also shows the resolved openspec root, and SHALL NOT be rendered into a screen title that other messages overwrite. + +#### Scenario: Live refresh on by default + +- **WHEN** the user opens the kanban or dashboard view +- **THEN** live refresh SHALL be active by default, periodically reloading the view from disk + +#### Scenario: User pauses live refresh + +- **WHEN** the user invokes the pause-live-refresh action +- **THEN** the application SHALL stop periodic reloading and SHALL display an indicator that live refresh is paused + +#### Scenario: User resumes live refresh + +- **WHEN** the user invokes the resume-live-refresh action while paused +- **THEN** the application SHALL resume periodic reloading and SHALL display an indicator that live refresh is active + +#### Scenario: Manual refresh always available + +- **WHEN** the user presses the manual refresh key while live refresh is either active or paused +- **THEN** the application SHALL immediately reload the current view from disk + +#### Scenario: Indicator survives transient messages + +- **WHEN** the application reports a transient message while live refresh is active +- **THEN** the live-refresh indicator and the resolved root SHALL remain visible in the status area diff --git a/openspec/changes/revamp-tui-experience/tasks.md b/openspec/changes/revamp-tui-experience/tasks.md new file mode 100644 index 0000000..ae1855b --- /dev/null +++ b/openspec/changes/revamp-tui-experience/tasks.md @@ -0,0 +1,89 @@ +## 1. Styling foundation + +- [x] 1.1 Create `openspec_ui/app.tcss` and load it from `OpenSpecUIApp` via `CSS_PATH`; move the inline `App.CSS`, `CommandPaletteScreen.CSS` and `NewChangeScreen.CSS` rules into it verbatim as a first pass, and delete the inline strings +- [x] 1.2 Rewrite the moved rules to reference only semantic theme tokens (`$surface`, `$panel`, `$boost`, `$accent`, `$success`, `$warning`, `$error`, `$foreground`, `$text-muted`) with opacity modifiers; assert no hex literal remains in the stylesheet +- [x] 1.3 Add `openspec_ui/theme.py` (or equivalent) exporting a single `STAGE_STYLE` mapping from each `ChangeStage` value to its token and glyph, per design.md's stage table +- [x] 1.4 Add stage-scoped CSS classes (`.stage-draft`, `.stage-active`, `.stage-completed`, `.stage-archived`) in `app.tcss` driven by that mapping, covering column heading, card left border and dashboard badge +- [x] 1.5 Verify the app starts and every existing screen renders under `textual-dark`, `textual-light` and `ansi-dark`, with nothing invisible or unreadable +- [x] 1.6 Bump the `textual` floor in `pyproject.toml` from `>=0.86.0` to `>=1.0.0` and note the reason in the dependency comment + +## 2. Status bar and notifications + +- [x] 2.1 Add a `StatusBar` widget (resolved root + `● live` / `○ paused` indicator) with its rules in `app.tcss`, docked above the `Footer` +- [x] 2.2 Mount `StatusBar` on the dashboard, kanban and detail screens; drive the indicator from `App.live_refresh` so `p` updates it on every screen +- [x] 2.3 Replace `DetailScreen._notify` (title mutation + `App.bell()` + restore timer) with `App.notify()` toasts, using severity for error/warning/success; delete `_notify` and `_restore_title` +- [x] 2.4 Route the dashboard and kanban error paths (`_render_error`, the kanban title error branch) through toasts plus a persistent inline error state, so a failed read no longer overwrites the title +- [x] 2.5 Restore screen titles to naming the screen only; remove the live indicator and root path from `#title` now that `StatusBar` owns them + +## 3. Non-blocking reads + +- [x] 3.1 Convert `DashboardScreen.reload`, `KanbanScreen.reload` and `DetailScreen.reload` into `@work(thread=True, exclusive=True, group="openspec-read")` methods that call the client off-thread and render on the UI thread +- [x] 3.2 Set the target widget's `loading` flag while a read is in flight and clear it on both success and failure +- [x] 3.3 Keep the 1-second `_tick` and its `has_changed()` stat probe on the UI thread; make the reload it triggers go through the worker, preserving the existing "not top screen" and "in `$EDITOR`" guards +- [x] 3.4 Verify rapid repeated `r` presses collapse to a single latest read (superseded workers cancelled) and that no stale result overwrites a newer render + +## 4. Command palette + +- [x] 4.1 Add a static `Provider` exposing the global actions (open dashboard, open kanban, refresh, new change, quit) with both `search()` and `discover()` implemented +- [x] 4.2 Add a dynamic `Provider` that enumerates changes and specs and yields "open change " / "open spec " hits, calling the client from its async `search()` +- [x] 4.3 Register both in `App.COMMANDS`, delete `openspec_ui/screens/palette.py`, and remove `action_command_palette`, `_palette_result` and the custom `ctrl+p` binding from `app.py` +- [x] 4.4 Verify `ctrl+p` opens the built-in palette, that typing fuzzy-filters, that an empty palette lists commands, and that the system theme command switches theme in-session + +## 5. Dashboard rewrite + +- [x] 5.1 Replace the `#changes` `ListView` with a `DataTable` (columns: stage badge, name, progress, tasks, modified) keyed by change name; keep draft → active → completed → archived ordering +- [x] 5.2 Restyle `progress_bar()` to render stage-coloured block glyphs and use it as the progress cell +- [x] 5.3 Rewire `enter` and row selection to open the change via the row key rather than a list index; confirm `k`, `n`, `r`, `p` keep their current meanings +- [x] 5.4 Replace the dim one-line summary with a horizontal row of `Digits` (specs / draft / active / completed / archived), hidden when the terminal is shorter than ~24 rows +- [x] 5.5 Render the no-changes and no-specs cases as centred, actionable empty states instead of dim inline text +- [x] 5.6 Verify columns align across rows of differing name length, and that the dashboard is usable at 80×24 + +## 6. Kanban polish + +- [x] 6.1 Apply `STAGE_STYLE` to each column heading and give each card a stage-coloured left border +- [x] 6.2 Restyle cards (name, progress bar, task count) using the shared stylesheet rules rather than inline markup strings +- [x] 6.3 Render an empty column as a centred placeholder rather than a blank list +- [x] 6.4 Verify a card visibly moves between columns on live refresh and that stage colours match the dashboard badges + +## 7. Detail view: markdown viewer and tabs + +- [x] 7.1 Replace `ArtifactPane`'s `Static` + `rich.markdown.Markdown` with `MarkdownViewer(show_table_of_contents=True)` for single-file artifacts +- [x] 7.2 Replace the concatenated multi-file rendering with a nested `TabbedContent`, one `MarkdownViewer` per file, labelled by filename; delete the plain-text fallback and its docstring caveat +- [x] 7.3 Hide the table of contents below ~100 columns and add a binding to toggle it +- [x] 7.4 Verify ASCII diagrams and fenced code blocks still render monospace and aligned, and that the TOC jumps to the selected heading + +## 8. Spec detail browser + +- [x] 8.1 Add `OpenSpecClient.show_spec(name)` parsing `openspec show --json --type spec --no-interactive` into `overview` and `requirements[].{text, scenarios[]}` +- [x] 8.2 Add `openspec_ui/screens/spec_detail.py` rendering the overview plus one `Collapsible` per requirement, each containing its scenarios as markdown +- [x] 8.3 Wire spec selection on the dashboard (`enter` on the specs list) and the palette's "open spec" hit to push `SpecDetailScreen`; add a back binding to the dashboard +- [x] 8.4 Render a spec with zero requirements as an empty state; surface a failed `show_spec` as a toast plus inline error +- [x] 8.5 Verify expanding and collapsing a requirement shows and hides its scenarios + +## 9. Task checklist pane + +- [x] 9.1 Add a `tasks.md` parser to the client producing sections (`## `) each with their `- [ ]` / `- [x]` items and a done/total count +- [x] 9.2 Render the detail view's Tasks pane as one `Collapsible` per section titled ` /`, with per-task completed indicators styled from the stage tokens +- [x] 9.3 Fall back to rendering the file as markdown when the parser finds no checkbox items +- [x] 9.4 Render an empty state when the change has no `tasks.md` +- [x] 9.5 Confirm the pane performs no writes — no key toggles a checkbox and `tasks.md` is unmodified after interacting with it + +## 10. Filtering + +- [x] 10.1 Add a hidden `Input` filter bar to the dashboard, kanban and spec listings, revealed and focused by `/` and cleared and hidden by `escape` +- [x] 10.2 Filter the rendered rows by case-insensitive substring match on name, holding the filter string on the screen instance only +- [x] 10.3 Re-apply the active filter after each live-refresh reload so filtering and polling do not fight +- [x] 10.4 Render a "nothing matched" empty state when the filter excludes everything +- [x] 10.5 Verify stage grouping and progress information survive filtering, and that relaunching opens unfiltered + +## 11. Help panel + +- [x] 11.1 Bind `?` to `action_show_help_panel()` at app level and confirm screen-specific bindings appear alongside global ones +- [x] 11.2 Confirm `?` does not steal input while an `Input` (filter, new-change name) has focus + +## 12. Packaging, CI and docs + +- [x] 12.1 Ensure `app.tcss` ships in the wheel; extend the CI import check to assert the stylesheet resolves via `importlib.resources` from the built wheel, not just from the source checkout +- [x] 12.2 Build the wheel, install it into a clean environment, and confirm `openspec-ui` starts with styling applied +- [x] 12.3 Update `README.md`: refresh the key-binding table for `?` and `/`, describe the spec browser and tasks pane, and note the `textual>=1.0.0` floor +- [x] 12.4 Run a final pass at 80×24 across every screen — dashboard, kanban, change detail, spec detail, filter active, help panel open — and fix any overflow or clipping diff --git a/openspec_ui/app.py b/openspec_ui/app.py index ca67db9..5e5a4c0 100644 --- a/openspec_ui/app.py +++ b/openspec_ui/app.py @@ -4,65 +4,50 @@ from pathlib import Path -from textual.app import App, ComposeResult +from textual.app import App from textual.binding import Binding +from textual.reactive import reactive from openspec_ui.client import OpenSpecClient +from openspec_ui.commands import NavigationCommands, OpenSpecItems from openspec_ui.screens.dashboard import DashboardScreen from openspec_ui.screens.kanban import KanbanScreen from openspec_ui.screens.detail import DetailScreen -from openspec_ui.screens.palette import CommandPaletteScreen from openspec_ui.screens.new_change import NewChangeScreen +from openspec_ui.screens.spec_detail import SpecDetailScreen class OpenSpecUIApp(App): """Interactive dashboard for an OpenSpec root (thin client; owns no state).""" - CSS = """ - Screen { - background: $surface; - } - #board { - height: 1fr; - } - .column { - width: 1fr; - border: round $primary-background 50%; - padding: 0 1; - margin: 0 1; - } - .column-title { - text-style: bold; - color: $text; - background: $primary-background; - padding: 0 1; - width: 100%; - } - .column-list { - height: 1fr; - } - .card { - width: 100%; - } - """ + # All styling lives in app.tcss — one stylesheet, semantic tokens only. + CSS_PATH = "app.tcss" TITLE = "openspec-ui" SUB_TITLE = "manage your openspecs" + # Textual's own palette providers (theme switcher, screenshot, …) plus ours. + COMMANDS = App.COMMANDS | {NavigationCommands, OpenSpecItems} + BINDINGS = [ - Binding("ctrl+p", "command_palette", "Command palette", show=True), + # `?` is a printable key, so a focused Input swallows it before the + # screen sees it — typing "?" into the filter still types a "?". + Binding("question_mark", "show_help_panel", "Help", show=True), Binding("q", "quit", "Quit", show=True), Binding("ctrl+c", "quit", "Quit", show=False), ] + # Ephemeral live-refresh state (NOT persisted — thin-client invariant). + # Reactive so every mounted StatusBar re-renders on toggle without each + # screen having to push the change itself. + live_refresh: reactive[bool] = reactive(True) + def __init__(self, client: OpenSpecClient, root: Path) -> None: super().__init__() self.client = client self.root = root - # Ephemeral live-refresh state (NOT persisted — thin-client invariant): - # `live_refresh` toggles the poller; `_in_editor` flags a suspended - # $EDITOR session so polling never races a half-saved file. - self.live_refresh: bool = True + # `_in_editor` flags a suspended $EDITOR session so polling never races + # a half-saved file. self._in_editor: bool = False # -- lifecycle ------------------------------------------------------- @@ -97,6 +82,9 @@ def open_kanban(self) -> None: def open_detail(self, change_name: str) -> None: self.push_screen(DetailScreen(change_name=change_name)) + def open_spec(self, spec_name: str) -> None: + self.push_screen(SpecDetailScreen(spec_name=spec_name)) + def refresh_active(self) -> None: screen = self.screen if hasattr(screen, "reload"): @@ -116,30 +104,8 @@ def foreground_busy(self) -> bool: """ return self._in_editor - @property - def live_label(self) -> str: - """Glyph shown in screen titles so the live/paused state is always visible.""" - return "[green]● live[/green]" if self.live_refresh else "[dim]○ paused[/dim]" - # -- modal actions --------------------------------------------------- - def action_command_palette(self) -> None: - self.push_screen(CommandPaletteScreen(), self._palette_result) - - def _palette_result(self, action: str | None) -> None: - if not action: - return - dispatch = { - "open_dashboard": self.open_dashboard, - "open_kanban": self.open_kanban, - "refresh_active": self.refresh_active, - "open_new_change": self.open_new_change, - "quit": self.exit, - } - handler = dispatch.get(action) - if handler is not None: - handler() - def open_new_change(self) -> None: self.push_screen(NewChangeScreen(), self._new_change_result) diff --git a/openspec_ui/app.tcss b/openspec_ui/app.tcss new file mode 100644 index 0000000..2a1c2c6 --- /dev/null +++ b/openspec_ui/app.tcss @@ -0,0 +1,378 @@ +/* openspec-ui stylesheet. + * + * All styling for the app lives here. Rules reference only Textual's + * theme-provided semantic tokens — no hex literals, no fixed colours — so the + * interface takes its colour from whichever theme is active, including the + * ANSI themes that map onto the terminal's own palette. + * + * Stage colours are mirrored in openspec_ui/theme.py (STAGE_STYLE); change + * both together. + */ + +/* -- app shell ------------------------------------------------------------ */ + +Screen { + background: $surface; +} + +#title { + text-style: bold; + padding: 0 1; +} + +/* -- status bar ----------------------------------------------------------- */ +/* Sits directly above the Footer. Carries state only — transient messages go + * to toasts, so nothing ever overwrites what is shown here. */ + +StatusBar { + height: 1; + width: 100%; + background: $panel; + padding: 0 1; +} + +#status-root { + width: 1fr; + content-align: left middle; +} + +#status-live { + width: auto; + content-align: right middle; +} + +/* -- stage identity ------------------------------------------------------- */ +/* Applied to kanban column headings, card accents and dashboard badges. + * Tokens match STAGE_STYLE in theme.py exactly, so a stage is the same colour + * whether it is drawn by a stylesheet rule or by Content markup in a table + * cell. They are all concrete tokens for that reason — see theme.py. */ + +.stage-draft { + color: $foreground-muted; +} + +.stage-active { + color: $text-accent; +} + +.stage-completed { + color: $text-success; +} + +/* $foreground-disabled resolves for markup but is not exposed to the CSS + * parser; 38% alpha is the same value it expands to. */ +.stage-archived { + color: $foreground 38%; +} + +/* Column headings sit on a neutral bar and carry the stage in their text + * colour only. Tinting the background per stage looks right on truecolor + * themes but collapses on ANSI ones, where e.g. $accent-muted and + * $text-accent resolve to the same palette entry and the label vanishes. */ +.column-title.stage-draft, +.column-title.stage-active, +.column-title.stage-completed, +.column-title.stage-archived { + background: $boost; +} + +/* Cards carry the stage colour as a left accent border. */ +.card.stage-draft { + border-left: thick $foreground 30%; +} + +.card.stage-active { + border-left: thick $accent; +} + +.card.stage-completed { + border-left: thick $success; +} + +.card.stage-archived { + border-left: thick $foreground 15%; +} + +/* -- kanban --------------------------------------------------------------- */ + +#board { + height: 1fr; +} + +.column { + width: 1fr; + border: round $foreground 20%; + padding: 0 1; + margin: 0 1; +} + +.column-title { + text-style: bold; + padding: 0 1; + width: 100%; + height: 1; +} + +.column-title-name { + width: 1fr; + /* A heading that wraps to two lines pushes that column's cards down and + * breaks alignment with every other column. */ + text-wrap: nowrap; + text-overflow: ellipsis; +} + +.column-title-count { + width: auto; + text-align: right; + padding-left: 1; +} + +.column-list { + height: 1fr; + background: transparent; +} + +.card { + width: 100%; + height: auto; + padding: 0 1; +} + +.card-name { + text-style: bold; + width: 100%; + /* Truncate rather than wrap: a name broken mid-word across two lines + * misaligns every card below it in the column. */ + text-wrap: nowrap; + text-overflow: ellipsis; +} + +.card-progress { + width: 100%; + /* Same reason as the name: a wrapped bar costs a row and misaligns the + * cards below it. */ + text-wrap: nowrap; +} + +.card-tasks { + color: $foreground-muted; + width: 100%; +} + +/* ListItem paints its own background; let the column show through so the + * card's stage border is what carries the colour. */ +.column-list > ListItem { + background: transparent; + padding: 0; + margin-bottom: 1; +} + +/* -- filter bar ----------------------------------------------------------- */ + +FilterBar { + height: auto; + padding: 0 1; +} + +.filter-prompt { + width: 2; + color: $text-accent; + text-style: bold; + content-align: left middle; +} + +FilterBar Input { + width: 1fr; + border: none; + background: $boost; + padding: 0 1; +} + +/* -- dashboard ------------------------------------------------------------ */ + +#stats { + height: auto; + padding: 0 1; +} + +.stat { + width: 1fr; + height: auto; +} + +.stat Digits { + color: $text-accent; + width: auto; +} + +.stat-label { + color: $text-muted; +} + +#body { + height: 1fr; + padding: 0 1; +} + +#changes_section, #specs_section { + text-style: bold; + padding: 1 0 0 0; +} + +#changes { + height: auto; + max-height: 60%; + background: transparent; +} + +#specs { + height: auto; + background: transparent; +} + +#changes_empty, #specs_empty { + height: auto; + padding: 1 0; +} + +/* -- change detail -------------------------------------------------------- */ + +#tabs { + height: 1fr; +} + +.artifact-viewer { + height: 1fr; + background: transparent; +} + +.artifact-files { + height: 1fr; +} + +/* -- tasks checklist ------------------------------------------------------ */ + +.tasks-body { + height: 1fr; + padding: 0 1; +} + +.tasks-summary { + text-style: bold; + padding-bottom: 1; +} + +.task-section { + border: none; + background: $boost; + margin: 0; +} + +.task-section CollapsibleTitle { + padding: 0 1; +} + +.task-item { + padding: 0 1; + width: 100%; +} + +/* -- spec detail ---------------------------------------------------------- */ + +#spec-body { + height: 1fr; + padding: 0 1; +} + +.spec-overview { + background: transparent; + margin: 0; + padding: 0; +} + +.spec-count { + color: $text-muted; + padding: 1 0 0 0; +} + +.requirement { + border: none; + background: $boost; + margin: 0; +} + +.requirement CollapsibleTitle { + padding: 0 1; +} + +.requirement-empty { + color: $text-muted; + padding: 0 1; +} + +/* -- empty states --------------------------------------------------------- */ + +.empty-state { + width: 100%; + height: 1fr; + content-align: center middle; + text-align: center; + color: $text-muted; +} + +/* -- new-change modal ----------------------------------------------------- */ + +NewChangeScreen { + align: center middle; +} + +#new-change { + width: 60; + max-width: 90%; + height: auto; + border: round $primary; + background: $panel; + padding: 1 2; +} + +Label.title { + text-style: bold; + padding-bottom: 1; +} + +#status { + padding-top: 1; +} + +/* -- confirm modal -------------------------------------------------------- */ + +ConfirmScreen { + align: center middle; +} + +#confirm-box { + width: 64; + max-width: 90%; + height: auto; + border: round $primary; + background: $panel; + padding: 1 2; +} + +#confirm-title { + text-style: bold; + padding-bottom: 1; +} + +#confirm-message { + padding-bottom: 1; +} + +#confirm-buttons { + align-horizontal: right; + height: auto; +} + +#confirm-buttons Button { + margin-left: 1; +} diff --git a/openspec_ui/client.py b/openspec_ui/client.py index 8354160..5d29fa5 100644 --- a/openspec_ui/client.py +++ b/openspec_ui/client.py @@ -18,6 +18,11 @@ "OpenSpecError", "Change", "Spec", + "SpecDetail", + "Requirement", + "Task", + "TaskSection", + "parse_tasks", "ChangeStage", "OpenSpecClient", ] @@ -89,8 +94,106 @@ class Spec: requirement_count: int +@dataclass +class Task: + """One checklist item from a change's `tasks.md`.""" + + text: str + done: bool + + +@dataclass +class TaskSection: + """A `## heading` in `tasks.md` and the checklist items beneath it.""" + + title: str + tasks: list[Task] + + @property + def completed(self) -> int: + return sum(1 for task in self.tasks if task.done) + + @property + def total(self) -> int: + return len(self.tasks) + + @property + def label(self) -> str: + return f"{self.completed}/{self.total}" + + +@dataclass +class Requirement: + """One requirement of a spec, with its scenarios as raw markdown.""" + + text: str + scenarios: list[str] + + +@dataclass +class SpecDetail: + """A spec's overview plus its requirements, as `openspec show` reports them.""" + + name: str + title: str + overview: str + requirements: list[Requirement] + + _KEBAB_RE = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$") +# `## 1. Section name` / `## Section name` +_SECTION_RE = re.compile(r"^\s{0,3}#{2,3}\s+(?P.+?)\s*$") +# `- [ ] 1.1 do the thing` / `* [x] do the thing` +_TASK_RE = re.compile(r"^\s*[-*+]\s+\[(?P<mark>[ xX])\]\s*(?P<text>.*?)\s*$") + + +def parse_tasks(text: str) -> list[TaskSection]: + """Parse a `tasks.md` into its sections and checklist items. + + Returns an empty list when the file contains no checkbox items at all, which + is the caller's signal to fall back to rendering the file as markdown rather + than showing a misleading empty checklist. Items appearing before any + heading are collected under an untitled leading section, so a flat task list + is not silently dropped. + """ + sections: list[TaskSection] = [] + current: TaskSection | None = None + in_fence = False + + for line in text.splitlines(): + stripped = line.strip() + # Never read checkboxes out of a fenced code block — task files often + # show example markup. + if stripped.startswith("```") or stripped.startswith("~~~"): + in_fence = not in_fence + continue + if in_fence: + continue + + task_match = _TASK_RE.match(line) + if task_match: + if current is None: + current = TaskSection(title="", tasks=[]) + sections.append(current) + current.tasks.append( + Task( + text=task_match.group("text"), + done=task_match.group("mark").lower() == "x", + ) + ) + continue + + section_match = _SECTION_RE.match(line) + if section_match: + current = TaskSection(title=section_match.group("title"), tasks=[]) + sections.append(current) + + # Drop headings that carried no checklist items, and report "no checklist" + # rather than a list of empty sections. + sections = [section for section in sections if section.tasks] + return sections + def is_valid_change_name(name: str) -> bool: """A kebab-case check used by the create-change flow before invoking the CLI.""" @@ -154,6 +257,12 @@ def _run_json(self, args: list[str]) -> object: # -- reads ----------------------------------------------------------- def root(self) -> Path: + # Served from cache after the first resolve. The live-refresh poller + # calls this on every tick via has_changed(); without the fast path it + # spawns `openspec list` once a second, which is exactly the subprocess + # the mtime gate exists to avoid. + if self._root_cache is not None: + return self._root_cache data = self._run_json(["list"]) root_obj = data.get("root") if isinstance(data, dict) else None root_path = root_obj.get("path") if isinstance(root_obj, dict) else None @@ -294,6 +403,37 @@ def list_specs(self) -> list[Spec]: ) return specs + def show_spec(self, name: str) -> SpecDetail: + """Return a spec's overview and requirements via `openspec show --type spec`. + + Read through the CLI rather than by parsing `spec.md` ourselves, so the + app stays on the CLI's contract (thin-client invariant). + """ + data = self._run_json(["show", name, "--type", "spec", "--no-interactive"]) + if not isinstance(data, dict): + raise OpenSpecError(f"Unexpected show output for spec '{name}'.") + requirements: list[Requirement] = [] + for entry in data.get("requirements") or []: + if not isinstance(entry, dict): + continue + scenarios = [ + scenario.get("rawText", "") + for scenario in (entry.get("scenarios") or []) + if isinstance(scenario, dict) + ] + requirements.append( + Requirement( + text=(entry.get("text") or "").strip(), + scenarios=[s for s in scenarios if s.strip()], + ) + ) + return SpecDetail( + name=data.get("id") or name, + title=data.get("title") or name, + overview=(data.get("overview") or "").strip(), + requirements=requirements, + ) + def status(self, change_name: str) -> dict: """Return parsed `openspec status --change <name> --json` (artifact paths, progress).""" data = self._run_json(["status", "--change", change_name]) diff --git a/openspec_ui/commands.py b/openspec_ui/commands.py new file mode 100644 index 0000000..3c1fbec --- /dev/null +++ b/openspec_ui/commands.py @@ -0,0 +1,109 @@ +"""Command palette providers. + +Registered in `OpenSpecUIApp.COMMANDS`, these feed Textual's built-in palette +(`ctrl+p`), which brings fuzzy matching and the system commands — including the +theme switcher — for free. The app ships no palette screen of its own. +""" + +from __future__ import annotations + +import asyncio +from functools import partial +from typing import Callable, NamedTuple + +from textual.command import DiscoveryHit, Hit, Hits, Provider + +__all__ = ["NavigationCommands", "OpenSpecItems"] + + +class _Command(NamedTuple): + name: str + help: str + run: Callable[[], None] + + +class NavigationCommands(Provider): + """The app's global actions.""" + + def _commands(self) -> list[_Command]: + app = self.app + return [ + _Command("Open dashboard", "Changes and specs for this root", app.open_dashboard), + _Command("Open kanban", "Changes arranged by pipeline stage", app.open_kanban), + _Command("Refresh current view", "Re-read the current view from disk", app.refresh_active), + _Command("New change", "Create a change via the openspec CLI", app.open_new_change), + # No Quit entry: Textual's system commands already provide one, and + # two identically-named commands in the palette read as a bug. + ] + + async def discover(self) -> Hits: + # What the palette lists before anything is typed. + for command in self._commands(): + yield DiscoveryHit(command.name, command.run, help=command.help) + + async def search(self, query: str) -> Hits: + matcher = self.matcher(query) + for command in self._commands(): + score = matcher.match(command.name) + if score > 0: + yield Hit( + score, + matcher.highlight(command.name), + command.run, + help=command.help, + ) + + +class OpenSpecItems(Provider): + """Jump straight to a change or a spec by name.""" + + def __init__(self, screen, match_style=None) -> None: + super().__init__(screen, match_style) + self._items: list[_Command] = [] + + async def startup(self) -> None: + # Read once when the palette opens rather than per keystroke, and off + # the event loop — the client shells out to the openspec CLI. + self._items = await asyncio.to_thread(self._fetch) + + def _fetch(self) -> list[_Command]: + app = self.app + items: list[_Command] = [] + # A palette that cannot list changes is still useful for specs (and + # vice versa), so each read fails independently and silently — the + # screens already report read failures loudly. + try: + for change in app.client.list_changes(): + items.append( + _Command( + f"Open change: {change.name}", + change.progress_label, + partial(app.open_detail, change.name), + ) + ) + except Exception: + pass + try: + for spec in app.client.list_specs(): + noun = "requirement" if spec.requirement_count == 1 else "requirements" + items.append( + _Command( + f"Open spec: {spec.name}", + f"{spec.requirement_count} {noun}", + partial(app.open_spec, spec.name), + ) + ) + except Exception: + pass + return items + + async def discover(self) -> Hits: + for item in self._items: + yield DiscoveryHit(item.name, item.run, help=item.help) + + async def search(self, query: str) -> Hits: + matcher = self.matcher(query) + for item in self._items: + score = matcher.match(item.name) + if score > 0: + yield Hit(score, matcher.highlight(item.name), item.run, help=item.help) diff --git a/openspec_ui/screens/confirm.py b/openspec_ui/screens/confirm.py index b238193..5cdb2f5 100644 --- a/openspec_ui/screens/confirm.py +++ b/openspec_ui/screens/confirm.py @@ -16,34 +16,6 @@ class ConfirmScreen(ModalScreen): trigger the destructive action. """ - CSS = """ - ConfirmScreen { - align: center middle; - } - #confirm-box { - width: 64; - max-width: 90%; - height: auto; - border: round $primary; - background: $panel; - padding: 1 2; - } - #confirm-title { - text-style: bold; - padding-bottom: 1; - } - #confirm-message { - padding-bottom: 1; - } - #confirm-buttons { - align-horizontal: right; - height: auto; - } - #confirm-buttons Button { - margin-left: 1; - } - """ - BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)] def __init__(self, title: str, message: str) -> None: diff --git a/openspec_ui/screens/dashboard.py b/openspec_ui/screens/dashboard.py index 356809d..707d98b 100644 --- a/openspec_ui/screens/dashboard.py +++ b/openspec_ui/screens/dashboard.py @@ -2,19 +2,47 @@ from __future__ import annotations +from datetime import datetime, timezone + +from textual import work from textual.binding import Binding -from textual.containers import Vertical, VerticalScroll +from textual.containers import Horizontal, VerticalScroll +from textual.content import Content from textual.screen import Screen -from textual.widgets import Footer, Header, Label, ListItem, ListView, Static +from textual.widgets import DataTable, Footer, Header, Label, ListItem, ListView, Static +from textual.worker import get_current_worker from openspec_ui.client import Change, ChangeStage - - -def progress_bar(change: Change, width: int = 16) -> str: - if change.total_tasks <= 0: - return "─" * width - filled = round((change.completed_tasks / change.total_tasks) * width) - return "█" * filled + "░" * (width - filled) +from openspec_ui.theme import PROGRESS_WIDTH, progress_bar, stage_style +from openspec_ui.widgets import EmptyState, FilterBar, Stat, StatusBar + +#: Below this terminal height the headline stat row costs more than it earns, +#: so it gives its rows back to the listing. +STAT_ROW_MIN_HEIGHT = 24 + +#: Below this terminal width the full column set overflows 80 columns, so the +#: least load-bearing column (Modified) is dropped rather than left to scroll +#: off the right edge. +NARROW_WIDTH = 90 + + +def format_modified(raw: str | None) -> str: + """Render an ISO timestamp as a compact age ("3h", "2d"), or "—" if absent.""" + if not raw: + return "—" + try: + stamp = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return raw[:10] + delta = datetime.now(timezone.utc) - stamp + seconds = int(delta.total_seconds()) + if seconds < 60: + return "just now" + if seconds < 3600: + return f"{seconds // 60}m" + if seconds < 86400: + return f"{seconds // 3600}h" + return f"{seconds // 86400}d" class DashboardScreen(Screen): @@ -24,39 +52,98 @@ class DashboardScreen(Screen): Binding("r", "refresh", "Refresh", show=True), Binding("p", "toggle_live", "Live", show=True), Binding("enter", "open_selected", "Open", show=True), + Binding("slash", "show_filter", "Filter", show=True), + Binding("escape", "clear_filter", "Clear filter", show=False), ] + #: Changes table columns as (label, width). Widths are explicit because + #: DataTable does not measure Content cells, so colour-marked-up columns + #: would otherwise collapse to the width of their header text. + COLUMNS = ( + ("Stage", 13), # "◆ In Progress" + ("Change", 32), + ("Progress", PROGRESS_WIDTH), + ("Tasks", 13), # "21/21 (100%)" + ("Modified", 9), + ) + + #: Narrow terminals drop Modified and give the name column less room, so + #: the remaining columns fit inside 80 cells without scrolling. + COMPACT_COLUMNS = ( + ("Stage", 13), + ("Change", 24), + ("Progress", PROGRESS_WIDTH), + ("Tasks", 13), + ) + def __init__(self) -> None: super().__init__() self._changes: list[Change] = [] + self._specs: list = [] + self._compact: bool | None = None + # Session-only: never persisted, and re-applied after every reload so + # live refresh and filtering do not fight over the listing. + self._filter = "" # -- compose -------------------------------------------------------- def compose(self) -> None: yield Header(show_clock=False) yield Static("", id="title") - yield Static("", id="summary") + yield FilterBar(id="filter") + yield Horizontal( + Stat("specs", id="stat-specs"), + Stat("draft", id="stat-draft"), + Stat("active", id="stat-active"), + Stat("completed", id="stat-completed"), + Stat("archived", id="stat-archived"), + id="stats", + ) yield VerticalScroll( - Static("", id="changes_section"), - ListView(id="changes"), - Static("", id="specs_section"), + Static("Changes", id="changes_section"), + DataTable(id="changes", cursor_type="row", zebra_stripes=True), + EmptyState("", id="changes_empty"), + Static("Specifications", id="specs_section"), ListView(id="specs"), + EmptyState("", id="specs_empty"), id="body", ) + yield StatusBar() yield Footer() def on_mount(self) -> None: + self._rebuild_columns() + table = self.query_one("#changes", DataTable) + self.query_one("#changes_empty", EmptyState).display = False + self.query_one("#specs_empty", EmptyState).display = False self.reload() - # Focus the changes list so keyboard navigation works immediately. - self.query_one("#changes", ListView).focus() + # Focus the changes table so keyboard navigation works immediately. + table.focus() # Live refresh: poll for external changes on a cheap mtime-gated cadence. self.set_interval(1.0, self._tick) - # -- data ------------------------------------------------------------ + def on_resize(self) -> None: + self._apply_stat_visibility() + # Crossing the width breakpoint changes the column set, which means + # rebuilding the table and repainting the rows we already have. + if self._compact != (self.size.width < NARROW_WIDTH): + self._rebuild_columns() + self._render_changes(self._ordered(self._changes)) + + def _apply_stat_visibility(self) -> None: + self.query_one("#stats", Horizontal).display = ( + self.size.height >= STAT_ROW_MIN_HEIGHT + ) + + def _rebuild_columns(self) -> None: + self._compact = self.size.width < NARROW_WIDTH + columns = self.COMPACT_COLUMNS if self._compact else self.COLUMNS + table = self.query_one("#changes", DataTable) + table.clear(columns=True) + for label, width in columns: + table.add_column(label, key=label, width=width) - @property - def app_(self): - return self.app # type: ignore[attr-defined] + # -- data ------------------------------------------------------------ def client(self): return self.app.client # type: ignore[attr-defined] @@ -81,90 +168,203 @@ def _tick(self) -> None: pass def reload(self) -> None: + """Kick off a background read. Rendering happens on the UI thread.""" + self.query_one("#body", VerticalScroll).loading = True + self._load() + + @work(thread=True, exclusive=True, group="openspec-read") + def _load(self) -> None: + # Runs off the UI thread so a slow `openspec` call never freezes input. + # `exclusive` cancels a superseded read; the is_cancelled checks below + # are what stop a stale result from overwriting a newer render. + worker = get_current_worker() try: changes = self.client().list_changes() specs = self.client().list_specs() except Exception as exc: # surface CLI errors in-app, never crash - self._render_error(str(exc)) + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_error, str(exc)) return + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_load, changes, specs) + + def _finish_load(self, changes: list[Change], specs) -> None: + self.query_one("#body", VerticalScroll).loading = False self._changes = changes + self._specs = list(specs) self._render_dashboard(changes, specs) - # Highlight the first change (if any) so Enter opens it immediately. - changes_list = self.query_one("#changes", ListView) - if changes_list._nodes: - try: - changes_list.index = 0 - except Exception: - pass + + def _finish_error(self, message: str) -> None: + self.query_one("#body", VerticalScroll).loading = False + self._render_error(message) # -- rendering ------------------------------------------------------ def _render_error(self, message: str) -> None: - self.query_one("#title", Static).update("openspec-ui — error") - self.query_one("#summary", Static).update("") - self.query_one("#changes_section", Static).update("") - self.query_one("#specs_section", Static).update("") - self.query_one("#changes", ListView).clear() + # A failed read is reported twice on purpose: a toast so it is noticed, + # and a persistent inline state so it is still readable afterwards. The + # title keeps naming the screen either way. + self.app.notify(message, title="Could not read openspec", severity="error") + self._render_title() + self.query_one("#changes", DataTable).clear() self.query_one("#specs", ListView).clear() - self.query_one("#body", VerticalScroll).update( - Static(f"[red]Error:[/red] {message}\n\nIs `openspec` installed and on PATH?") + self.query_one("#specs_empty", EmptyState).display = False + self._show_changes_empty( + f"[$text-error]{message}[/]\n\n" + "[$text-muted]Is `openspec` installed and on PATH?[/]" ) - def _render_dashboard(self, changes: list[Change], specs) -> None: - self._render_title() + def _show_changes_empty(self, message: str | None) -> None: + """Swap the changes table for a centred placeholder, or back again.""" + empty = self.query_one("#changes_empty", EmptyState) + table = self.query_one("#changes", DataTable) + if message is None: + empty.display = False + table.display = True + return + empty.set_message(message) + empty.display = True + table.display = False + + def _show_specs_empty(self, message: str | None) -> None: + empty = self.query_one("#specs_empty", EmptyState) + specs = self.query_one("#specs", ListView) + if message is None: + empty.display = False + specs.display = True + return + empty.set_message(message) + empty.display = True + specs.display = False + @staticmethod + def _grouped(changes: list[Change]) -> tuple[list[Change], ...]: archived = [c for c in changes if c.archived] draft = [c for c in changes if c.stage == ChangeStage.DRAFT] active = [c for c in changes if c.stage == ChangeStage.IN_PROGRESS] completed = [c for c in changes if c.stage == ChangeStage.COMPLETED] + return draft, active, completed, archived - summary = ( - f"{len(specs)} specs · " - f"{len(draft)} draft · {len(active)} active · " - f"{len(completed)} completed · {len(archived)} archived" - ) - self.query_one("#summary", Static).update(f"[dim]{summary}[/dim]") - - changes_list: ListView = self.query_one("#changes", ListView) - changes_list.clear() - section = self.query_one("#changes_section", Static) - - if not changes: - section.update("[dim]No changes yet. Press [b]n[/b] to create one.[/dim]") - else: - section.update("[b]Changes[/b]") - ordered = draft + active + completed + archived - for change in ordered: - label = ( - f"[{change.stage}] {change.name} " - f"[dim]{change.progress_label}[/dim] " - f"{progress_bar(change)}" + def _ordered(self, changes: list[Change]) -> list[Change]: + """Changes in pipeline order: draft, active, completed, archived.""" + draft, active, completed, archived = self._grouped(changes) + return draft + active + completed + archived + + def _render_dashboard(self, changes: list[Change], specs) -> None: + self._render_title() + self._apply_stat_visibility() + + draft, active, completed, archived = self._grouped(changes) + + self.query_one("#stat-specs", Stat).set_value(len(specs)) + self.query_one("#stat-draft", Stat).set_value(len(draft)) + self.query_one("#stat-active", Stat).set_value(len(active)) + self.query_one("#stat-completed", Stat).set_value(len(completed)) + self.query_one("#stat-archived", Stat).set_value(len(archived)) + + self._render_changes(self._ordered(changes)) + self._render_specs(specs) + + def _matches(self, name: str) -> bool: + return self._filter in name.lower() + + def _render_changes(self, ordered: list[Change]) -> None: + table: DataTable = self.query_one("#changes", DataTable) + # Remember the highlighted change so a live refresh doesn't yank the + # cursor back to the top while the user is reading the list. + previous = self._highlighted_change_name() + table.clear() + + total = len(ordered) + # Filtering narrows the already-grouped list, so stage ordering and + # progress survive it untouched. + ordered = [change for change in ordered if self._matches(change.name)] + + if not ordered: + if self._filter and total: + self._show_changes_empty( + f"No change matches [b]{self._filter}[/].\n" + "[$text-muted]Press escape to clear the filter.[/]" + ) + else: + self._show_changes_empty( + "No changes yet.\n[$text-muted]Press n to create one.[/]" ) - changes_list.append(ListItem(Label(label), name=change.name)) + return + self._show_changes_empty(None) + + for change in ordered: + style = stage_style(change.stage) + # Tasks and Modified are deliberately unstyled: column position + # carries the hierarchy, and muting them costs contrast on the + # row-cursor background without buying much. + cells = [ + Content.from_markup(f"[${style.token}]{style.glyph} {change.stage}[/]"), + change.name, + progress_bar(change), + change.progress_label, + format_modified(change.last_modified), + ] + if self._compact: + cells = cells[: len(self.COMPACT_COLUMNS)] + table.add_row(*cells, key=change.name) + + if previous is not None: + try: + table.move_cursor(row=table.get_row_index(previous)) + return + except Exception: + pass + table.move_cursor(row=0) - specs_section = self.query_one("#specs_section", Static) + def _render_specs(self, specs) -> None: specs_list: ListView = self.query_one("#specs", ListView) specs_list.clear() + total = len(specs) + specs = [spec for spec in specs if self._matches(spec.name)] if not specs: - specs_section.update("[dim]No specs.[/dim]") - else: - specs_section.update("[b]Specifications[/b]") - for spec in specs: - noun = "requirement" if spec.requirement_count == 1 else "requirements" - specs_list.append( - ListItem( - Label( - f"{spec.name} [dim]{spec.requirement_count} {noun}[/dim]" - ), - name=spec.name, - ) + if self._filter and total: + self._show_specs_empty(f"No spec matches [b]{self._filter}[/].") + else: + self._show_specs_empty("No specs yet.") + return + self._show_specs_empty(None) + for spec in specs: + noun = "requirement" if spec.requirement_count == 1 else "requirements" + specs_list.append( + ListItem( + Label( + f"{spec.name} [$text-muted]{spec.requirement_count} {noun}[/]" + ), + name=spec.name, ) + ) + + def _render_title(self) -> None: + # The title names the screen and nothing else — the root path and the + # live indicator live in the StatusBar, where no message overwrites them. + self.query_one("#title", Static).update("OpenSpec Dashboard") # -- selection ------------------------------------------------------- + def _highlighted_change_name(self) -> str | None: + """The change under the table cursor, by row key rather than by index.""" + table: DataTable = self.query_one("#changes", DataTable) + if table.row_count == 0: + return None + try: + row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate) + except Exception: + return None + return row_key.value + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if event.row_key is not None and event.row_key.value: + self.app.open_detail(event.row_key.value) # type: ignore[attr-defined] + def on_list_view_selected(self, event: ListView.Selected) -> None: - if event.list_view.id == "changes" and event.item.name: - self.app.open_detail(event.item.name) # type: ignore[attr-defined] + if event.list_view.id == "specs" and event.item.name: + self.app.open_spec(event.item.name) # type: ignore[attr-defined] # -- actions --------------------------------------------------------- @@ -180,24 +380,40 @@ def action_refresh(self) -> None: # one redundant poll reload immediately after a forced refresh. self.client().reset_change_cache() - def _render_title(self) -> None: - try: - root = self.client().root() # cached; no subprocess after first resolve - except Exception: - root = None - label = "[b]OpenSpec Dashboard[/b]" - if root is not None: - label += f" [dim]{root}[/dim]" - label += f" {self.app.live_label}" # type: ignore[attr-defined] - self.query_one("#title", Static).update(label) - def action_toggle_live(self) -> None: self.app.toggle_live_refresh() # type: ignore[attr-defined] - self._render_title() + + # -- filtering ------------------------------------------------------- + + def action_show_filter(self) -> None: + self.query_one("#filter", FilterBar).reveal() + + def action_clear_filter(self) -> None: + self.query_one("#filter", FilterBar).dismiss_filter() + self._filter = "" + self._repaint() + self.query_one("#changes", DataTable).focus() + + def on_input_changed(self, event) -> None: + if event.input.id != FilterBar.INPUT_ID: + return + self._filter = event.value.strip().lower() + self._repaint() + + def _repaint(self) -> None: + """Re-render from data already loaded, applying the current filter.""" + self._render_changes(self._ordered(self._changes)) + self._render_specs(self._specs) def action_open_selected(self) -> None: - changes: ListView = self.query_one("#changes", ListView) - if changes.highlighted_child is not None: - name = changes.highlighted_child.name + # Enter opens whichever list has focus, so the specs list is reachable + # by keyboard the same way the changes table is. + specs: ListView = self.query_one("#specs", ListView) + if specs.has_focus and specs.highlighted_child is not None: + name = specs.highlighted_child.name if name: - self.app.open_detail(name) # type: ignore[attr-defined] + self.app.open_spec(name) # type: ignore[attr-defined] + return + name = self._highlighted_change_name() + if name: + self.app.open_detail(name) # type: ignore[attr-defined] diff --git a/openspec_ui/screens/detail.py b/openspec_ui/screens/detail.py index 7ec0970..0b1792d 100644 --- a/openspec_ui/screens/detail.py +++ b/openspec_ui/screens/detail.py @@ -6,14 +6,29 @@ import subprocess from pathlib import Path -from rich.markdown import Markdown +from textual import work from textual.binding import Binding -from textual.containers import Vertical +from textual.containers import VerticalScroll +from textual.content import Content from textual.screen import Screen -from textual.widgets import Footer, Header, Static, TabbedContent, TabPane - -from openspec_ui.client import OpenSpecError +from textual.widgets import ( + Collapsible, + Footer, + Header, + MarkdownViewer, + Static, + TabbedContent, + TabPane, +) +from textual.worker import get_current_worker + +from openspec_ui.client import OpenSpecError, parse_tasks from openspec_ui.screens.confirm import ConfirmScreen +from openspec_ui.widgets import EmptyState, StatusBar + +#: Below this width the table of contents costs more room than the document it +#: indexes, so it is hidden regardless of the user's toggle. +TOC_MIN_WIDTH = 100 # Order of the artifact tabs. "specs" may list several files in one pane. @@ -23,54 +38,118 @@ class ArtifactPane(TabPane): """A tab pane that renders one artifact (or a group of spec files) read-only. - Single artifacts are rendered as rich markdown (headings, bold, lists, - fenced code blocks in monospace). When multiple spec files share a pane, - each is rendered under a header as plain monospace text — Static can't - compose several Markdown documents cleanly in one view, and plain text - preserves ASCII alignment just as well. Content is re-read from disk on - every render (thin-client: nothing is cached). + Each file gets its own `MarkdownViewer`: scroll-aware, syntax-highlighted + fences, and a navigable table of contents. When an artifact group resolves + to several files, they become nested tabs rather than one concatenated + document. Content is re-read from disk on every render (thin-client: + nothing is cached). """ def __init__(self, title: str, paths: list[Path], pane_id: str) -> None: super().__init__(title, id=pane_id) self._paths = list(paths) - def compose(self): - yield Static() - - def on_mount(self) -> None: - self.render_content() - def paths(self) -> list[Path]: return list(self._paths) - def render_content(self) -> None: - static: Static = self.query_one(Static) + def _toc_wanted(self) -> bool: + # During the first compose the pane may not be attached to a screen + # yet; the screen re-applies the real value once the tabs are built. + try: + return bool(getattr(self.screen, "toc_visible", False)) + except Exception: + return False + + def _viewer(self, path: Path) -> MarkdownViewer: + try: + text = self.app.client.read_artifact(path) # type: ignore[attr-defined] + except OpenSpecError as exc: + # Render the failure as markdown so the pane still behaves like a + # document rather than switching widget type on the error path. + text = f"# Could not read {path.name}\n\n```\n{exc}\n```" + return MarkdownViewer( + text, + show_table_of_contents=self._toc_wanted(), + classes="artifact-viewer", + ) + + def compose(self): if not self._paths: - static.update("[dim](no artifact)[/dim]") + yield EmptyState("[$foreground-muted]No such artifact.[/]") return if len(self._paths) == 1: - p = self._paths[0] - try: - text = self.app.client.read_artifact(p) # type: ignore[attr-defined] - except OpenSpecError as exc: - static.update(f"[red]Error reading {p.name}:[/red] {exc}") - return - static.update(Markdown(text)) + yield self._viewer(self._paths[0]) return - # Multiple files: plain monospace under headers (ASCII preserved). - parts: list[str] = [] - for p in self._paths: - try: - text = self.app.client.read_artifact(p) # type: ignore[attr-defined] - except OpenSpecError as exc: - text = f"[red]Error reading {p.name}:[/red] {exc}" - header = f"[b]{p.name}[/b] [dim]{p}[/dim]" - parts.append(f"{header}\n{'─' * 40}\n{text}") - static.update("\n\n".join(parts)) + # Nested tabs are built inside compose rather than by mounting a + # TabbedContent and adding panes: its positional arguments are tab + # titles, not panes, and add_pane needs children that only exist once + # mounting has completed. + with TabbedContent(classes="artifact-files"): + for index, path in enumerate(self._paths): + with TabPane(path.name, id=f"{self.id}-file-{index}"): + yield self._viewer(path) def reload_content(self) -> None: - self.render_content() + """Re-read every file from disk and rebuild the pane.""" + self.refresh(recompose=True) + + +class TasksPane(ArtifactPane): + """The Tasks artifact rendered as its parsed checklist. + + Read-only by design: the thin-client invariant means task edits go through + the openspec CLI or $EDITOR, never through this pane. + """ + + def compose(self): + if not self._paths: + yield EmptyState( + "No tasks defined for this change.\n" + "[$foreground-muted]Press e to add some.[/]" + ) + return + + path = self._paths[0] + try: + text = self.app.client.read_artifact(path) # type: ignore[attr-defined] + except OpenSpecError as exc: + yield EmptyState(f"[$text-error]Could not read {path.name}: {exc}[/]") + return + + sections = parse_tasks(text) + if not sections: + # Nothing checkbox-shaped in there — show the file as written + # rather than an empty checklist that implies there are no tasks. + yield self._viewer(path) + return + + done = sum(section.completed for section in sections) + total = sum(section.total for section in sections) + percent = round(done / total * 100) if total else 0 + with VerticalScroll(classes="tasks-body"): + yield Static( + f"{done}/{total} tasks complete ({percent}%)", + classes="tasks-summary", + ) + for section in sections: + complete = section.completed == section.total + title = f"{section.title or 'Tasks'} ({section.label})" + # Finished sections start collapsed so the eye lands on the + # work that is left. + with Collapsible(title=title, collapsed=complete, classes="task-section"): + for task in section.tasks: + # $text is a Content template variable, so the task + # text is substituted rather than parsed as markup — + # brackets in a task description stay literal. + yield Static( + Content.from_markup( + "[$text-success]✓[/] [$foreground-muted]$text[/]" + if task.done + else "[$text-accent]○[/] $text", + text=task.text, + ), + classes="task-item", + ) class DetailScreen(Screen): @@ -82,17 +161,44 @@ class DetailScreen(Screen): Binding("right", "next_tab", "Next tab", show=True), Binding("h", "prev_tab", show=False), Binding("l", "next_tab", show=False), + Binding("t", "toggle_toc", "Contents", show=True), ] def __init__(self, change_name: str) -> None: super().__init__() self.change_name = change_name self._pane_ids: list[str] = [] + # User preference for the table of contents; the width guard below can + # still override it. Session-only, like every other bit of UI state. + self._toc_wanted = True + + @property + def toc_visible(self) -> bool: + """Whether panes should show a table of contents right now.""" + return self._toc_wanted and self.size.width >= TOC_MIN_WIDTH + + def on_resize(self) -> None: + self._apply_toc() + + def _apply_toc(self) -> None: + visible = self.toc_visible + for viewer in self.query(MarkdownViewer): + viewer.show_table_of_contents = visible + + def action_toggle_toc(self) -> None: + if self.size.width < TOC_MIN_WIDTH: + self.app.notify( + "Not enough width for the table of contents.", severity="warning" + ) + return + self._toc_wanted = not self._toc_wanted + self._apply_toc() def compose(self) -> None: yield Header(show_clock=False) yield Static("", id="title") yield TabbedContent(id="tabs") + yield StatusBar() yield Footer() def on_mount(self) -> None: @@ -102,13 +208,32 @@ def client(self): return self.app.client # type: ignore[attr-defined] def reload(self) -> None: + """Kick off a background read. Rendering happens on the UI thread.""" + self.query_one("#tabs", TabbedContent).loading = True + self._load() + + @work(thread=True, exclusive=True, group="openspec-read") + def _load(self) -> None: + # `artifact_paths` shells out to `openspec status`; keep it off the UI + # thread so opening a change never freezes input. + worker = get_current_worker() try: artifact_paths = self.client().artifact_paths(self.change_name) - root = self.client().root() except OpenSpecError as exc: - self.query_one("#title", Static).update(f"[red]Error:[/red] {exc}") + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_error, str(exc)) return - self._set_title(root) + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_load, artifact_paths) + + def _finish_error(self, message: str) -> None: + self.query_one("#tabs", TabbedContent).loading = False + self.app.notify(message, title="Could not read change", severity="error") + self._set_title() + + def _finish_load(self, artifact_paths: dict[str, list[Path]]) -> None: + self.query_one("#tabs", TabbedContent).loading = False + self._set_title() tabs: TabbedContent = self.query_one("#tabs", TabbedContent) tabs.clear_panes() self._pane_ids = [] @@ -116,16 +241,17 @@ def reload(self) -> None: paths = artifact_paths.get(art_id, []) pane_id = f"pane-{art_id}" self._pane_ids.append(pane_id) - tabs.add_pane(ArtifactPane(art_id.capitalize(), paths, pane_id=pane_id)) + # Tasks get the checklist rendering; everything else is markdown. + pane_type = TasksPane if art_id == "tasks" else ArtifactPane + tabs.add_pane(pane_type(art_id.capitalize(), paths, pane_id=pane_id)) # Default to the first tab. if self._pane_ids: tabs.active = self._pane_ids[0] + self._apply_toc() - def _set_title(self, root: Path | None = None) -> None: - label = f"[b]Change:[/b] {self.change_name}" - if root is not None: - label += f" [dim]{root}[/dim]" - self.query_one("#title", Static).update(label) + def _set_title(self) -> None: + # Names the change only; the root path lives in the StatusBar. + self.query_one("#title", Static).update(f"Change: {self.change_name}") # -- tab navigation ------------------------------------------------- @@ -159,7 +285,7 @@ def _current_pane(self) -> ArtifactPane | None: def action_edit(self) -> None: pane = self._current_pane() if pane is None or not pane.paths(): - self._notify("Nothing to edit in this tab.", style="yellow") + self.app.notify("Nothing to edit in this tab.", severity="warning") return editor = self.client().editor_command() # Warn the user before the terminal jumps into the editor. @@ -176,15 +302,17 @@ def action_edit(self) -> None: def _after_edit_confirm(self, confirmed: bool, pane: "ArtifactPane", editor: str) -> None: if not confirmed: - self._notify("Edit cancelled.", style="yellow") + self.app.notify("Edit cancelled.", severity="warning") return if editor == "vi": - self._notify("$EDITOR not set; using vi.", style="yellow") + self.app.notify("$EDITOR not set; using vi.", severity="warning") for path in pane.paths(): self._run_editor(editor, path) # Re-read from disk and re-render the active pane (thin-client invariant). pane.reload_content() - self._notify(f"Reloaded {len(pane.paths())} artifact(s).", style="green") + self.app.notify( + f"Reloaded {len(pane.paths())} artifact(s).", severity="information" + ) def _run_editor(self, editor: str, path: Path) -> None: # Flag the suspended session so the live-refresh poller skips ticks for @@ -199,18 +327,6 @@ def _run_editor(self, editor: str, path: Path) -> None: finally: self.app._in_editor = False # type: ignore[attr-defined] - def _notify(self, message: str, *, style: str = "white") -> None: - self.app.bell() # type: ignore[attr-defined] - self.query_one("#title", Static).update(f"[{style}]{message}[/{style}]") - self.set_timer(2.0, self._restore_title) - - def _restore_title(self) -> None: - try: - root = self.client().root() - except Exception: - root = None - self._set_title(root) - # -- other actions --------------------------------------------------- def action_refresh(self) -> None: diff --git a/openspec_ui/screens/kanban.py b/openspec_ui/screens/kanban.py index eddcc2e..8c489e0 100644 --- a/openspec_ui/screens/kanban.py +++ b/openspec_ui/screens/kanban.py @@ -2,17 +2,46 @@ from __future__ import annotations +from textual import work from textual.binding import Binding -from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Footer, Header, Label, ListItem, ListView, Static +from textual.worker import get_current_worker from openspec_ui.client import Change, ChangeStage +from openspec_ui.theme import progress_bar, stage_style +from openspec_ui.widgets import EmptyState, FilterBar, StatusBar STAGES = ChangeStage.all() +#: Cards are narrow, so their bar is shorter than the dashboard's. Sized to +#: fit a column at 80 terminal columns, where each of the four is ~14 cells. +CARD_PROGRESS_WIDTH = 10 + + +class ChangeCard(Vertical): + """One change as a kanban card: name, progress bar, task count. + + Structure and styling are split: the pieces are separate widgets carrying + CSS classes, rather than one Label with colour markup baked into a string. + """ + + def __init__(self, change: Change) -> None: + super().__init__(classes=f"card {stage_style(change.stage).css_class}") + self.change = change + + def compose(self): + yield Static(self.change.name, classes="card-name") + yield Static( + progress_bar(self.change, width=CARD_PROGRESS_WIDTH), + classes="card-progress", + ) + yield Static(self.change.progress_label, classes="card-tasks") + + class KanbanColumn(Vertical): """A single kanban column: a header + a list of change cards.""" @@ -21,24 +50,48 @@ def __init__(self, stage: str) -> None: self.stage = stage def compose(self): - yield Label(self.stage, classes="column-title") + # The heading carries the stage's own colour class, so a column is + # identifiable without reading its label. Name and count are separate + # widgets so the count stays pinned right and the name ellipsizes + # under it — a trailing count gets truncated away on narrow columns. + style = stage_style(self.stage) + with Horizontal(classes=f"column-title {style.css_class}"): + yield Label( + f"{style.glyph} {self.stage}", classes="column-title-name" + ) + yield Label("0", classes="column-title-count") yield ListView(classes="column-list") + yield EmptyState("", id=None) + + def _placeholder(self) -> EmptyState: + return self.query_one(EmptyState) def set_cards(self, changes: list[Change]) -> None: lv: ListView = self.query_one(ListView) lv.clear() for change in changes: - lv.append( - ListItem( - Label( - f"{change.name}\n[dim]{change.progress_label}[/dim]", - classes="card", - ), - name=change.name, - ) - ) - title: Label = self.query_one(".column-title", Label) - title.update(f"{self.stage} ({len(changes)})") + lv.append(ListItem(ChangeCard(change), name=change.name)) + self.query_one(".column-title-count", Label).update(str(len(changes))) + # An empty column reads as a deliberate state, not a failed render. + self._show_placeholder("[$text-muted]nothing here[/]" if not changes else None) + + def set_error(self, message: str) -> None: + self._show_placeholder(f"[$text-error]{message}[/]") + + def set_placeholder(self, message: str) -> None: + """Override the empty-column placeholder (e.g. while filtering).""" + self._show_placeholder(message) + + def _show_placeholder(self, message: str | None) -> None: + placeholder = self._placeholder() + lv = self.query_one(ListView) + if message is None: + placeholder.display = False + lv.display = True + return + placeholder.set_message(message) + placeholder.display = True + lv.display = False class KanbanScreen(Screen): @@ -52,26 +105,32 @@ class KanbanScreen(Screen): Binding("h", "focus_prev", show=False), Binding("l", "focus_next", show=False), Binding("enter", "open_selected", "Open", show=True), + Binding("slash", "show_filter", "Filter", show=True), + Binding("escape", "clear_filter", "Clear filter", show=False), ] def __init__(self) -> None: super().__init__() self._columns: dict[str, KanbanColumn] = {} + self._focused_once = False + self._changes: list[Change] = [] + # Session-only; re-applied after every reload. + self._filter = "" def compose(self) -> None: yield Header(show_clock=False) - yield Static("[b]Kanban — change pipeline[/b]", id="title") + yield Static("Kanban — change pipeline", id="title") + yield FilterBar(id="filter") with Horizontal(id="board"): for stage in STAGES: col = KanbanColumn(stage) self._columns[stage] = col yield col + yield StatusBar() yield Footer() def on_mount(self) -> None: self.reload() - # Start focus on the first column that has cards (or the first column). - self._focus_initial_column() # Live refresh: poll for external changes (agent edits, edits in # another window) on a cheap mtime-gated cadence. self.set_interval(1.0, self._tick) @@ -82,28 +141,80 @@ def client(self): return self.app.client # type: ignore[attr-defined] def reload(self) -> None: + """Kick off a background read. Rendering happens on the UI thread.""" + self.query_one("#board", Horizontal).loading = True + self._load() + + @work(thread=True, exclusive=True, group="openspec-read") + def _load(self) -> None: + # Off the UI thread; `exclusive` cancels a superseded read and the + # is_cancelled checks stop a stale result landing over a newer one. + worker = get_current_worker() try: changes = self.client().list_changes() except Exception as exc: - self.query_one("#title", Static).update( - f"[red]Error:[/red] {exc}" - ) - for col in self._columns.values(): - col.set_cards([]) + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_error, str(exc)) return + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_load, changes) + + def _finish_load(self, changes: list[Change]) -> None: + self.query_one("#board", Horizontal).loading = False + self._changes = changes + self._repaint() + # Focus is set after the first load lands, so it can land on the first + # column that actually has cards rather than on an empty one. + if not self._focused_once: + self._focused_once = True + self._focus_initial_column() + + def _repaint(self) -> None: + """Re-group and re-render already-loaded changes under the filter.""" + matching = [c for c in self._changes if self._filter in c.name.lower()] grouped: dict[str, list[Change]] = {stage: [] for stage in STAGES} - for change in changes: - grouped[change.stage].append(change) + for change in matching: + grouped.setdefault(change.stage, []).append(change) + filtered_out = self._filter and not matching and self._changes for stage, col in self._columns.items(): - col.set_cards(grouped[stage]) - self._render_title() + col.set_cards(grouped.get(stage, [])) + if filtered_out: + col.set_placeholder("[$foreground-muted]no match[/]") + + # -- filtering ------------------------------------------------------- + + def action_show_filter(self) -> None: + self.query_one("#filter", FilterBar).reveal() + + def action_clear_filter(self) -> None: + self.query_one("#filter", FilterBar).dismiss_filter() + self._filter = "" + self._repaint() + self._focus_initial_column() + + def on_input_changed(self, event) -> None: + if event.input.id != FilterBar.INPUT_ID: + return + self._filter = event.value.strip().lower() + self._repaint() + + def _finish_error(self, message: str) -> None: + self.query_one("#board", Horizontal).loading = False + self._render_error(message) # -- live refresh ---------------------------------------------------- def _render_title(self) -> None: - self.query_one("#title", Static).update( - f"[b]Kanban — change pipeline[/b] {self.app.live_label}" # type: ignore[attr-defined] - ) + # State (root, live/paused) belongs to the StatusBar; the title only + # names the screen so nothing can overwrite the indicator. + self.query_one("#title", Static).update("Kanban — change pipeline") + + def _render_error(self, message: str) -> None: + """Report a failed read as a toast plus a persistent inline state.""" + self.app.notify(message, title="Could not read openspec", severity="error") + for col in self._columns.values(): + col.set_cards([]) + col.set_error(message) def _tick(self) -> None: # Only poll when this screen is visible (top of the stack), live refresh @@ -184,4 +295,3 @@ def action_refresh(self) -> None: def action_toggle_live(self) -> None: self.app.toggle_live_refresh() # type: ignore[attr-defined] - self._render_title() diff --git a/openspec_ui/screens/new_change.py b/openspec_ui/screens/new_change.py index 5c1d832..f285f51 100644 --- a/openspec_ui/screens/new_change.py +++ b/openspec_ui/screens/new_change.py @@ -9,27 +9,6 @@ class NewChangeScreen(ModalScreen): - CSS = """ - NewChangeScreen { - align: center middle; - } - #new-change { - width: 60; - max-width: 90%; - height: auto; - border: round $primary; - background: $panel; - padding: 1 2; - } - Label.title { - text-style: bold; - padding-bottom: 1; - } - #status { - padding-top: 1; - } - """ - BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)] def compose(self): diff --git a/openspec_ui/screens/palette.py b/openspec_ui/screens/palette.py deleted file mode 100644 index 3f11529..0000000 --- a/openspec_ui/screens/palette.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Command palette: a modal listing available actions.""" - -from __future__ import annotations - -from textual.binding import Binding -from textual.containers import Vertical -from textual.screen import ModalScreen -from textual.widgets import Label, ListItem, ListView - - -class PaletteItem(ListItem): - def __init__(self, label: str, action: str) -> None: - super().__init__(Label(label), name=action) - - -class CommandPaletteScreen(ModalScreen): - CSS = """ - CommandPaletteScreen { - align: center middle; - } - #palette { - width: 60; - max-width: 90%; - height: auto; - max-height: 70%; - border: round $primary; - background: $panel; - } - #palette Label.title { - padding: 0 1; - text-style: bold; - background: $primary; - color: $text; - } - """ - - BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)] - - def __init__(self) -> None: - super().__init__() - # (label, action_method_name) — actions dispatched on the app. - self._actions = [ - ("Open dashboard", "open_dashboard"), - ("Open kanban", "open_kanban"), - ("Refresh current view", "refresh_active"), - ("New change", "open_new_change"), - ("Quit", "quit"), - ] - - def compose(self): - with Vertical(id="palette"): - yield Label("Command palette", classes="title") - yield ListView( - *[PaletteItem(label, action) for label, action in self._actions], - id="list", - ) - - def on_mount(self) -> None: - self.query_one("#list", ListView).focus() - - def on_list_view_selected(self, event: ListView.Selected) -> None: - action = event.item.name - self.dismiss(action) - - def action_cancel(self) -> None: - self.dismiss(None) diff --git a/openspec_ui/screens/spec_detail.py b/openspec_ui/screens/spec_detail.py new file mode 100644 index 0000000..c2a73aa --- /dev/null +++ b/openspec_ui/screens/spec_detail.py @@ -0,0 +1,136 @@ +"""Spec detail screen: a spec's overview and its requirements, drilled down. + +A spec with nine requirements and thirty scenarios is unreadable flat, so each +requirement is a `Collapsible` that reveals its scenarios on demand. +""" + +from __future__ import annotations + +from textual import work +from textual.binding import Binding +from textual.containers import VerticalScroll +from textual.screen import Screen +from textual.widgets import Collapsible, Footer, Header, Markdown, Static +from textual.worker import get_current_worker + +from openspec_ui.client import OpenSpecError, SpecDetail +from openspec_ui.widgets import EmptyState, StatusBar + + +class SpecDetailScreen(Screen): + BINDINGS = [ + Binding("d", "dashboard", "Dashboard", show=True), + Binding("r", "refresh", "Refresh", show=True), + Binding("escape", "dashboard", "Back", show=True), + ] + + def __init__(self, spec_name: str) -> None: + super().__init__() + self.spec_name = spec_name + + def compose(self): + yield Header(show_clock=False) + yield Static("", id="title") + yield VerticalScroll(id="spec-body") + yield StatusBar() + yield Footer() + + def on_mount(self) -> None: + self._set_title() + self.reload() + + def client(self): + return self.app.client # type: ignore[attr-defined] + + def _set_title(self) -> None: + self.query_one("#title", Static).update(f"Spec: {self.spec_name}") + + # -- data ------------------------------------------------------------ + + def reload(self) -> None: + """Kick off a background read. Rendering happens on the UI thread.""" + self.query_one("#spec-body", VerticalScroll).loading = True + self._load() + + @work(thread=True, exclusive=True, group="openspec-read") + def _load(self) -> None: + worker = get_current_worker() + try: + detail = self.client().show_spec(self.spec_name) + except OpenSpecError as exc: + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_error, str(exc)) + return + if not worker.is_cancelled: + self.app.call_from_thread(self._finish_load, detail) + + # -- rendering ------------------------------------------------------- + + def _body(self) -> VerticalScroll: + return self.query_one("#spec-body", VerticalScroll) + + def _finish_error(self, message: str) -> None: + body = self._body() + body.loading = False + body.remove_children() + # Toast so it is noticed, inline state so it stays readable. + self.app.notify(message, title="Could not read spec", severity="error") + body.mount(EmptyState(f"[$text-error]{message}[/]")) + + def _finish_load(self, detail: SpecDetail) -> None: + body = self._body() + body.loading = False + body.remove_children() + self._set_title() + + if detail.overview: + body.mount(Markdown(detail.overview, classes="spec-overview")) + + if not detail.requirements: + body.mount( + EmptyState( + "This spec declares no requirements yet.\n" + "[$text-muted]Press e on its change to add some.[/]" + ) + ) + return + + count = len(detail.requirements) + noun = "requirement" if count == 1 else "requirements" + body.mount(Static(f"{count} {noun}", classes="spec-count")) + + for index, requirement in enumerate(detail.requirements, start=1): + if requirement.scenarios: + content = Markdown("\n\n".join(requirement.scenarios)) + else: + content = Static( + "[$text-muted]No scenarios.[/]", classes="requirement-empty" + ) + # Content must be passed to the constructor: mounting onto a + # Collapsible afterwards attaches to the Collapsible itself rather + # than to its Contents, which renders the child unconditionally and + # leaves expand/collapse doing nothing. + # + # The scenario count sits in the title so the shape of a requirement + # is readable without expanding it. + count = len(requirement.scenarios) + noun = "scenario" if count == 1 else "scenarios" + body.mount( + Collapsible( + content, + # Count goes before the requirement text, not after it: + # requirement text is a long SHALL statement that gets + # truncated at the pane's width, which would take a + # trailing count with it. + title=f"{index}. ({count} {noun}) {requirement.text}", + classes="requirement", + ) + ) + + # -- actions --------------------------------------------------------- + + def action_refresh(self) -> None: + self.reload() + + def action_dashboard(self) -> None: + self.app.open_dashboard() # type: ignore[attr-defined] diff --git a/openspec_ui/theme.py b/openspec_ui/theme.py new file mode 100644 index 0000000..b86b0f4 --- /dev/null +++ b/openspec_ui/theme.py @@ -0,0 +1,97 @@ +"""Stage presentation: the single source of truth for what a stage looks like. + +Every place a pipeline stage is drawn — the kanban column heading, a card's +accent border, the dashboard's stage badge — reads its colour and glyph from +`STAGE_STYLE` here. Defining it once is what makes "the same stage is the same +colour everywhere" mechanically true rather than a convention three call sites +have to remember. + +Colours are semantic Textual tokens, never literals, so the mapping survives a +theme switch and reads correctly on light, dark, and ANSI themes. + +Only tokens that resolve to a concrete colour are used here. Textual's `$text`, +`$text-muted` and `$text-disabled` are `auto` values that pick their colour from +the background they are drawn on; that works in a stylesheet, but inside +`Content.from_markup` — which is how DataTable cells are coloured — there is no +backdrop to resolve against and `auto` collapses to black, which is invisible on +any dark theme. `$foreground-muted`, `$foreground-disabled`, `$text-accent` and +`$text-success` are concrete and theme-aware, so they behave in both places. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from textual.content import Content + +from openspec_ui.client import Change, ChangeStage + +__all__ = [ + "StageStyle", + "STAGE_STYLE", + "stage_style", + "stage_markup", + "progress_bar", + "PROGRESS_WIDTH", +] + +#: Default width of a progress bar, in cells. +PROGRESS_WIDTH = 16 + + +@dataclass(frozen=True) +class StageStyle: + """How one pipeline stage is drawn.""" + + #: Textual theme token used for this stage's text (no leading `$`). + token: str + #: Glyph prefixed to the stage in badges and column headings. + glyph: str + #: CSS class applied to widgets representing this stage. + css_class: str + + +STAGE_STYLE: dict[str, StageStyle] = { + ChangeStage.DRAFT: StageStyle( + token="foreground-muted", glyph="○", css_class="stage-draft" + ), + ChangeStage.IN_PROGRESS: StageStyle( + token="text-accent", glyph="◆", css_class="stage-active" + ), + ChangeStage.COMPLETED: StageStyle( + token="text-success", glyph="●", css_class="stage-completed" + ), + ChangeStage.ARCHIVED: StageStyle( + token="foreground-disabled", glyph="·", css_class="stage-archived" + ), +} + +#: Fallback for an unrecognised stage, so a future CLI stage never crashes a render. +_FALLBACK = StageStyle(token="foreground-muted", glyph="·", css_class="stage-draft") + + +def stage_style(stage: str) -> StageStyle: + """Return the presentation for `stage`, falling back rather than raising.""" + return STAGE_STYLE.get(stage, _FALLBACK) + + +def stage_markup(stage: str) -> str: + """Render a stage as console markup: its glyph and name in its stage colour.""" + style = stage_style(stage) + return f"[${style.token}]{style.glyph} {stage}[/]" + + +def progress_bar(change: Change, width: int = PROGRESS_WIDTH) -> Content: + """A block-glyph progress bar tinted with the change's stage colour. + + The track uses $foreground-muted for the reason given above: an `auto` + token has no backdrop to resolve against inside Content markup and would + collapse to black. + """ + token = stage_style(change.stage).token + if change.total_tasks <= 0: + return Content.from_markup(f"[$foreground-muted]{'─' * width}[/]") + filled = round((change.completed_tasks / change.total_tasks) * width) + return Content.from_markup( + f"[${token}]{'█' * filled}[/][$foreground-muted]{'░' * (width - filled)}[/]" + ) diff --git a/openspec_ui/widgets.py b/openspec_ui/widgets.py new file mode 100644 index 0000000..7affdd0 --- /dev/null +++ b/openspec_ui/widgets.py @@ -0,0 +1,114 @@ +"""Small shared widgets: the status bar and the empty-state placeholder.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Digits, Input, Label, Static + +__all__ = ["StatusBar", "EmptyState", "Stat", "FilterBar"] + + +class StatusBar(Horizontal): + """Persistent strip carrying the resolved root and the live-refresh state. + + This owns the state display that screen titles used to carry. Because + transient messages now go to toasts, nothing overwrites it, so the root and + the live indicator stay visible at all times. + + The indicator tracks `App.live_refresh` (a reactive), so pressing the + pause key updates every mounted status bar without each screen having to + push the change itself. + """ + + def compose(self) -> ComposeResult: + yield Static("", id="status-root") + yield Static("", id="status-live") + + def on_mount(self) -> None: + self._render_root() + self._render_live(self.app.live_refresh) # type: ignore[attr-defined] + # Re-render whenever the app's live-refresh flag flips. + self.watch(self.app, "live_refresh", self._render_live, init=False) + + def _render_root(self) -> None: + root = getattr(self.app, "root", None) + self.query_one("#status-root", Static).update( + f"[$text-muted]{root}[/]" if root else "" + ) + + def _render_live(self, live: bool) -> None: + self.query_one("#status-live", Static).update( + "[$text-success]● live[/]" if live else "[$text-muted]○ paused[/]" + ) + + +class Stat(Vertical): + """One headline figure with its label, e.g. the count of active changes.""" + + def __init__( + self, label: str, value: int = 0, *, id: str | None = None, classes: str = "" + ) -> None: + super().__init__(id=id, classes=f"stat {classes}".strip()) + self._label = label + self._value = value + + def compose(self) -> ComposeResult: + yield Digits(str(self._value)) + yield Static(self._label, classes="stat-label") + + def set_value(self, value: int) -> None: + self.query_one(Digits).update(str(value)) + + +class FilterBar(Horizontal): + """A hidden-by-default filter input, revealed by `/`. + + The filter narrows data the screen has already loaded — it is never a + re-query — so it costs nothing and cannot fall out of step with disk. The + typed value lives here for the life of the screen and is never persisted. + """ + + INPUT_ID = "filter-input" + + def compose(self) -> ComposeResult: + yield Label("/", classes="filter-prompt") + yield Input(placeholder="filter by name", id=self.INPUT_ID) + + def on_mount(self) -> None: + self.display = False + + @property + def _input(self) -> Input: + return self.query_one(f"#{self.INPUT_ID}", Input) + + @property + def value(self) -> str: + return self._input.value.strip() + + @property + def active(self) -> bool: + return self.display + + def reveal(self) -> None: + self.display = True + self._input.focus() + + def dismiss_filter(self) -> None: + """Clear the filter and hide the bar.""" + self._input.value = "" + self.display = False + + +class EmptyState(Static): + """A centred placeholder for a view with nothing to show. + + Used instead of dim inline text so an empty list reads as a deliberate + state rather than a failed render. + """ + + def __init__(self, message: str, *, id: str | None = None) -> None: + super().__init__(message, id=id, classes="empty-state") + + def set_message(self, message: str) -> None: + self.update(message) diff --git a/pyproject.toml b/pyproject.toml index 7638072..8de13f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,12 @@ classifiers = [ ] # The openspec CLI is consumed over subprocess; supported range mirrors the # @fission-ai/openspec --json shapes this client parses (tested against 1.7.0). +# Floor is 1.0 because the UI relies on theme semantic tokens, `Provider`-based +# command palette discovery, `Widget.loading`, `action_show_help_panel` and +# `Digits` — features that landed across the 0.8x series. Pinning to the 1.x +# line avoids asserting an exact minor for each; tested against 8.2.x. dependencies = [ - "textual>=0.86.0", + "textual>=1.0.0", ] [project.scripts]