Skip to content

feat(tui): revamp the TUI experience - #11

Merged
mrn-dk merged 11 commits into
mainfrom
feat/tui-experience
Aug 3, 2026
Merged

mrn-dk merged 11 commits into
mainfrom
feat/tui-experience

Conversation

@mrn-dk

@mrn-dk mrn-dk commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Implements the OpenSpec change revamp-tui-experience (54/54 tasks).

The TUI was functionally complete but visually flat: styling lived in four
inline CSS strings with no shared tokens, every kanban column wore the same
header colour, the dashboard was a ListView of pre-formatted strings that
could not align, notifications hijacked the title bar and rang the terminal
bell, the command palette was hand-rolled with no matching, and every
openspec subprocess ran on the UI thread.

Presentation

  • All styling moves into one openspec_ui/app.tcss (CSS_PATH), in Textual's
    semantic tokens only — no hex literals, so the app follows the active theme
    including the ANSI ones. openspec_ui/theme.py is the single source of truth
    for how a pipeline stage is drawn, shared by the stylesheet and by table cells.
  • Dashboard becomes an aligned DataTable (stage, name, progress, tasks,
    modified) keyed by change name, over a Digits stat row.
  • Kanban cards become structured widgets with a stage-coloured left border;
    column headings carry their stage glyph and colour.
  • Transient feedback moves to toasts; a persistent StatusBar carries the root
    and the live/paused indicator, so nothing overwrites state any more.
  • Empty and error states render as centred, actionable placeholders.

Structure

  • The bespoke palette screen is deleted in favour of Textual's fuzzy palette
    via two Providers, which also brings in-session theme switching.
  • Artifacts render in MarkdownViewers with a navigable table of contents;
    multi-file artifact groups get one tab per file.
  • Every read moves into thread workers sharing one exclusive group, with a
    loading indicator; superseded reads are cancelled rather than landing stale.
  • ? opens Textual's help panel for the focused screen.

New views

  • Spec browser — a spec's overview plus one expandable Collapsible per
    requirement revealing its scenarios.
  • Tasks pane — tasks.md parsed into a per-section checklist with
    done/total counts. Read-only; edits still go through $EDITOR.
  • Filter — / narrows changes and specs by name on data already loaded,
    re-applied after each live refresh.

Bugs found and fixed along the way

  • OpenSpecClient.root() assigned _root_cache but never read it, so every
    live-refresh tick spawned openspec list --json — once a second, forever.
    The existing spec requires no CLI spawn on an unchanged tick.
  • Textual's $text-muted / $text-disabled are auto values that resolve
    against their backdrop; inside Content.from_markup (how DataTable cells are
    coloured) there is none, so they collapsed to black — invisible on dark themes.
  • Mounting onto a Collapsible attaches outside its Contents, so the spec
    browser's scenarios rendered unconditionally and expand/collapse did nothing.
  • Per-stage tinted column headings collapsed on ANSI themes, where
    $accent-muted and $text-accent resolve to the same palette entry.
  • Kanban headings and card names wrapped at 80 columns, misaligning every card
    below them.

Compatibility

textual>=0.86.0 → >=1.0.0 (themes, Provider discovery, Widget.loading,
action_show_help_panel, Digits). No new runtime dependencies. All existing
key bindings keep their meanings, and the thin-client invariant is unchanged —
nothing is persisted, theme and filter included.

CI gains a check that app.tcss resolves from the built wheel via
importlib.resources: it is package data, not a module, so its absence would
install cleanly and fail only at startup.

Verified headlessly under Textual's test pilot across textual-dark,
textual-light and ansi-dark, and at 80x24, on every screen.

🤖 Generated with Claude Code

Morten R. N. and others added 11 commits August 2, 2026 23:01
All four inline CSS strings (app, palette, new-change, confirm) collapse
into a single openspec_ui/app.tcss loaded via CSS_PATH, expressed only in
Textual's theme tokens so the interface follows the active theme instead of
shipping its own palette.

Adds openspec_ui/theme.py as the single source of truth for how a pipeline
stage is drawn (colour token, glyph, CSS class), and the matching
.stage-* rules for column headings, card accents and dashboard badges.

Replaces the hard-coded [green] live indicator with the $text-success
token — under an ANSI theme the literal rendered as truecolor #008000
rather than resolving through the terminal palette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transient feedback (cancelled edit, editor fallback, reload complete,
failed reads) now goes to Textual toasts instead of overwriting a screen's
#title and ringing the terminal bell, so messages no longer compete with
the state they were painting over.

State moves to a persistent StatusBar docked above the Footer, carrying the
resolved root and the live/paused indicator. App.live_refresh becomes a
reactive so every mounted bar re-renders on toggle without each screen
pushing the change itself.

Failed reads are now reported twice on purpose: a toast so it is noticed,
and a persistent inline state so it stays readable. Kanban columns gain a
centred placeholder for the empty and error cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cost

Dashboard, kanban and detail reads move into thread workers sharing one
exclusive "openspec-read" group, so a slow CLI call no longer freezes input
and a superseded read is cancelled rather than landing over a newer render.
The target widget shows a loading indicator while a read is in flight,
cleared on both success and failure.

Also fixes OpenSpecClient.root(): _root_cache was assigned but never read,
so every live-refresh tick spawned `openspec list --json` — once a second,
forever. The mtime gate existed precisely to avoid that subprocess, and the
openspec-tui spec requires no CLI spawn on an unchanged tick. Idle ticks now
spawn nothing.

Kanban initial focus moves to after the first load lands, so it can land on
the first column that actually has cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting a spec now opens a screen showing its overview and one
Collapsible per requirement, revealing that requirement's scenarios on
demand — a spec with nine requirements and thirty scenarios is unreadable
flat. Data comes from `openspec show --type spec --json`, so the app stays
on the CLI's contract rather than parsing spec.md itself.

Requirement content is passed to the Collapsible constructor rather than
mounted onto it afterwards: mounting attaches to the Collapsible itself
instead of its Contents, which renders the child unconditionally and leaves
expand/collapse doing nothing.

Scenario counts sit before the requirement text, since the long SHALL
statement is truncated at the pane width and would take a trailing count
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deletes screens/palette.py (a hand-rolled ListView modal with no matching)
in favour of two Providers registered in App.COMMANDS: the global actions,
and a dynamic one that enumerates changes and specs so either can be opened
by name. Textual's palette brings fuzzy ranking and the system commands —
including the theme switcher, which is how themes change in-session without
the app owning any theme state.

The dynamic provider reads once per palette session in startup() via
asyncio.to_thread rather than per keystroke on the event loop, since the
client shells out to the openspec CLI.

No Quit entry of our own: Textual's system commands already provide one,
and two identically-named commands read as a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The changes list becomes a DataTable keyed by change name (stage badge,
name, progress, tasks, modified). A ListView of pre-formatted label strings
could not line up columns when names differ in length, which was the main
reason the dashboard read as unstructured. Selection now travels by row key
rather than list index. The dim one-line summary becomes a row of Digits.

Both are behind size guards: the stat row hides under 24 rows, and under 90
columns the table drops Modified and narrows the name column so everything
fits inside 80 cells without scrolling.

Stage tokens move to concrete theme values ($foreground-muted,
$foreground-disabled). Textual's $text-* tokens are `auto` values that
resolve against the background they are drawn on; inside
Content.from_markup — how DataTable cells are coloured — there is no
backdrop to resolve against and `auto` collapses to black, which rendered
the progress-bar track invisible on every dark theme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cards become a ChangeCard widget (name, progress bar, task count) whose
pieces carry CSS classes, instead of one Label with colour markup baked into
a string. Each card takes a stage-coloured left border and each column
heading its stage glyph and colour, both from STAGE_STYLE, so a column is
identifiable without reading its label.

Column heading backgrounds stay neutral: tinting them per stage looks right
on truecolor themes but collapses on ANSI ones, where $accent-muted and
$text-accent resolve to the same palette entry and the heading text
disappears entirely.

progress_bar moves to theme.py so the dashboard and kanban share one
implementation. Card names truncate with an ellipsis rather than wrapping,
which was misaligning every card below them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each artifact now gets a MarkdownViewer: independent scrolling,
syntax-highlighted fences and a navigable table of contents. An artifact
group resolving to several files becomes nested tabs labelled by filename,
which removes the plain-text concatenation the old pane documented as a
limitation of composing several Markdown documents in one Static.

The TOC hides below 100 columns and toggles with `t`, so it never eats the
document it indexes on a narrow terminal.

Nested tabs are built in compose(): TabbedContent's positional arguments are
tab titles rather than panes, and add_pane needs children that exist only
after mounting completes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Tasks pane becomes one Collapsible per tasks.md section, titled with its
done/total count, over an overall progress line. Finished sections start
collapsed so the eye lands on the work that is left.

The parser ignores checkboxes inside fenced code blocks (task files often
show example markup) and collects items appearing before any heading, so a
flat list is not dropped. A file with no checkbox items at all falls back to
markdown rather than showing an empty checklist that implies no tasks
exist; a change with no tasks.md gets an empty state.

The pane stays read-only: task edits go through the openspec CLI or $EDITOR,
per the thin-client invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/` reveals a filter bar on the dashboard and kanban that narrows the
listing by case-insensitive substring on name; escape clears it. Filtering
works on data already loaded rather than re-querying, so it costs nothing,
keeps stage grouping and progress intact, and is re-applied after each
live-refresh reload instead of fighting it. Nothing is persisted.

`?` opens Textual's help panel with the focused screen's bindings. It is
bound as a printable key, so a focused Input swallows it first — typing "?"
into the filter or the new-change name types a "?" rather than opening help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app.tcss is package data rather than a module, so nothing in CI would have
caught it dropping out of the wheel — the app would install cleanly and then
fail at startup with no CSS. CI now resolves it through importlib.resources
from the installed wheel.

Kanban column headings split into a name and a right-pinned count: a
trailing count was truncated away on narrow columns, and a heading wrapping
to two lines pushed that column's cards out of alignment with its
neighbours. Card progress bars shrink to 10 cells and no longer wrap.

README documents the key table, the new screens, theming, and the Textual
1.0 floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mrn-dk
mrn-dk merged commit bf94a0e into main Aug 3, 2026
1 check passed
mrn-dk added a commit that referenced this pull request Aug 3, 2026
* chore: remove add-dark-mode change and stale add-live-kanban tasks.md

* feat(tui): revamp the TUI experience (#11)

* feat(tui): move styling into app.tcss with semantic stage tokens

All four inline CSS strings (app, palette, new-change, confirm) collapse
into a single openspec_ui/app.tcss loaded via CSS_PATH, expressed only in
Textual's theme tokens so the interface follows the active theme instead of
shipping its own palette.

Adds openspec_ui/theme.py as the single source of truth for how a pipeline
stage is drawn (colour token, glyph, CSS class), and the matching
.stage-* rules for column headings, card accents and dashboard badges.

Replaces the hard-coded [green] live indicator with the $text-success
token — under an ANSI theme the literal rendered as truecolor #008000
rather than resolving through the terminal palette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add status bar and replace title messages with toasts

Transient feedback (cancelled edit, editor fallback, reload complete,
failed reads) now goes to Textual toasts instead of overwriting a screen's
#title and ringing the terminal bell, so messages no longer compete with
the state they were painting over.

State moves to a persistent StatusBar docked above the Footer, carrying the
resolved root and the live/paused indicator. App.live_refresh becomes a
reactive so every mounted bar re-renders on toggle without each screen
pushing the change itself.

Failed reads are now reported twice on purpose: a toast so it is noticed,
and a persistent inline state so it stays readable. Kanban columns gain a
centred placeholder for the empty and error cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(tui): run openspec reads off the UI thread and fix idle polling cost

Dashboard, kanban and detail reads move into thread workers sharing one
exclusive "openspec-read" group, so a slow CLI call no longer freezes input
and a superseded read is cancelled rather than landing over a newer render.
The target widget shows a loading indicator while a read is in flight,
cleared on both success and failure.

Also fixes OpenSpecClient.root(): _root_cache was assigned but never read,
so every live-refresh tick spawned `openspec list --json` — once a second,
forever. The mtime gate existed precisely to avoid that subprocess, and the
openspec-tui spec requires no CLI spawn on an unchanged tick. Idle ticks now
spawn nothing.

Kanban initial focus moves to after the first load lands, so it can land on
the first column that actually has cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add spec detail browser

Selecting a spec now opens a screen showing its overview and one
Collapsible per requirement, revealing that requirement's scenarios on
demand — a spec with nine requirements and thirty scenarios is unreadable
flat. Data comes from `openspec show --type spec --json`, so the app stays
on the CLI's contract rather than parsing spec.md itself.

Requirement content is passed to the Collapsible constructor rather than
mounted onto it afterwards: mounting attaches to the Collapsible itself
instead of its Contents, which renders the child unconditionally and leaves
expand/collapse doing nothing.

Scenario counts sit before the requirement text, since the long SHALL
statement is truncated at the pane width and would take a trailing count
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): replace bespoke palette with Textual's fuzzy command palette

Deletes screens/palette.py (a hand-rolled ListView modal with no matching)
in favour of two Providers registered in App.COMMANDS: the global actions,
and a dynamic one that enumerates changes and specs so either can be opened
by name. Textual's palette brings fuzzy ranking and the system commands —
including the theme switcher, which is how themes change in-session without
the app owning any theme state.

The dynamic provider reads once per palette session in startup() via
asyncio.to_thread rather than per keystroke on the event loop, since the
client shells out to the openspec CLI.

No Quit entry of our own: Textual's system commands already provide one,
and two identically-named commands read as a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): rebuild the dashboard around an aligned table and stat row

The changes list becomes a DataTable keyed by change name (stage badge,
name, progress, tasks, modified). A ListView of pre-formatted label strings
could not line up columns when names differ in length, which was the main
reason the dashboard read as unstructured. Selection now travels by row key
rather than list index. The dim one-line summary becomes a row of Digits.

Both are behind size guards: the stat row hides under 24 rows, and under 90
columns the table drops Modified and narrows the name column so everything
fits inside 80 cells without scrolling.

Stage tokens move to concrete theme values ($foreground-muted,
$foreground-disabled). Textual's $text-* tokens are `auto` values that
resolve against the background they are drawn on; inside
Content.from_markup — how DataTable cells are coloured — there is no
backdrop to resolve against and `auto` collapses to black, which rendered
the progress-bar track invisible on every dark theme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): give kanban cards structure and stage colour

Cards become a ChangeCard widget (name, progress bar, task count) whose
pieces carry CSS classes, instead of one Label with colour markup baked into
a string. Each card takes a stage-coloured left border and each column
heading its stage glyph and colour, both from STAGE_STYLE, so a column is
identifiable without reading its label.

Column heading backgrounds stay neutral: tinting them per stage looks right
on truecolor themes but collapses on ANSI ones, where $accent-muted and
$text-accent resolve to the same palette entry and the heading text
disappears entirely.

progress_bar moves to theme.py so the dashboard and kanban share one
implementation. Card names truncate with an ellipsis rather than wrapping,
which was misaligning every card below them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): render artifacts with MarkdownViewer and per-file tabs

Each artifact now gets a MarkdownViewer: independent scrolling,
syntax-highlighted fences and a navigable table of contents. An artifact
group resolving to several files becomes nested tabs labelled by filename,
which removes the plain-text concatenation the old pane documented as a
limitation of composing several Markdown documents in one Static.

The TOC hides below 100 columns and toggles with `t`, so it never eats the
document it indexes on a narrow terminal.

Nested tabs are built in compose(): TabbedContent's positional arguments are
tab titles rather than panes, and add_pane needs children that exist only
after mounting completes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): render a change's tasks as a parsed checklist

The Tasks pane becomes one Collapsible per tasks.md section, titled with its
done/total count, over an overall progress line. Finished sections start
collapsed so the eye lands on the work that is left.

The parser ignores checkboxes inside fenced code blocks (task files often
show example markup) and collects items appearing before any heading, so a
flat list is not dropped. A file with no checkbox items at all falls back to
markdown rather than showing an empty checklist that implies no tasks
exist; a change with no tasks.md gets an empty state.

The pane stays read-only: task edits go through the openspec CLI or $EDITOR,
per the thin-client invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add name filtering and a key-binding help panel

`/` reveals a filter bar on the dashboard and kanban that narrows the
listing by case-insensitive substring on name; escape clears it. Filtering
works on data already loaded rather than re-querying, so it costs nothing,
keeps stage grouping and progress intact, and is re-applied after each
live-refresh reload instead of fighting it. Nothing is persisted.

`?` opens Textual's help panel with the focused screen's bindings. It is
bound as a printable key, so a focused Input swallows it first — typing "?"
into the filter or the new-change name types a "?" rather than opening help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tui): guard the stylesheet in CI, document the new keys, fit 80x24

app.tcss is package data rather than a module, so nothing in CI would have
caught it dropping out of the wheel — the app would install cleanly and then
fail at startup with no CSS. CI now resolves it through importlib.resources
from the installed wheel.

Kanban column headings split into a name and a right-pinned count: a
trailing count was truncated away on narrow columns, and a heading wrapping
to two lines pushed that column's cards out of alignment with its
neighbours. Card progress bars shrink to 10 cells and no longer wrap.

README documents the key table, the new screens, theming, and the Textual
1.0 floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* bump: version 0.3.0 → 0.4.0

* test: add a headless test suite for the TUI

103 tests driving the real app through Textual's test pilot, so they need no
terminal. Data comes from a fake client so assertions do not depend on
whatever is in this repo's own openspec/ root; the few tests that exercise
the real CLI skip themselves when it is absent.

Covers each screen's rendering and bindings, the thread-worker read path
(off-thread reads, loading flags, superseded reads not landing), the
tasks.md parser, filtering, the command palette, and the help panel.

Two checks encode invariants that were violated during development and are
easy to regress:

- Theme readability. Every screen is audited under light, dark and ANSI
  themes for text whose foreground is unreadable against its background, and
  for truecolor literals reaching the screen while an ANSI theme is active —
  the signature of a hard-coded colour bypassing the theme.
- 80x24. Every screen must render without overflowing the width or losing
  content off the bottom.

Adds a dev dependency group and a CI job to run them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: drop unused imports

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
mrn-dk added a commit that referenced this pull request Aug 3, 2026
* chore: remove add-dark-mode change and stale add-live-kanban tasks.md

* feat(tui): revamp the TUI experience (#11)

* feat(tui): move styling into app.tcss with semantic stage tokens

All four inline CSS strings (app, palette, new-change, confirm) collapse
into a single openspec_ui/app.tcss loaded via CSS_PATH, expressed only in
Textual's theme tokens so the interface follows the active theme instead of
shipping its own palette.

Adds openspec_ui/theme.py as the single source of truth for how a pipeline
stage is drawn (colour token, glyph, CSS class), and the matching
.stage-* rules for column headings, card accents and dashboard badges.

Replaces the hard-coded [green] live indicator with the $text-success
token — under an ANSI theme the literal rendered as truecolor #008000
rather than resolving through the terminal palette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add status bar and replace title messages with toasts

Transient feedback (cancelled edit, editor fallback, reload complete,
failed reads) now goes to Textual toasts instead of overwriting a screen's
#title and ringing the terminal bell, so messages no longer compete with
the state they were painting over.

State moves to a persistent StatusBar docked above the Footer, carrying the
resolved root and the live/paused indicator. App.live_refresh becomes a
reactive so every mounted bar re-renders on toggle without each screen
pushing the change itself.

Failed reads are now reported twice on purpose: a toast so it is noticed,
and a persistent inline state so it stays readable. Kanban columns gain a
centred placeholder for the empty and error cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(tui): run openspec reads off the UI thread and fix idle polling cost

Dashboard, kanban and detail reads move into thread workers sharing one
exclusive "openspec-read" group, so a slow CLI call no longer freezes input
and a superseded read is cancelled rather than landing over a newer render.
The target widget shows a loading indicator while a read is in flight,
cleared on both success and failure.

Also fixes OpenSpecClient.root(): _root_cache was assigned but never read,
so every live-refresh tick spawned `openspec list --json` — once a second,
forever. The mtime gate existed precisely to avoid that subprocess, and the
openspec-tui spec requires no CLI spawn on an unchanged tick. Idle ticks now
spawn nothing.

Kanban initial focus moves to after the first load lands, so it can land on
the first column that actually has cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add spec detail browser

Selecting a spec now opens a screen showing its overview and one
Collapsible per requirement, revealing that requirement's scenarios on
demand — a spec with nine requirements and thirty scenarios is unreadable
flat. Data comes from `openspec show --type spec --json`, so the app stays
on the CLI's contract rather than parsing spec.md itself.

Requirement content is passed to the Collapsible constructor rather than
mounted onto it afterwards: mounting attaches to the Collapsible itself
instead of its Contents, which renders the child unconditionally and leaves
expand/collapse doing nothing.

Scenario counts sit before the requirement text, since the long SHALL
statement is truncated at the pane width and would take a trailing count
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): replace bespoke palette with Textual's fuzzy command palette

Deletes screens/palette.py (a hand-rolled ListView modal with no matching)
in favour of two Providers registered in App.COMMANDS: the global actions,
and a dynamic one that enumerates changes and specs so either can be opened
by name. Textual's palette brings fuzzy ranking and the system commands —
including the theme switcher, which is how themes change in-session without
the app owning any theme state.

The dynamic provider reads once per palette session in startup() via
asyncio.to_thread rather than per keystroke on the event loop, since the
client shells out to the openspec CLI.

No Quit entry of our own: Textual's system commands already provide one,
and two identically-named commands read as a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): rebuild the dashboard around an aligned table and stat row

The changes list becomes a DataTable keyed by change name (stage badge,
name, progress, tasks, modified). A ListView of pre-formatted label strings
could not line up columns when names differ in length, which was the main
reason the dashboard read as unstructured. Selection now travels by row key
rather than list index. The dim one-line summary becomes a row of Digits.

Both are behind size guards: the stat row hides under 24 rows, and under 90
columns the table drops Modified and narrows the name column so everything
fits inside 80 cells without scrolling.

Stage tokens move to concrete theme values ($foreground-muted,
$foreground-disabled). Textual's $text-* tokens are `auto` values that
resolve against the background they are drawn on; inside
Content.from_markup — how DataTable cells are coloured — there is no
backdrop to resolve against and `auto` collapses to black, which rendered
the progress-bar track invisible on every dark theme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): give kanban cards structure and stage colour

Cards become a ChangeCard widget (name, progress bar, task count) whose
pieces carry CSS classes, instead of one Label with colour markup baked into
a string. Each card takes a stage-coloured left border and each column
heading its stage glyph and colour, both from STAGE_STYLE, so a column is
identifiable without reading its label.

Column heading backgrounds stay neutral: tinting them per stage looks right
on truecolor themes but collapses on ANSI ones, where $accent-muted and
$text-accent resolve to the same palette entry and the heading text
disappears entirely.

progress_bar moves to theme.py so the dashboard and kanban share one
implementation. Card names truncate with an ellipsis rather than wrapping,
which was misaligning every card below them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): render artifacts with MarkdownViewer and per-file tabs

Each artifact now gets a MarkdownViewer: independent scrolling,
syntax-highlighted fences and a navigable table of contents. An artifact
group resolving to several files becomes nested tabs labelled by filename,
which removes the plain-text concatenation the old pane documented as a
limitation of composing several Markdown documents in one Static.

The TOC hides below 100 columns and toggles with `t`, so it never eats the
document it indexes on a narrow terminal.

Nested tabs are built in compose(): TabbedContent's positional arguments are
tab titles rather than panes, and add_pane needs children that exist only
after mounting completes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): render a change's tasks as a parsed checklist

The Tasks pane becomes one Collapsible per tasks.md section, titled with its
done/total count, over an overall progress line. Finished sections start
collapsed so the eye lands on the work that is left.

The parser ignores checkboxes inside fenced code blocks (task files often
show example markup) and collects items appearing before any heading, so a
flat list is not dropped. A file with no checkbox items at all falls back to
markdown rather than showing an empty checklist that implies no tasks
exist; a change with no tasks.md gets an empty state.

The pane stays read-only: task edits go through the openspec CLI or $EDITOR,
per the thin-client invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add name filtering and a key-binding help panel

`/` reveals a filter bar on the dashboard and kanban that narrows the
listing by case-insensitive substring on name; escape clears it. Filtering
works on data already loaded rather than re-querying, so it costs nothing,
keeps stage grouping and progress intact, and is re-applied after each
live-refresh reload instead of fighting it. Nothing is persisted.

`?` opens Textual's help panel with the focused screen's bindings. It is
bound as a printable key, so a focused Input swallows it first — typing "?"
into the filter or the new-change name types a "?" rather than opening help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tui): guard the stylesheet in CI, document the new keys, fit 80x24

app.tcss is package data rather than a module, so nothing in CI would have
caught it dropping out of the wheel — the app would install cleanly and then
fail at startup with no CSS. CI now resolves it through importlib.resources
from the installed wheel.

Kanban column headings split into a name and a right-pinned count: a
trailing count was truncated away on narrow columns, and a heading wrapping
to two lines pushed that column's cards out of alignment with its
neighbours. Card progress bars shrink to 10 cells and no longer wrap.

README documents the key table, the new screens, theming, and the Textual
1.0 floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* bump: version 0.3.0 → 0.4.0

* test: add a headless test suite for the TUI

103 tests driving the real app through Textual's test pilot, so they need no
terminal. Data comes from a fake client so assertions do not depend on
whatever is in this repo's own openspec/ root; the few tests that exercise
the real CLI skip themselves when it is absent.

Covers each screen's rendering and bindings, the thread-worker read path
(off-thread reads, loading flags, superseded reads not landing), the
tasks.md parser, filtering, the command palette, and the help panel.

Two checks encode invariants that were violated during development and are
easy to regress:

- Theme readability. Every screen is audited under light, dark and ANSI
  themes for text whose foreground is unreadable against its background, and
  for truecolor literals reaching the screen while an ANSI theme is active —
  the signature of a hard-coded colour bypassing the theme.
- 80x24. Every screen must render without overflowing the width or losing
  content off the bottom.

Adds a dev dependency group and a CI job to run them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: drop unused imports

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
mrn-dk added a commit that referenced this pull request Aug 3, 2026
* test: add a headless test suite for the TUI (#12)

* chore: remove add-dark-mode change and stale add-live-kanban tasks.md

* feat(tui): revamp the TUI experience (#11)

* feat(tui): move styling into app.tcss with semantic stage tokens

All four inline CSS strings (app, palette, new-change, confirm) collapse
into a single openspec_ui/app.tcss loaded via CSS_PATH, expressed only in
Textual's theme tokens so the interface follows the active theme instead of
shipping its own palette.

Adds openspec_ui/theme.py as the single source of truth for how a pipeline
stage is drawn (colour token, glyph, CSS class), and the matching
.stage-* rules for column headings, card accents and dashboard badges.

Replaces the hard-coded [green] live indicator with the $text-success
token — under an ANSI theme the literal rendered as truecolor #008000
rather than resolving through the terminal palette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add status bar and replace title messages with toasts

Transient feedback (cancelled edit, editor fallback, reload complete,
failed reads) now goes to Textual toasts instead of overwriting a screen's
#title and ringing the terminal bell, so messages no longer compete with
the state they were painting over.

State moves to a persistent StatusBar docked above the Footer, carrying the
resolved root and the live/paused indicator. App.live_refresh becomes a
reactive so every mounted bar re-renders on toggle without each screen
pushing the change itself.

Failed reads are now reported twice on purpose: a toast so it is noticed,
and a persistent inline state so it stays readable. Kanban columns gain a
centred placeholder for the empty and error cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(tui): run openspec reads off the UI thread and fix idle polling cost

Dashboard, kanban and detail reads move into thread workers sharing one
exclusive "openspec-read" group, so a slow CLI call no longer freezes input
and a superseded read is cancelled rather than landing over a newer render.
The target widget shows a loading indicator while a read is in flight,
cleared on both success and failure.

Also fixes OpenSpecClient.root(): _root_cache was assigned but never read,
so every live-refresh tick spawned `openspec list --json` — once a second,
forever. The mtime gate existed precisely to avoid that subprocess, and the
openspec-tui spec requires no CLI spawn on an unchanged tick. Idle ticks now
spawn nothing.

Kanban initial focus moves to after the first load lands, so it can land on
the first column that actually has cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add spec detail browser

Selecting a spec now opens a screen showing its overview and one
Collapsible per requirement, revealing that requirement's scenarios on
demand — a spec with nine requirements and thirty scenarios is unreadable
flat. Data comes from `openspec show --type spec --json`, so the app stays
on the CLI's contract rather than parsing spec.md itself.

Requirement content is passed to the Collapsible constructor rather than
mounted onto it afterwards: mounting attaches to the Collapsible itself
instead of its Contents, which renders the child unconditionally and leaves
expand/collapse doing nothing.

Scenario counts sit before the requirement text, since the long SHALL
statement is truncated at the pane width and would take a trailing count
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): replace bespoke palette with Textual's fuzzy command palette

Deletes screens/palette.py (a hand-rolled ListView modal with no matching)
in favour of two Providers registered in App.COMMANDS: the global actions,
and a dynamic one that enumerates changes and specs so either can be opened
by name. Textual's palette brings fuzzy ranking and the system commands —
including the theme switcher, which is how themes change in-session without
the app owning any theme state.

The dynamic provider reads once per palette session in startup() via
asyncio.to_thread rather than per keystroke on the event loop, since the
client shells out to the openspec CLI.

No Quit entry of our own: Textual's system commands already provide one,
and two identically-named commands read as a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): rebuild the dashboard around an aligned table and stat row

The changes list becomes a DataTable keyed by change name (stage badge,
name, progress, tasks, modified). A ListView of pre-formatted label strings
could not line up columns when names differ in length, which was the main
reason the dashboard read as unstructured. Selection now travels by row key
rather than list index. The dim one-line summary becomes a row of Digits.

Both are behind size guards: the stat row hides under 24 rows, and under 90
columns the table drops Modified and narrows the name column so everything
fits inside 80 cells without scrolling.

Stage tokens move to concrete theme values ($foreground-muted,
$foreground-disabled). Textual's $text-* tokens are `auto` values that
resolve against the background they are drawn on; inside
Content.from_markup — how DataTable cells are coloured — there is no
backdrop to resolve against and `auto` collapses to black, which rendered
the progress-bar track invisible on every dark theme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): give kanban cards structure and stage colour

Cards become a ChangeCard widget (name, progress bar, task count) whose
pieces carry CSS classes, instead of one Label with colour markup baked into
a string. Each card takes a stage-coloured left border and each column
heading its stage glyph and colour, both from STAGE_STYLE, so a column is
identifiable without reading its label.

Column heading backgrounds stay neutral: tinting them per stage looks right
on truecolor themes but collapses on ANSI ones, where $accent-muted and
$text-accent resolve to the same palette entry and the heading text
disappears entirely.

progress_bar moves to theme.py so the dashboard and kanban share one
implementation. Card names truncate with an ellipsis rather than wrapping,
which was misaligning every card below them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): render artifacts with MarkdownViewer and per-file tabs

Each artifact now gets a MarkdownViewer: independent scrolling,
syntax-highlighted fences and a navigable table of contents. An artifact
group resolving to several files becomes nested tabs labelled by filename,
which removes the plain-text concatenation the old pane documented as a
limitation of composing several Markdown documents in one Static.

The TOC hides below 100 columns and toggles with `t`, so it never eats the
document it indexes on a narrow terminal.

Nested tabs are built in compose(): TabbedContent's positional arguments are
tab titles rather than panes, and add_pane needs children that exist only
after mounting completes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): render a change's tasks as a parsed checklist

The Tasks pane becomes one Collapsible per tasks.md section, titled with its
done/total count, over an overall progress line. Finished sections start
collapsed so the eye lands on the work that is left.

The parser ignores checkboxes inside fenced code blocks (task files often
show example markup) and collects items appearing before any heading, so a
flat list is not dropped. A file with no checkbox items at all falls back to
markdown rather than showing an empty checklist that implies no tasks
exist; a change with no tasks.md gets an empty state.

The pane stays read-only: task edits go through the openspec CLI or $EDITOR,
per the thin-client invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tui): add name filtering and a key-binding help panel

`/` reveals a filter bar on the dashboard and kanban that narrows the
listing by case-insensitive substring on name; escape clears it. Filtering
works on data already loaded rather than re-querying, so it costs nothing,
keeps stage grouping and progress intact, and is re-applied after each
live-refresh reload instead of fighting it. Nothing is persisted.

`?` opens Textual's help panel with the focused screen's bindings. It is
bound as a printable key, so a focused Input swallows it first — typing "?"
into the filter or the new-change name types a "?" rather than opening help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tui): guard the stylesheet in CI, document the new keys, fit 80x24

app.tcss is package data rather than a module, so nothing in CI would have
caught it dropping out of the wheel — the app would install cleanly and then
fail at startup with no CSS. CI now resolves it through importlib.resources
from the installed wheel.

Kanban column headings split into a name and a right-pinned count: a
trailing count was truncated away on narrow columns, and a heading wrapping
to two lines pushed that column's cards out of alignment with its
neighbours. Card progress bars shrink to 10 cells and no longer wrap.

README documents the key table, the new screens, theming, and the Textual
1.0 floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* bump: version 0.3.0 → 0.4.0

* test: add a headless test suite for the TUI

103 tests driving the real app through Textual's test pilot, so they need no
terminal. Data comes from a fake client so assertions do not depend on
whatever is in this repo's own openspec/ root; the few tests that exercise
the real CLI skip themselves when it is absent.

Covers each screen's rendering and bindings, the thread-worker read path
(off-thread reads, loading flags, superseded reads not landing), the
tasks.md parser, filtering, the command palette, and the help panel.

Two checks encode invariants that were violated during development and are
easy to regress:

- Theme readability. Every screen is audited under light, dark and ANSI
  themes for text whose foreground is unreadable against its background, and
  for truecolor literals reaching the screen while an ANSI theme is active —
  the signature of a hard-coded colour bypassing the theme.
- 80x24. Every screen must render without overflowing the width or losing
  content off the bottom.

Adds a dev dependency group and a CI job to run them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: drop unused imports

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* docs: add screenshots to the README (#13)

Eight screenshots covering the dashboard, kanban, change detail, tasks,
spec browser, command palette and filtering, plus a light-theme dashboard
to show the app follows the terminal's theme.

Also focus the README to just the essentials: openspec-ui, install & run,
what it does, screenshots, contributing. Remove Keys, Theming, Design,
Status, Releases, and License sections.

Two things the screenshots caught that no text-based assertion could:

- The Header icon rendered as a missing-glyph box. Removed; some terminals
  lack that glyph too.
- Spec requirement counts sat ragged directly beneath the aligned changes
  table. Names are now padded so the counts line up.

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>

---------

Co-authored-by: Morten R. N. <morten@rothnielsen.dk>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant