Repository navigation
feat(tui): revamp the TUI experience - #11
Merged
Merged
Conversation
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>
This was referenced Aug 3, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ListViewof pre-formatted strings thatcould not align, notifications hijacked the title bar and rang the terminal
bell, the command palette was hand-rolled with no matching, and every
openspecsubprocess ran on the UI thread.Presentation
openspec_ui/app.tcss(CSS_PATH), in Textual'ssemantic tokens only — no hex literals, so the app follows the active theme
including the ANSI ones.
openspec_ui/theme.pyis the single source of truthfor how a pipeline stage is drawn, shared by the stylesheet and by table cells.
DataTable(stage, name, progress, tasks,modified) keyed by change name, over a
Digitsstat row.column headings carry their stage glyph and colour.
StatusBarcarries the rootand the live/paused indicator, so nothing overwrites state any more.
Structure
via two
Providers, which also brings in-session theme switching.MarkdownViewers with a navigable table of contents;multi-file artifact groups get one tab per file.
loading indicator; superseded reads are cancelled rather than landing stale.
?opens Textual's help panel for the focused screen.New views
Collapsibleperrequirement revealing its scenarios.
tasks.mdparsed into a per-section checklist withdone/total counts. Read-only; edits still go through
$EDITOR./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_cachebut never read it, so everylive-refresh tick spawned
openspec list --json— once a second, forever.The existing spec requires no CLI spawn on an unchanged tick.
$text-muted/$text-disabledareautovalues that resolveagainst their backdrop; inside
Content.from_markup(how DataTable cells arecoloured) there is none, so they collapsed to black — invisible on dark themes.
Collapsibleattaches outside itsContents, so the specbrowser's scenarios rendered unconditionally and expand/collapse did nothing.
$accent-mutedand$text-accentresolve to the same palette entry.below them.
Compatibility
textual>=0.86.0→>=1.0.0(themes,Providerdiscovery,Widget.loading,action_show_help_panel,Digits). No new runtime dependencies. All existingkey bindings keep their meanings, and the thin-client invariant is unchanged —
nothing is persisted, theme and filter included.
CI gains a check that
app.tcssresolves from the built wheel viaimportlib.resources: it is package data, not a module, so its absence wouldinstall cleanly and fail only at startup.
Verified headlessly under Textual's test pilot across
textual-dark,textual-lightandansi-dark, and at 80x24, on every screen.🤖 Generated with Claude Code