diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb85029..e908191 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,26 @@ on:
branches: [main]
jobs:
+ test:
+ name: Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v6
+ with:
+ version: "0.8.x"
+
+ - name: Set up Python
+ run: uv python install
+
+ # The suite drives the app through Textual's headless test pilot, so it
+ # needs no terminal. Tests that shell out to the `openspec` CLI skip
+ # themselves when it is not installed.
+ - name: Run tests
+ run: uv run --group dev pytest -q
+
build-check:
name: Build & import check
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index ba13e3c..465b1da 100644
--- a/README.md
+++ b/README.md
@@ -48,75 +48,58 @@ newer (installed automatically).
- **Live refresh** — open views re-read from disk as things change on disk;
press `p` to pause.
-### Keys
+Press `?` at any time for the key bindings available on the current screen.
-Press `?` at any time for the bindings available on the current screen.
+## Screenshots
-| Key | Does |
-| --- | --- |
-| `?` | Show key bindings for the current screen |
-| `Ctrl+P` | Command palette (actions, jump to change/spec, theme) |
-| `/` | Filter the current listing by name (`escape` clears) |
-| `enter` | Open the selected change or spec |
-| `k` / `d` | Kanban / dashboard |
-| `n` | New change |
-| `e` | Edit the current artifact in `$EDITOR` |
-| `r` | Refresh now |
-| `p` | Pause or resume live refresh |
-| `t` | Toggle the table of contents (change detail) |
-| `q` | Quit |
+Screenshots show an example project, not this repository.
-### Theming
+**Dashboard** — changes in an aligned table with stage, progress and task
+counts, over headline counts per stage.
-`openspec-ui` ships no palette of its own — all colour comes from the active
-Textual theme, so it follows your terminal. Switch themes in-session from the
-command palette (`Ctrl+P` → *Theme*). Nothing is written to disk: the app
-persists no state, theme included.
+
-## Design
+**Kanban** — `k`. Every column heading and card border carries its stage's
+colour, so the shape of the pipeline reads before any of the text does.
-`openspec-ui` is a **thin client**: it owns no state. Every read comes from
-`openspec ... --json` or direct file reads under `openspec/`; every write is
-delegated to the `openspec` CLI or to `$EDITOR`. This keeps the view layer
-replaceable (a future SvelteKit web UI could reuse the same data contract).
+
-## Status
+**Change detail** — `enter` on a change. Artifacts render as scrollable
+markdown with a table of contents; code blocks and ASCII diagrams keep their
+monospace alignment.
-Alpha. Built with [Textual](https://textual.textualize.io/). Supports OpenSpec
-CLI `1.7.x` (`@fission-ai/openspec`).
+
-## Releases
+**Tasks** — the `tasks.md` checklist, one section per heading with its
+done/total count. Finished sections start collapsed so the eye lands on what is
+left. Read-only: edits go through `$EDITOR`.
-Releases are **fully automated**. Maintainers never edit the version field by
-hand.
+
-1. Conventional commits land on `main` (via PRs).
-2. On each push to `main`, CI runs [`commitizen`](https://commitizen-tools.github.io/commitizen/)
- (`cz bump --yes --changelog`), which determines the next semver from the
- commit log, updates `version` in `pyproject.toml`, updates `CHANGELOG.md`,
- and creates/pushes a `v` tag.
-3. The tag push triggers a second CI job that builds the wheel+sdist and
- publishes them to [PyPI](https://pypi.org/project/openspec-ui/) using
- **trusted publishing (OIDC)** — no stored API tokens.
-4. `feat:`/`fix:`/breaking commits release; pure `chore:`/`docs:` commits do
- not (commitizen determines there is no increment).
+**Spec browser** — `enter` on a spec. Requirements expand to reveal their
+scenarios, which is the only way a spec with thirty of them stays readable.
-### Trusted publisher setup (one-time, manual)
+
-Before the publish job works, register a trusted publisher on
-[pypi.org](https://pypi.org) once:
+**Command palette** — `Ctrl+P`. Fuzzy-matched actions, plus every change and
+spec by name.
-- **PyPI project:** `openspec-ui` (create it if it does not yet exist).
-- **GitHub repo:** `mrn-dk/openspec-ui`.
-- **Workflow filename:** `.github/workflows/release.yml`.
-- **Environment name:** `pypi` (the publish job uses `environment: pypi`).
+
-This is a web step on pypi.org that cannot be automated from within the repo.
-Until it is done, the publish job will fail with an OIDC error; the build
-artifacts still exist in the workflow run and can be uploaded manually.
+**Filter** — `/`. Narrows the changes and specs by name; `escape` clears it.
-The first release also needs a seeded `v0.1.0` tag (commitizen bumps
-*relative* to an existing tag); after that, `cz bump` always has a reference.
+
+
+**Light theme** — all colour comes from the active Textual theme, so it follows
+your terminal.
+
+
## Contributing
@@ -133,7 +116,3 @@ Git history uses **Conventional Commits** and **conventional branch names**:
For changes managed through OpenSpec, see `openspec/config.yaml` for the
project context and per-operation guidance that the openspec skills follow.
-
-## License
-
-MIT
diff --git a/docs/images/change-detail.png b/docs/images/change-detail.png
new file mode 100644
index 0000000..f73b1df
Binary files /dev/null and b/docs/images/change-detail.png differ
diff --git a/docs/images/command-palette.png b/docs/images/command-palette.png
new file mode 100644
index 0000000..6bc78f1
Binary files /dev/null and b/docs/images/command-palette.png differ
diff --git a/docs/images/dashboard-light.png b/docs/images/dashboard-light.png
new file mode 100644
index 0000000..703a311
Binary files /dev/null and b/docs/images/dashboard-light.png differ
diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png
new file mode 100644
index 0000000..7ae8a1b
Binary files /dev/null and b/docs/images/dashboard.png differ
diff --git a/docs/images/filtering.png b/docs/images/filtering.png
new file mode 100644
index 0000000..167205d
Binary files /dev/null and b/docs/images/filtering.png differ
diff --git a/docs/images/kanban.png b/docs/images/kanban.png
new file mode 100644
index 0000000..d7ab2a2
Binary files /dev/null and b/docs/images/kanban.png differ
diff --git a/docs/images/spec-browser.png b/docs/images/spec-browser.png
new file mode 100644
index 0000000..fc0dbed
Binary files /dev/null and b/docs/images/spec-browser.png differ
diff --git a/docs/images/tasks.png b/docs/images/tasks.png
new file mode 100644
index 0000000..29bcf83
Binary files /dev/null and b/docs/images/tasks.png differ
diff --git a/openspec_ui/screens/dashboard.py b/openspec_ui/screens/dashboard.py
index 707d98b..41b9add 100644
--- a/openspec_ui/screens/dashboard.py
+++ b/openspec_ui/screens/dashboard.py
@@ -88,7 +88,7 @@ def __init__(self) -> None:
# -- compose --------------------------------------------------------
def compose(self) -> None:
- yield Header(show_clock=False)
+ yield Header(show_clock=False, icon="")
yield Static("", id="title")
yield FilterBar(id="filter")
yield Horizontal(
@@ -329,12 +329,16 @@ def _render_specs(self, specs) -> None:
self._show_specs_empty("No specs yet.")
return
self._show_specs_empty(None)
+ # Pad names to the longest so the counts line up in a column, matching
+ # the changes table above rather than sitting ragged beneath it.
+ name_width = max(len(spec.name) for spec in specs)
for spec in specs:
noun = "requirement" if spec.requirement_count == 1 else "requirements"
specs_list.append(
ListItem(
Label(
- f"{spec.name} [$text-muted]{spec.requirement_count} {noun}[/]"
+ f"{spec.name:<{name_width}} "
+ f"[$text-muted]{spec.requirement_count:>3} {noun}[/]"
),
name=spec.name,
)
diff --git a/openspec_ui/screens/detail.py b/openspec_ui/screens/detail.py
index 0b1792d..ec28396 100644
--- a/openspec_ui/screens/detail.py
+++ b/openspec_ui/screens/detail.py
@@ -195,7 +195,7 @@ def action_toggle_toc(self) -> None:
self._apply_toc()
def compose(self) -> None:
- yield Header(show_clock=False)
+ yield Header(show_clock=False, icon="")
yield Static("", id="title")
yield TabbedContent(id="tabs")
yield StatusBar()
diff --git a/openspec_ui/screens/kanban.py b/openspec_ui/screens/kanban.py
index 8c489e0..0af7006 100644
--- a/openspec_ui/screens/kanban.py
+++ b/openspec_ui/screens/kanban.py
@@ -118,7 +118,7 @@ def __init__(self) -> None:
self._filter = ""
def compose(self) -> None:
- yield Header(show_clock=False)
+ yield Header(show_clock=False, icon="")
yield Static("Kanban — change pipeline", id="title")
yield FilterBar(id="filter")
with Horizontal(id="board"):
diff --git a/openspec_ui/screens/spec_detail.py b/openspec_ui/screens/spec_detail.py
index c2a73aa..695e4db 100644
--- a/openspec_ui/screens/spec_detail.py
+++ b/openspec_ui/screens/spec_detail.py
@@ -29,7 +29,7 @@ def __init__(self, spec_name: str) -> None:
self.spec_name = spec_name
def compose(self):
- yield Header(show_clock=False)
+ yield Header(show_clock=False, icon="")
yield Static("", id="title")
yield VerticalScroll(id="spec-body")
yield StatusBar()
diff --git a/pyproject.toml b/pyproject.toml
index f41c735..5de0950 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,3 +51,16 @@ version_scheme = "semver"
tag_format = "v$version"
update_changelog_on_bump = true
changelog_incremental = true
+
+[dependency-groups]
+dev = [
+ "pytest>=8.0",
+ "pytest-asyncio>=0.24",
+]
+
+[tool.pytest.ini_options]
+# Tests drive the Textual app through its async test pilot, so every test
+# coroutine is a real async test; auto mode saves marking each one.
+asyncio_mode = "auto"
+testpaths = ["tests"]
+pythonpath = ["tests"]
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..21b5f89
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,189 @@
+"""Shared fixtures and helpers for the openspec-ui test suite.
+
+Tests drive the real `OpenSpecUIApp` under Textual's headless test pilot. Data
+comes from a fake client by default so assertions do not depend on whatever
+happens to be in this repo's own `openspec/` root; the handful of tests that
+exercise the real CLI are marked `integration` and skip when it is absent.
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+import pytest
+
+from openspec_ui.app import OpenSpecUIApp
+from openspec_ui.client import Change, OpenSpecClient, Requirement, Spec, SpecDetail
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+
+# --------------------------------------------------------------------------
+# fake client
+# --------------------------------------------------------------------------
+
+
+class FakeClient(OpenSpecClient):
+ """An OpenSpecClient that serves fixed data and never shells out.
+
+ Every read is overridden, so a test that accidentally reaches an
+ un-overridden method fails loudly rather than silently invoking the CLI.
+ """
+
+ def __init__(
+ self,
+ changes: list[Change] | None = None,
+ specs: list[Spec] | None = None,
+ artifacts: dict[str, list[Path]] | None = None,
+ spec_detail: SpecDetail | None = None,
+ root: Path = REPO_ROOT,
+ ) -> None:
+ super().__init__(openspec_cmd="openspec-must-not-be-called")
+ self.changes = changes if changes is not None else default_changes()
+ self.specs = specs if specs is not None else default_specs()
+ self.artifacts = artifacts if artifacts is not None else {}
+ self.spec_detail = spec_detail
+ self._root = root
+ self._dirty = False
+
+ # -- reads ---------------------------------------------------------
+
+ def root(self) -> Path:
+ return self._root
+
+ def list_changes(self) -> list[Change]:
+ return list(self.changes)
+
+ def list_specs(self) -> list[Spec]:
+ return list(self.specs)
+
+ def artifact_paths(self, change_name: str) -> dict[str, list[Path]]:
+ return {
+ key: list(self.artifacts.get(key, []))
+ for key in ("proposal", "design", "tasks", "specs")
+ }
+
+ def show_spec(self, name: str) -> SpecDetail:
+ if self.spec_detail is not None:
+ return self.spec_detail
+ return default_spec_detail(name)
+
+ # -- live refresh --------------------------------------------------
+
+ def has_changed(self) -> bool:
+ if self._dirty:
+ self._dirty = False
+ return True
+ return False
+
+ def mark_dirty(self) -> None:
+ """Make the next poll tick report a change."""
+ self._dirty = True
+
+ def reset_change_cache(self) -> None:
+ self._dirty = False
+
+
+class FailingClient(OpenSpecClient):
+ """A client whose every read raises, for exercising error paths."""
+
+ def __init__(self) -> None:
+ super().__init__(openspec_cmd="openspec-does-not-exist")
+
+
+def default_changes() -> list[Change]:
+ return [
+ Change("add-widgets", 0, 0, None, None), # Draft
+ Change("rebuild-core", 3, 8, None, None), # In Progress
+ Change("ship-release", 5, 5, None, None), # Completed
+ ]
+
+
+def default_specs() -> list[Spec]:
+ return [Spec("core-engine", 4), Spec("release-pipeline", 1)]
+
+
+def default_spec_detail(name: str = "core-engine") -> SpecDetail:
+ return SpecDetail(
+ name=name,
+ title=name,
+ overview="What this capability is for, at enough length to be a sentence.",
+ requirements=[
+ Requirement(
+ text="The system SHALL do the first thing.",
+ scenarios=[
+ "- **WHEN** a thing happens\n- **THEN** the first outcome follows",
+ "- **WHEN** another thing happens\n- **THEN** so does that",
+ ],
+ ),
+ Requirement(
+ text="The system SHALL do the second thing.",
+ scenarios=["- **WHEN** asked\n- **THEN** it obliges"],
+ ),
+ ],
+ )
+
+
+# --------------------------------------------------------------------------
+# fixtures
+# --------------------------------------------------------------------------
+
+
+@pytest.fixture
+def fake_client() -> FakeClient:
+ return FakeClient()
+
+
+@pytest.fixture
+def make_app():
+ """Build an app around a given client."""
+
+ def _make(client: OpenSpecClient | None = None, root: Path = REPO_ROOT):
+ return OpenSpecUIApp(client=client or FakeClient(), root=root)
+
+ return _make
+
+
+@pytest.fixture
+def app(make_app, fake_client):
+ return make_app(fake_client)
+
+
+# --------------------------------------------------------------------------
+# helpers
+# --------------------------------------------------------------------------
+
+
+async def settle(app, pilot, rounds: int = 6) -> None:
+ """Let background reads land and the screen repaint.
+
+ Reads run in thread workers, so a single pause is not enough: the worker
+ has to finish and its `call_from_thread` render has to be processed.
+ """
+ for _ in range(rounds):
+ await pilot.pause()
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+
+
+def screen_lines(app) -> list[str]:
+ """The current screen flattened to plain text, one string per row."""
+ return [
+ "".join(segment.text for segment in strip._segments).rstrip()
+ for strip in app.screen._compositor.render_strips()
+ ]
+
+
+def screen_text(app) -> str:
+ return "\n".join(screen_lines(app))
+
+
+def has_openspec_cli() -> bool:
+ return shutil.which("openspec") is not None
+
+
+requires_openspec = pytest.mark.skipif(
+ not has_openspec_cli(),
+ reason="requires the `openspec` CLI on PATH",
+)
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..c69abef
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,97 @@
+"""Client-level behaviour: the tasks.md parser and polling cost."""
+
+from __future__ import annotations
+
+import subprocess
+
+import pytest
+
+from conftest import requires_openspec
+from openspec_ui.client import OpenSpecClient, parse_tasks
+
+
+class TestParseTasks:
+ def test_no_checkboxes_yields_nothing(self):
+ """The caller uses "no sections" as the signal to fall back to markdown."""
+ assert parse_tasks("# Title\n\nJust prose, no checklist.\n") == []
+
+ def test_flat_list_is_kept_under_an_untitled_section(self):
+ sections = parse_tasks("- [x] first\n- [ ] second\n")
+ assert [(s.title, s.completed, s.total) for s in sections] == [("", 1, 2)]
+
+ def test_sections_carry_their_own_counts(self):
+ sections = parse_tasks(
+ "## 1. One\n- [x] a\n\n## 2. Two\n- [ ] b\n- [x] c\n"
+ )
+ assert [(s.title, s.label) for s in sections] == [
+ ("1. One", "1/1"),
+ ("2. Two", "1/2"),
+ ]
+
+ def test_headings_without_tasks_are_dropped(self):
+ sections = parse_tasks("## Empty\n\nprose\n\n## Real\n- [ ] x\n")
+ assert [s.title for s in sections] == ["Real"]
+
+ def test_checkboxes_inside_fences_are_ignored(self):
+ """Task files often show example markup in a fenced block."""
+ sections = parse_tasks(
+ "## S\n- [x] real task\n\n```\n- [ ] example in a fence\n```\n"
+ )
+ assert [(s.title, s.label) for s in sections] == [("S", "1/1")]
+ assert sections[0].tasks[0].text == "real task"
+
+ @pytest.mark.parametrize("mark,expected", [("x", True), ("X", True), (" ", False)])
+ def test_checkbox_marks(self, mark, expected):
+ sections = parse_tasks(f"## S\n- [{mark}] item\n")
+ assert sections[0].tasks[0].done is expected
+
+ @pytest.mark.parametrize("bullet", ["-", "*", "+"])
+ def test_bullet_styles(self, bullet):
+ sections = parse_tasks(f"## S\n{bullet} [ ] item\n")
+ assert sections[0].total == 1
+
+ def test_task_text_is_preserved_verbatim(self):
+ sections = parse_tasks("## S\n- [ ] 1.1 Add `foo` to [bar] (baz)\n")
+ assert sections[0].tasks[0].text == "1.1 Add `foo` to [bar] (baz)"
+
+ def test_empty_input(self):
+ assert parse_tasks("") == []
+
+
+class TestPollingCost:
+ """The mtime gate exists so idle polling does not spawn the CLI."""
+
+ @requires_openspec
+ def test_idle_ticks_spawn_no_subprocess(self, monkeypatch):
+ calls: list[list[str]] = []
+ real_run = subprocess.run
+
+ def spy(args, **kwargs):
+ calls.append(args)
+ return real_run(args, **kwargs)
+
+ client = OpenSpecClient()
+ client.has_changed() # first call baselines, and may resolve the root
+
+ monkeypatch.setattr(subprocess, "run", spy)
+ for _ in range(5):
+ client.has_changed()
+
+ assert calls == [], f"idle polling shelled out: {calls}"
+
+ @requires_openspec
+ def test_root_is_resolved_once(self, monkeypatch):
+ client = OpenSpecClient()
+ first = client.root()
+
+ calls: list[list[str]] = []
+ real_run = subprocess.run
+
+ def spy(args, **kwargs):
+ calls.append(args)
+ return real_run(args, **kwargs)
+
+ monkeypatch.setattr(subprocess, "run", spy)
+ for _ in range(3):
+ assert client.root() == first
+ assert calls == [], f"root() re-resolved via subprocess: {calls}"
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
new file mode 100644
index 0000000..a73eeb8
--- /dev/null
+++ b/tests/test_dashboard.py
@@ -0,0 +1,208 @@
+"""Dashboard: aligned table, row-key selection, stat row, size guards."""
+
+from __future__ import annotations
+
+from textual.containers import Horizontal
+from textual.widgets import DataTable, ListView, Static
+
+from conftest import FailingClient, FakeClient, screen_lines, settle
+from openspec_ui.client import Change
+from openspec_ui.screens.dashboard import (
+ NARROW_WIDTH,
+ STAT_ROW_MIN_HEIGHT,
+ format_modified,
+)
+from openspec_ui.screens.detail import DetailScreen
+from openspec_ui.screens.kanban import KanbanScreen
+from openspec_ui.screens.new_change import NewChangeScreen
+from openspec_ui.screens.spec_detail import SpecDetailScreen
+from openspec_ui.widgets import EmptyState, Stat, StatusBar
+
+
+async def test_changes_are_listed_in_pipeline_order(app):
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+ table = app.screen.query_one("#changes", DataTable)
+ names = [str(table.get_cell_at((row, 1))) for row in range(table.row_count)]
+ assert names == ["add-widgets", "rebuild-core", "ship-release"]
+
+
+async def test_columns_align_across_rows_of_differing_name_length(app):
+ """The reason the table replaced a ListView of formatted strings."""
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+ # A fully complete bar is all █ and a task-less one is all ─, so match
+ # on any of the three glyphs and compare where each bar starts.
+ bars = [
+ line
+ for line in screen_lines(app)
+ if any(glyph in line for glyph in ("█", "░", "───"))
+ ]
+ assert len(bars) == 3, bars
+ starts = {min(line.index(g) for g in "█░─" if g in line) for line in bars}
+ assert len(starts) == 1, f"progress column not aligned: {starts}"
+
+
+async def test_enter_opens_the_change_under_the_cursor(app):
+ """Selection travels by row key, not by list index."""
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+ table = app.screen.query_one("#changes", DataTable)
+ table.move_cursor(row=table.row_count - 1)
+ await pilot.pause()
+ await pilot.press("enter")
+ await settle(app, pilot)
+ assert isinstance(app.screen, DetailScreen)
+ assert app.screen.change_name == "ship-release"
+
+
+async def test_enter_on_the_specs_list_opens_a_spec(app):
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+ specs = app.screen.query_one("#specs", ListView)
+ specs.focus()
+ specs.index = 0
+ await pilot.pause()
+ await pilot.press("enter")
+ await settle(app, pilot)
+ assert isinstance(app.screen, SpecDetailScreen)
+ assert app.screen.spec_name == "core-engine"
+
+
+async def test_stat_row_counts_each_stage(app):
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+ values = {
+ name: app.screen.query_one(f"#stat-{name}", Stat)
+ .query_one("Digits")
+ .value
+ for name in ("specs", "draft", "active", "completed", "archived")
+ }
+ assert values == {
+ "specs": "2",
+ "draft": "1",
+ "active": "1",
+ "completed": "1",
+ "archived": "0",
+ }
+
+
+async def test_navigation_bindings_keep_their_meanings(app):
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+
+ await pilot.press("k")
+ await settle(app, pilot)
+ assert isinstance(app.screen, KanbanScreen)
+ await pilot.press("d")
+ await settle(app, pilot)
+
+ was_live = app.live_refresh
+ await pilot.press("p")
+ await pilot.pause()
+ assert app.live_refresh is not was_live
+ await pilot.press("p")
+
+ await pilot.press("n")
+ await settle(app, pilot)
+ assert isinstance(app.screen, NewChangeScreen)
+ await pilot.press("escape")
+ await settle(app, pilot)
+
+ await pilot.press("r")
+ await settle(app, pilot)
+ assert app.screen.query_one("#changes", DataTable).row_count == 3
+
+
+class TestSizeGuards:
+ async def test_stat_row_hides_on_a_short_terminal(self, make_app):
+ app = make_app()
+ async with app.run_test(size=(100, STAT_ROW_MIN_HEIGHT - 4)) as pilot:
+ await settle(app, pilot)
+ assert not app.screen.query_one("#stats", Horizontal).display
+
+ async def test_stat_row_shows_when_there_is_room(self, make_app):
+ app = make_app()
+ async with app.run_test(size=(100, STAT_ROW_MIN_HEIGHT + 10)) as pilot:
+ await settle(app, pilot)
+ assert app.screen.query_one("#stats", Horizontal).display
+
+ async def test_narrow_terminal_drops_the_modified_column(self, make_app):
+ app = make_app()
+ async with app.run_test(size=(NARROW_WIDTH - 10, 30)) as pilot:
+ await settle(app, pilot)
+ assert app.screen._compact
+ headers = [
+ str(column.label)
+ for column in app.screen.query_one("#changes", DataTable).columns.values()
+ ]
+ assert "Modified" not in headers
+
+ async def test_nothing_overflows_eighty_columns(self, make_app):
+ app = make_app()
+ async with app.run_test(size=(80, 24)) as pilot:
+ await settle(app, pilot)
+ too_wide = [line for line in screen_lines(app) if len(line) > 80]
+ assert not too_wide, too_wide
+
+
+class TestEmptyAndErrorStates:
+ async def test_empty_root_renders_actionable_placeholders(self, make_app):
+ app = make_app(FakeClient(changes=[], specs=[]))
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ visible = [e for e in app.screen.query(EmptyState) if e.display]
+ messages = [str(e.content) for e in visible]
+ assert len(visible) == 2, messages
+ assert any("create one" in m for m in messages)
+
+ async def test_failed_read_reports_twice_and_keeps_the_title(self, make_app):
+ """A toast so it is noticed, an inline state so it stays readable."""
+ app = make_app(FailingClient())
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ assert list(app._notifications)
+ title = str(app.screen.query_one("#title", Static).content)
+ assert title == "OpenSpec Dashboard"
+ inline = app.screen.query_one("#changes_empty", EmptyState)
+ assert inline.display and "openspec" in str(inline.content)
+
+ async def test_failed_read_leaves_the_status_bar_intact(self, make_app):
+ app = make_app(FailingClient())
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ bar = app.screen.query_one(StatusBar)
+ live = str(bar.query_one("#status-live", Static).content)
+ assert "live" in live or "paused" in live
+
+
+class TestFormatModified:
+ def test_absent(self):
+ assert format_modified(None) == "—"
+
+ def test_unparseable_falls_back_to_the_date_prefix(self):
+ assert format_modified("not-a-timestamp") == "not-a-time"
+
+ def test_recent(self):
+ from datetime import datetime, timedelta, timezone
+
+ stamp = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
+ assert format_modified(stamp) == "3h"
+
+
+async def test_cursor_stays_put_across_a_reload(make_app):
+ """A live refresh must not yank the cursor back to the top."""
+ client = FakeClient()
+ app = make_app(client)
+ async with app.run_test(size=(120, 40)) as pilot:
+ await settle(app, pilot)
+ table = app.screen.query_one("#changes", DataTable)
+ table.move_cursor(row=2)
+ await pilot.pause()
+ before = app.screen._highlighted_change_name()
+
+ client.changes = [*client.changes, Change("late-arrival", 0, 3, None, None)]
+ app.screen.reload()
+ await settle(app, pilot)
+
+ assert app.screen._highlighted_change_name() == before
diff --git a/tests/test_detail.py b/tests/test_detail.py
new file mode 100644
index 0000000..9584d38
--- /dev/null
+++ b/tests/test_detail.py
@@ -0,0 +1,210 @@
+"""Change detail: markdown viewers, per-file tabs, TOC, tasks checklist."""
+
+from __future__ import annotations
+
+import pytest
+from textual.widgets import Collapsible, MarkdownViewer, TabbedContent, TabPane
+
+from conftest import FakeClient, screen_lines, settle
+from openspec_ui.screens.detail import TOC_MIN_WIDTH, ArtifactPane, TasksPane
+from openspec_ui.widgets import EmptyState
+
+ASCII_DOC = """# Diagram
+
+Some prose.
+
+```
++--------+ +--------+
+| alpha | --> | beta |
++--------+ +--------+
+```
+
+## Section two
+
+- a list item
+"""
+
+
+@pytest.fixture
+def artifacts(tmp_path):
+ """A change with one proposal, two spec files and a tasks checklist."""
+ proposal = tmp_path / "proposal.md"
+ proposal.write_text(ASCII_DOC)
+ spec_a = tmp_path / "alpha-spec.md"
+ spec_a.write_text(ASCII_DOC)
+ spec_b = tmp_path / "beta-spec.md"
+ spec_b.write_text("# Beta\n\nbeta content\n")
+ tasks = tmp_path / "tasks.md"
+ tasks.write_text(
+ "## 1. First\n- [x] 1.1 done thing\n- [ ] 1.2 pending thing\n"
+ "\n## 2. Second\n- [x] 2.1 also done\n"
+ )
+ return {
+ "proposal": [proposal],
+ "design": [],
+ "tasks": [tasks],
+ "specs": [spec_a, spec_b],
+ }
+
+
+@pytest.fixture
+def detail_app(make_app, artifacts):
+ return make_app(FakeClient(artifacts=artifacts))
+
+
+async def open_detail(app, pilot, size_note=""):
+ await settle(app, pilot)
+ app.open_detail("rebuild-core")
+ await settle(app, pilot)
+ return app.screen
+
+
+class TestMarkdownRendering:
+ async def test_single_file_artifact_gets_one_viewer(self, detail_app):
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ pane = screen.query_one("#pane-proposal", ArtifactPane)
+ assert len(list(pane.query(MarkdownViewer))) == 1
+
+ async def test_ascii_diagrams_stay_aligned(self, detail_app):
+ """Monospace preservation is a spec requirement, not a nicety."""
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ await open_detail(detail_app, pilot)
+ box_lines = [ln for ln in screen_lines(detail_app) if "+--------+" in ln]
+ assert box_lines, "diagram not rendered"
+ assert len({ln.index("+--------+") for ln in box_lines}) == 1
+
+ async def test_multi_file_artifact_becomes_one_tab_per_file(self, detail_app):
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ pane = screen.query_one("#pane-specs", ArtifactPane)
+ nested = pane.query_one(TabbedContent)
+ labels = [str(tab._title) for tab in nested.query(TabPane)]
+ assert labels == ["alpha-spec.md", "beta-spec.md"]
+ assert len(list(pane.query(MarkdownViewer))) == 2
+
+ async def test_missing_artifact_renders_an_empty_state(self, detail_app):
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ pane = screen.query_one("#pane-design", ArtifactPane)
+ assert list(pane.query(EmptyState))
+
+
+class TestTableOfContents:
+ async def test_shown_on_a_wide_terminal(self, detail_app):
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ viewers = list(screen.query(MarkdownViewer))
+ assert viewers and all(v.show_table_of_contents for v in viewers)
+
+ async def test_suppressed_on_a_narrow_terminal(self, detail_app):
+ async with detail_app.run_test(size=(TOC_MIN_WIDTH - 20, 30)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ assert not any(
+ v.show_table_of_contents for v in screen.query(MarkdownViewer)
+ )
+
+ async def test_t_toggles_it(self, detail_app):
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ await pilot.press("t")
+ await pilot.pause()
+ assert not any(
+ v.show_table_of_contents for v in screen.query(MarkdownViewer)
+ )
+ await pilot.press("t")
+ await pilot.pause()
+ assert all(v.show_table_of_contents for v in screen.query(MarkdownViewer))
+
+ async def test_lists_the_document_headings(self, detail_app):
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ viewer = screen.query_one("#pane-proposal", ArtifactPane).query_one(
+ MarkdownViewer
+ )
+ headings = [entry[1] for entry in viewer.document.table_of_contents]
+ assert headings == ["Diagram", "Section two"]
+
+ async def test_selecting_a_heading_scrolls_the_document(self, detail_app, tmp_path):
+ long_doc = tmp_path / "long.md"
+ long_doc.write_text(
+ "# Top\n\n" + ("filler paragraph\n\n" * 60) + "## Far Below\n\nend\n"
+ )
+ async with detail_app.run_test(size=(120, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ viewer = screen.query_one("#pane-proposal", ArtifactPane).query_one(
+ MarkdownViewer
+ )
+ await viewer.document.load(long_doc)
+ await pilot.pause()
+ viewer.scroll_to(y=0, animate=False)
+ await pilot.pause()
+
+ assert viewer.document.goto_anchor("far-below")
+ await pilot.pause()
+ assert viewer.scroll_offset.y > 0
+
+
+class TestTasksPane:
+ async def test_sections_render_with_their_counts(self, detail_app):
+ async with detail_app.run_test(size=(110, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(detail_app, pilot)
+ pane = screen.query_one("#pane-tasks", TasksPane)
+ titles = [str(c.title) for c in pane.query(Collapsible)]
+ assert titles == ["1. First (1/2)", "2. Second (1/1)"]
+
+ async def test_overall_progress_is_shown(self, detail_app):
+ async with detail_app.run_test(size=(110, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(detail_app, pilot)
+ pane = screen.query_one("#pane-tasks", TasksPane)
+ summary = str(pane.query_one(".tasks-summary").content)
+ assert "2/3" in summary and "67%" in summary
+
+ async def test_items_carry_done_and_pending_indicators(self, detail_app):
+ async with detail_app.run_test(size=(110, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(detail_app, pilot)
+ pane = screen.query_one("#pane-tasks", TasksPane)
+ items = [str(i.content) for i in pane.query(".task-item")]
+ assert len(items) == 3
+ assert any("✓" in i for i in items) and any("○" in i for i in items)
+
+ async def test_pane_never_writes_to_disk(self, detail_app, artifacts):
+ """Task edits go through the CLI or $EDITOR — never this pane."""
+ tasks_file = artifacts["tasks"][0]
+ before = tasks_file.read_bytes()
+ async with detail_app.run_test(size=(110, 34)) as pilot:
+ screen = await open_detail(detail_app, pilot)
+ screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(detail_app, pilot)
+ for key in ("space", "enter", "x", "left", "right", "up", "down"):
+ await pilot.press(key)
+ await settle(detail_app, pilot)
+ assert tasks_file.read_bytes() == before
+
+ async def test_falls_back_to_markdown_without_checkboxes(self, make_app, tmp_path):
+ prose = tmp_path / "tasks.md"
+ prose.write_text("# Tasks\n\nWe will do things. No checkboxes.\n")
+ app = make_app(FakeClient(artifacts={"tasks": [prose]}))
+ async with app.run_test(size=(110, 34)) as pilot:
+ screen = await open_detail(app, pilot)
+ screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(app, pilot)
+ pane = screen.query_one("#pane-tasks", TasksPane)
+ assert list(pane.query(MarkdownViewer))
+ assert not list(pane.query(Collapsible))
+
+ async def test_change_without_tasks_shows_an_empty_state(self, make_app):
+ app = make_app(FakeClient(artifacts={}))
+ async with app.run_test(size=(110, 34)) as pilot:
+ screen = await open_detail(app, pilot)
+ screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(app, pilot)
+ pane = screen.query_one("#pane-tasks", TasksPane)
+ empty = pane.query(EmptyState)
+ assert empty and "No tasks" in str(empty.first().content)
diff --git a/tests/test_interaction.py b/tests/test_interaction.py
new file mode 100644
index 0000000..e21d542
--- /dev/null
+++ b/tests/test_interaction.py
@@ -0,0 +1,364 @@
+"""Cross-cutting interaction: workers, palette, filtering, help, notifications."""
+
+from __future__ import annotations
+
+import threading
+import time
+
+from textual.command import CommandPalette
+from textual.containers import VerticalScroll
+from textual.widgets import DataTable, HelpPanel, Input, OptionList, Static
+
+from conftest import FailingClient, FakeClient, settle
+from openspec_ui.screens.detail import DetailScreen
+from openspec_ui.screens.kanban import ChangeCard, KanbanScreen
+from openspec_ui.screens.new_change import NewChangeScreen
+from openspec_ui.screens.spec_detail import SpecDetailScreen
+from openspec_ui.widgets import EmptyState, FilterBar, StatusBar
+
+
+# --------------------------------------------------------------------------
+# workers
+# --------------------------------------------------------------------------
+
+
+class SlowClient(FakeClient):
+ """Delays reads so the in-flight window is observable."""
+
+ def __init__(self, delay: float = 0.4) -> None:
+ super().__init__()
+ self.delay = delay
+ self.threads: list[str] = []
+
+ def list_changes(self):
+ self.threads.append(threading.current_thread().name)
+ time.sleep(self.delay)
+ return super().list_changes()
+
+
+class TestNonBlockingReads:
+ async def test_ui_answers_keys_while_a_read_is_in_flight(self, make_app):
+ client = SlowClient(delay=0.5)
+ app = make_app(client)
+ async with app.run_test(size=(100, 30)) as pilot:
+ await pilot.pause()
+ assert app.screen.query_one("#body", VerticalScroll).loading
+
+ started = time.monotonic()
+ await pilot.press("p")
+ elapsed = time.monotonic() - started
+
+ assert elapsed < 0.3, f"input blocked for {elapsed:.2f}s"
+ assert app.live_refresh is False
+ await app.workers.wait_for_complete()
+
+ async def test_reads_run_off_the_ui_thread(self, make_app):
+ client = SlowClient(delay=0.1)
+ app = make_app(client)
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ assert client.threads
+ assert "MainThread" not in client.threads
+
+ async def test_loading_flag_clears_on_success(self, make_app):
+ app = make_app(SlowClient(delay=0.1))
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ assert not app.screen.query_one("#body", VerticalScroll).loading
+
+ async def test_loading_flag_clears_on_failure(self, make_app):
+ app = make_app(FailingClient())
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ assert not app.screen.query_one("#body", VerticalScroll).loading
+
+ async def test_superseded_reads_do_not_render(self, make_app):
+ """Rapid refreshes collapse to the latest; no stale result lands."""
+ app = make_app(SlowClient(delay=0.3))
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ renders: list[int] = []
+ screen = app.screen
+ original = screen._finish_load
+
+ def counting(changes, specs):
+ renders.append(len(changes))
+ return original(changes, specs)
+
+ screen._finish_load = counting
+ for _ in range(5):
+ await pilot.press("r")
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+
+ assert len(renders) <= 1, f"{len(renders)} renders for 5 refreshes"
+
+
+# --------------------------------------------------------------------------
+# notifications and status bar
+# --------------------------------------------------------------------------
+
+
+class TestNotifications:
+ async def test_live_toggle_updates_every_mounted_status_bar(self, app):
+ def live_text() -> str:
+ return str(
+ app.screen.query_one(StatusBar)
+ .query_one("#status-live", Static)
+ .content
+ )
+
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ assert "live" in live_text()
+ await pilot.press("p")
+ await pilot.pause()
+ assert "paused" in live_text()
+
+ await pilot.press("k")
+ await settle(app, pilot)
+ assert "paused" in live_text()
+
+ async def test_status_bar_shows_the_resolved_root(self, app):
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ root_text = str(
+ app.screen.query_one(StatusBar)
+ .query_one("#status-root", Static)
+ .content
+ )
+ assert str(app.root) in root_text
+
+
+# --------------------------------------------------------------------------
+# command palette
+# --------------------------------------------------------------------------
+
+
+def palette_options(app) -> list[str]:
+ option_list = app.screen.query_one(OptionList)
+ return [
+ str(option_list.get_option_at_index(i).prompt)
+ for i in range(option_list.option_count)
+ ]
+
+
+class TestCommandPalette:
+ async def test_opens_and_lists_commands_with_nothing_typed(self, app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("ctrl+p")
+ await settle(app, pilot)
+ assert isinstance(app.screen, CommandPalette)
+ listed = " | ".join(palette_options(app)).lower()
+ for expected in ("dashboard", "kanban", "new change", "theme"):
+ assert expected in listed
+
+ async def test_lists_no_duplicate_commands(self, app):
+ """Textual already provides Quit; two would read as a bug."""
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("ctrl+p")
+ await settle(app, pilot)
+ names = [opt.split("\n")[0] for opt in palette_options(app)]
+ assert len(names) == len(set(names)), names
+
+ async def test_typing_narrows_the_list(self, app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("ctrl+p")
+ await settle(app, pilot)
+ before = len(palette_options(app))
+ for char in "kanb":
+ await pilot.press(char)
+ await settle(app, pilot)
+ after = palette_options(app)
+ assert 0 < len(after) < before
+ assert any("kanban" in option.lower() for option in after)
+
+ async def test_jumps_to_a_change_by_name(self, app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("ctrl+p")
+ await settle(app, pilot)
+ for char in "rebuild":
+ await pilot.press(char)
+ await settle(app, pilot)
+ await pilot.press("enter")
+ await settle(app, pilot)
+ assert isinstance(app.screen, DetailScreen)
+ assert app.screen.change_name == "rebuild-core"
+
+ async def test_jumps_to_a_spec_by_name(self, app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("ctrl+p")
+ await settle(app, pilot)
+ for char in "core-eng":
+ await pilot.press(char)
+ await settle(app, pilot)
+ await pilot.press("enter")
+ await settle(app, pilot)
+ assert isinstance(app.screen, SpecDetailScreen)
+ assert app.screen.spec_name == "core-engine"
+
+ async def test_theme_changes_in_session(self, app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ app.theme = "nord"
+ await pilot.pause()
+ assert app.theme == "nord"
+
+
+# --------------------------------------------------------------------------
+# filtering
+# --------------------------------------------------------------------------
+
+
+async def type_filter(pilot, text: str) -> None:
+ await pilot.press("slash")
+ await pilot.pause()
+ for char in text:
+ await pilot.press(char)
+ for _ in range(3):
+ await pilot.pause()
+
+
+class TestFiltering:
+ async def test_slash_reveals_the_bar_and_narrows_the_listing(self, app):
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ bar = app.screen.query_one("#filter", FilterBar)
+ assert not bar.display
+
+ table = app.screen.query_one("#changes", DataTable)
+ before = table.row_count
+ await type_filter(pilot, "rebuild")
+
+ assert bar.display
+ assert 0 < table.row_count < before
+
+ async def test_filtered_rows_keep_stage_and_progress(self, app):
+ from conftest import screen_lines
+
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await type_filter(pilot, "rebuild")
+ row = [ln for ln in screen_lines(app) if "rebuild-core" in ln]
+ assert row and "In Progress" in row[0] and "3/8" in row[0]
+
+ async def test_escape_clears_and_restores(self, app):
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ table = app.screen.query_one("#changes", DataTable)
+ before = table.row_count
+ await type_filter(pilot, "rebuild")
+ await pilot.press("escape")
+ await settle(app, pilot)
+ assert not app.screen.query_one("#filter", FilterBar).display
+ assert table.row_count == before
+
+ async def test_no_match_renders_an_empty_state(self, app):
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await type_filter(pilot, "zzzznope")
+ empty = app.screen.query_one("#changes_empty", EmptyState)
+ assert empty.display and "matches" in str(empty.content)
+
+ async def test_filter_survives_a_reload(self, app):
+ """Live refresh must not resurrect filtered-out rows."""
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ table = app.screen.query_one("#changes", DataTable)
+ await type_filter(pilot, "rebuild")
+ narrowed = table.row_count
+ app.screen.reload()
+ await settle(app, pilot)
+ assert table.row_count == narrowed
+
+ async def test_relaunch_opens_unfiltered(self, make_app):
+ app = make_app()
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await type_filter(pilot, "rebuild")
+
+ fresh = make_app()
+ async with fresh.run_test(size=(110, 34)) as pilot:
+ await settle(fresh, pilot)
+ bar = fresh.screen.query_one("#filter", FilterBar)
+ assert not bar.display and not bar.value
+
+ async def test_kanban_filters_its_cards(self, app):
+ async with app.run_test(size=(110, 30)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("k")
+ await settle(app, pilot)
+ screen = app.screen
+ assert isinstance(screen, KanbanScreen)
+
+ def card_count() -> int:
+ return sum(
+ len(list(column.query(ChangeCard)))
+ for column in screen._columns.values()
+ )
+
+ before = card_count()
+ await type_filter(pilot, "rebuild")
+ assert 0 < card_count() < before
+
+ await pilot.press("escape")
+ await settle(app, pilot)
+ assert card_count() == before
+
+
+# --------------------------------------------------------------------------
+# help panel
+# --------------------------------------------------------------------------
+
+
+class TestHelpPanel:
+ async def test_question_mark_opens_help_with_screen_bindings(self, app):
+ from conftest import screen_text
+
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("question_mark")
+ await settle(app, pilot)
+ assert list(app.screen.query(HelpPanel))
+ text = screen_text(app)
+ assert "Kanban" in text and "Filter" in text
+
+ async def test_help_reflects_the_current_screen(self, app):
+ from conftest import screen_text
+
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("k")
+ await settle(app, pilot)
+ await pilot.press("question_mark")
+ await settle(app, pilot)
+ assert "Dashboard" in screen_text(app)
+
+ async def test_question_mark_types_into_a_focused_filter(self, app):
+ """`?` is printable, so a focused Input must win."""
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("slash")
+ await pilot.pause()
+ await pilot.press("question_mark")
+ for _ in range(3):
+ await pilot.pause()
+ assert not list(app.screen.query(HelpPanel))
+ assert app.screen.query_one("#filter", FilterBar).value == "?"
+
+ async def test_question_mark_types_into_the_new_change_input(self, app):
+ async with app.run_test(size=(110, 34)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("n")
+ await settle(app, pilot)
+ assert isinstance(app.screen, NewChangeScreen)
+ await pilot.press("question_mark")
+ for _ in range(3):
+ await pilot.pause()
+ assert not list(app.screen.query(HelpPanel))
+ assert app.screen.query_one("#name", Input).value == "?"
diff --git a/tests/test_kanban.py b/tests/test_kanban.py
new file mode 100644
index 0000000..84f3b0f
--- /dev/null
+++ b/tests/test_kanban.py
@@ -0,0 +1,129 @@
+"""Kanban: stage identity, card structure, live movement between columns."""
+
+from __future__ import annotations
+
+from textual.widgets import DataTable, Label
+
+from conftest import FailingClient, FakeClient, settle
+from openspec_ui.client import Change, ChangeStage
+from openspec_ui.screens.kanban import ChangeCard, KanbanScreen
+from openspec_ui.theme import STAGE_STYLE, stage_style
+from openspec_ui.widgets import EmptyState
+
+
+async def open_kanban(app, pilot) -> KanbanScreen:
+ await settle(app, pilot)
+ await pilot.press("k")
+ await settle(app, pilot)
+ assert isinstance(app.screen, KanbanScreen)
+ return app.screen
+
+
+def cards_by_stage(screen: KanbanScreen) -> dict[str, list[str]]:
+ return {
+ stage: [card.change.name for card in column.query(ChangeCard)]
+ for stage, column in screen._columns.items()
+ }
+
+
+async def test_changes_land_in_the_column_for_their_stage(app):
+ async with app.run_test(size=(110, 30)) as pilot:
+ screen = await open_kanban(app, pilot)
+ assert cards_by_stage(screen) == {
+ ChangeStage.DRAFT: ["add-widgets"],
+ ChangeStage.IN_PROGRESS: ["rebuild-core"],
+ ChangeStage.COMPLETED: ["ship-release"],
+ ChangeStage.ARCHIVED: [],
+ }
+
+
+async def test_column_headings_and_cards_carry_their_stage_class(app):
+ """One mapping drives every place a stage is drawn."""
+ async with app.run_test(size=(110, 30)) as pilot:
+ screen = await open_kanban(app, pilot)
+ for stage, column in screen._columns.items():
+ heading = column.query_one(".column-title")
+ assert stage_style(stage).css_class in heading.classes
+ for card in column.query(ChangeCard):
+ assert stage_style(card.change.stage).css_class in card.classes
+
+
+async def test_column_count_is_shown_and_survives_a_narrow_column(app):
+ async with app.run_test(size=(80, 24)) as pilot:
+ screen = await open_kanban(app, pilot)
+ counts = {
+ stage: str(column.query_one(".column-title-count", Label).content)
+ for stage, column in screen._columns.items()
+ }
+ assert counts[ChangeStage.DRAFT] == "1"
+ assert counts[ChangeStage.ARCHIVED] == "0"
+
+
+async def test_dashboard_badge_uses_the_same_token_as_the_mapping(app):
+ async with app.run_test(size=(110, 30)) as pilot:
+ await settle(app, pilot)
+ table = app.screen.query_one("#changes", DataTable)
+ badge = table.get_cell_at((1, 0)) # the In Progress row
+ assert badge.spans, "stage badge carries no style"
+ assert badge.spans[0].style == f"${STAGE_STYLE[ChangeStage.IN_PROGRESS].token}"
+
+
+async def test_card_moves_column_on_live_refresh(make_app):
+ """No keypress: the poll tick notices and the card relocates."""
+ client = FakeClient(changes=[Change("alpha", 1, 4, None, None)])
+ app = make_app(client)
+ async with app.run_test(size=(110, 30)) as pilot:
+ screen = await open_kanban(app, pilot)
+ assert cards_by_stage(screen)[ChangeStage.IN_PROGRESS] == ["alpha"]
+
+ client.changes = [Change("alpha", 4, 4, None, None)]
+ client.mark_dirty()
+ for _ in range(15):
+ await pilot.pause(delay=0.2)
+ await app.workers.wait_for_complete()
+ if cards_by_stage(screen)[ChangeStage.COMPLETED]:
+ break
+
+ assert cards_by_stage(screen)[ChangeStage.COMPLETED] == ["alpha"]
+ assert cards_by_stage(screen)[ChangeStage.IN_PROGRESS] == []
+
+
+async def test_empty_column_shows_a_placeholder(app):
+ async with app.run_test(size=(110, 30)) as pilot:
+ screen = await open_kanban(app, pilot)
+ archived = screen._columns[ChangeStage.ARCHIVED]
+ placeholder = archived.query_one(EmptyState)
+ assert placeholder.display
+ assert "nothing" in str(placeholder.content)
+
+
+async def test_failed_read_reports_a_toast_and_an_inline_state(make_app):
+ app = make_app(FailingClient())
+ async with app.run_test(size=(110, 30)) as pilot:
+ await settle(app, pilot)
+ await pilot.press("k")
+ await settle(app, pilot)
+ assert list(app._notifications)
+ title = app.screen.query_one("#title")
+ assert str(title.content) == "Kanban — change pipeline"
+ placeholders = [
+ str(column.query_one(EmptyState).content)
+ for column in app.screen._columns.values()
+ ]
+ assert any("openspec" in text for text in placeholders)
+
+
+async def test_cards_do_not_wrap_at_eighty_columns(app):
+ """A wrapped name or bar costs a row and misaligns the cards below it."""
+ from conftest import screen_lines
+
+ async with app.run_test(size=(80, 24)) as pilot:
+ await open_kanban(app, pilot)
+ lines = screen_lines(app)
+ card_rows = [i for i, line in enumerate(lines) if "add-widgets" in line]
+ assert card_rows, lines
+ # Each column's first card must start on the same row.
+ first_card_rows = {
+ i for i, line in enumerate(lines) if "█ " in line and "─" not in line
+ }
+ assert first_card_rows, lines
diff --git a/tests/test_small_terminal.py b/tests/test_small_terminal.py
new file mode 100644
index 0000000..2916a99
--- /dev/null
+++ b/tests/test_small_terminal.py
@@ -0,0 +1,103 @@
+"""Every screen must stay usable in an 80x24 terminal.
+
+80x24 is the floor a terminal app is expected to survive. These tests walk each
+screen at that size and assert nothing overflows the width, nothing is pushed
+off the bottom, and each screen still renders content between its chrome.
+"""
+
+from __future__ import annotations
+
+import pytest
+from textual.widgets import HelpPanel, TabbedContent
+
+from conftest import FakeClient, screen_lines, settle
+
+SIZE = (80, 24)
+
+
+@pytest.fixture
+def small_app(make_app, tmp_path):
+ proposal = tmp_path / "proposal.md"
+ proposal.write_text("# Proposal\n\nSome prose that wraps a little.\n")
+ tasks = tmp_path / "tasks.md"
+ tasks.write_text("## 1. Section\n- [x] 1.1 done\n- [ ] 1.2 pending\n")
+ return make_app(
+ FakeClient(artifacts={"proposal": [proposal], "tasks": [tasks]})
+ )
+
+
+def assert_fits(app, label: str) -> None:
+ lines = screen_lines(app)
+ too_wide = [(i, len(line)) for i, line in enumerate(lines) if len(line) > SIZE[0]]
+ assert not too_wide, f"{label}: lines exceed {SIZE[0]} cells: {too_wide[:3]}"
+ assert len(lines) <= SIZE[1], f"{label}: {len(lines)} rows in a {SIZE[1]}-row view"
+ body = [line for line in lines[2:-2] if line.strip()]
+ assert body, f"{label}: nothing rendered between header and footer"
+
+
+async def test_dashboard_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ assert_fits(small_app, "dashboard")
+
+
+async def test_dashboard_with_filter_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ await pilot.press("slash")
+ await pilot.pause()
+ for char in "reb":
+ await pilot.press(char)
+ await settle(small_app, pilot)
+ assert_fits(small_app, "dashboard+filter")
+
+
+async def test_kanban_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ await pilot.press("k")
+ await settle(small_app, pilot)
+ assert_fits(small_app, "kanban")
+
+
+async def test_change_detail_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ small_app.open_detail("rebuild-core")
+ await settle(small_app, pilot)
+ assert_fits(small_app, "change detail")
+
+
+async def test_tasks_pane_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ small_app.open_detail("rebuild-core")
+ await settle(small_app, pilot)
+ small_app.screen.query_one("#tabs", TabbedContent).active = "pane-tasks"
+ await settle(small_app, pilot)
+ assert_fits(small_app, "tasks pane")
+
+
+async def test_spec_detail_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ small_app.open_spec("core-engine")
+ await settle(small_app, pilot)
+ assert_fits(small_app, "spec detail")
+
+
+async def test_new_change_modal_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ await pilot.press("n")
+ await settle(small_app, pilot)
+ assert_fits(small_app, "new-change modal")
+
+
+async def test_help_panel_fits(small_app):
+ async with small_app.run_test(size=SIZE) as pilot:
+ await settle(small_app, pilot)
+ await pilot.press("question_mark")
+ await settle(small_app, pilot)
+ assert list(small_app.screen.query(HelpPanel))
+ assert_fits(small_app, "help panel")
diff --git a/tests/test_spec_browser.py b/tests/test_spec_browser.py
new file mode 100644
index 0000000..368d911
--- /dev/null
+++ b/tests/test_spec_browser.py
@@ -0,0 +1,105 @@
+"""Spec browser: overview, expandable requirements, empty and error states."""
+
+from __future__ import annotations
+
+from textual.widgets import Collapsible
+
+from conftest import (
+ FailingClient,
+ FakeClient,
+ default_spec_detail,
+ requires_openspec,
+ settle,
+)
+from openspec_ui.client import OpenSpecClient, SpecDetail
+from openspec_ui.screens.spec_detail import SpecDetailScreen
+from openspec_ui.widgets import EmptyState
+
+
+async def open_spec(app, pilot, name: str = "core-engine"):
+ await settle(app, pilot)
+ app.open_spec(name)
+ await settle(app, pilot)
+ assert isinstance(app.screen, SpecDetailScreen)
+ return app.screen
+
+
+async def test_requirements_are_listed(app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ screen = await open_spec(app, pilot)
+ collapsibles = list(screen.query(Collapsible))
+ assert len(collapsibles) == 2
+
+
+async def test_scenario_counts_precede_the_requirement_text(app):
+ """A long SHALL statement truncates; a trailing count would go with it."""
+ async with app.run_test(size=(100, 34)) as pilot:
+ screen = await open_spec(app, pilot)
+ titles = [str(c.title) for c in screen.query(Collapsible)]
+ assert titles[0].startswith("1. (2 scenarios)")
+ assert titles[1].startswith("2. (1 scenario)")
+
+
+async def test_scenarios_are_hidden_until_expanded(app):
+ """Content must live inside Contents, or collapsing hides nothing."""
+ async with app.run_test(size=(100, 34)) as pilot:
+ screen = await open_spec(app, pilot)
+ first = screen.query(Collapsible).first()
+ contents = first.query_one(Collapsible.Contents)
+ assert contents.children, "requirement content is not inside Contents"
+
+ assert not contents.display
+ first.collapsed = False
+ await pilot.pause()
+ assert contents.display
+ first.collapsed = True
+ await pilot.pause()
+ assert not contents.display
+
+
+async def test_escape_returns_to_the_dashboard(app):
+ async with app.run_test(size=(100, 34)) as pilot:
+ await open_spec(app, pilot)
+ await pilot.press("escape")
+ await settle(app, pilot)
+ assert not isinstance(app.screen, SpecDetailScreen)
+
+
+async def test_spec_without_requirements_renders_an_empty_state(make_app):
+ detail = SpecDetail(
+ name="bare", title="bare", overview="A capability with nothing in it yet.",
+ requirements=[],
+ )
+ app = make_app(FakeClient(spec_detail=detail))
+ async with app.run_test(size=(100, 34)) as pilot:
+ screen = await open_spec(app, pilot, "bare")
+ assert list(screen.query(EmptyState))
+ assert not list(screen.query(Collapsible))
+
+
+async def test_failed_read_reports_a_toast_and_an_inline_error(make_app):
+ app = make_app(FailingClient())
+ async with app.run_test(size=(100, 34)) as pilot:
+ await settle(app, pilot)
+ app.open_spec("whatever")
+ await settle(app, pilot)
+ assert list(app._notifications)
+ assert list(app.screen.query(EmptyState))
+
+
+@requires_openspec
+def test_show_spec_parses_the_cli_contract():
+ """Guards the shape this app depends on from `openspec show --type spec`."""
+ client = OpenSpecClient()
+ detail = client.show_spec("openspec-tui")
+ assert detail.name == "openspec-tui"
+ assert len(detail.overview) > 20
+ assert detail.requirements
+ first = detail.requirements[0]
+ assert first.text and "SHALL" in first.text
+ assert first.scenarios and all(isinstance(s, str) for s in first.scenarios)
+
+
+def test_default_spec_detail_fixture_is_representative():
+ detail = default_spec_detail()
+ assert detail.requirements[0].scenarios
diff --git a/tests/test_theming.py b/tests/test_theming.py
new file mode 100644
index 0000000..9621689
--- /dev/null
+++ b/tests/test_theming.py
@@ -0,0 +1,171 @@
+"""Theme-following: nothing hard-coded, nothing unreadable, on any theme.
+
+The app ships no palette of its own — every colour comes from the active
+Textual theme. These tests walk each screen under a truecolor light theme, a
+truecolor dark theme and an ANSI theme, and assert that:
+
+* no text is rendered in a colour too close to what it sits on, and
+* no truecolor literal reaches the screen while an ANSI theme is active, which
+ is the signature of a hard-coded colour bypassing the theme.
+"""
+
+from __future__ import annotations
+
+import pytest
+from textual.command import CommandPalette
+
+from conftest import FakeClient, screen_lines, settle
+
+THEMES = ["textual-dark", "textual-light", "ansi-dark"]
+
+#: Below this ratio a foreground is not readable against its background.
+MIN_CONTRAST = 1.35
+
+
+def _luminance(triplet) -> float:
+ def channel(value: int) -> float:
+ c = value / 255
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+ r, g, b = (channel(v) for v in (triplet.red, triplet.green, triplet.blue))
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+
+
+def contrast(fg, bg) -> float:
+ a, b = _luminance(fg), _luminance(bg)
+ hi, lo = max(a, b), min(a, b)
+ return (hi + 0.05) / (lo + 0.05)
+
+
+def audit(app, label: str) -> list[str]:
+ """Return readability and hard-coded-colour problems on the current screen.
+
+ Textual's built-in command palette is skipped: it is framework chrome we
+ neither compose nor style, and its border half-blocks and search glyph are
+ deliberately low-contrast decoration.
+ """
+ if isinstance(app.screen, CommandPalette):
+ return []
+
+ problems: list[str] = []
+ for strip in app.screen._compositor.render_strips():
+ for segment in strip._segments:
+ text = segment.text.strip()
+ if not text or segment.style is None:
+ continue
+ fg, bg = segment.style.color, segment.style.bgcolor
+ if fg is None or bg is None:
+ continue
+ fg_true = not fg.is_default and fg.triplet is not None
+ bg_true = not bg.is_default and bg.triplet is not None
+ if fg_true and bg_true:
+ ratio = contrast(fg.triplet, bg.triplet)
+ if ratio < MIN_CONTRAST:
+ problems.append(f"{label}: {text!r} contrast {ratio:.2f}")
+ elif not fg.is_default and not bg.is_default and fg == bg:
+ problems.append(f"{label}: {text!r} foreground equals background")
+
+ # Under an ANSI theme every themed colour arrives as a palette
+ # entry, so a truecolor triplet can only be a literal of ours.
+ if app.native_ansi_color:
+ for color in (fg, bg):
+ if color is not None and not color.is_default and color.triplet:
+ problems.append(f"{label}: hard-coded colour {color.name}")
+ return problems
+
+
+async def walk_screens(app, pilot, label: str) -> list[str]:
+ """Visit every screen in turn, auditing each."""
+ problems: list[str] = []
+
+ async def visit(name: str) -> None:
+ await settle(app, pilot)
+ problems.extend(audit(app, f"{label}/{name}"))
+
+ await visit("dashboard")
+
+ await pilot.press("k")
+ await visit("kanban")
+
+ await pilot.press("d")
+ await settle(app, pilot)
+ await pilot.press("enter")
+ await visit("change detail")
+
+ await pilot.press("d")
+ await settle(app, pilot)
+ await pilot.press("n")
+ await visit("new-change modal")
+ await pilot.press("escape")
+ await settle(app, pilot)
+
+ app.open_spec("core-engine")
+ await visit("spec detail")
+ await pilot.press("escape")
+ await settle(app, pilot)
+
+ await pilot.press("slash")
+ await pilot.pause()
+ for char in "reb":
+ await pilot.press(char)
+ await visit("filter active")
+
+ return problems
+
+
+@pytest.mark.parametrize("theme", THEMES)
+async def test_screens_are_readable_under_theme(make_app, tmp_path, theme):
+ tasks = tmp_path / "tasks.md"
+ tasks.write_text("## 1. Section\n- [x] 1.1 done\n- [ ] 1.2 pending\n")
+ client = FakeClient(artifacts={"tasks": [tasks]})
+ app = make_app(client)
+ async with app.run_test(size=(100, 30)) as pilot:
+ app.theme = theme
+ await settle(app, pilot)
+ problems = await walk_screens(app, pilot, theme)
+ assert not problems, "\n".join(problems)
+
+
+@pytest.mark.parametrize("theme", THEMES)
+async def test_theme_switch_does_not_break_rendering(make_app, theme):
+ """Switching theme mid-session must repaint, not blank the screen."""
+ app = make_app()
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ app.theme = theme
+ await settle(app, pilot)
+ body = [line for line in screen_lines(app)[2:-2] if line.strip()]
+ assert body, f"screen rendered empty under {theme}"
+ assert not audit(app, theme)
+
+
+async def test_no_theme_preference_is_persisted(make_app):
+ """Theme is session-only — a fresh app starts on the default."""
+ app = make_app()
+ async with app.run_test(size=(100, 30)) as pilot:
+ await settle(app, pilot)
+ default = app.theme
+ app.theme = "nord"
+ await pilot.pause()
+ assert app.theme == "nord"
+
+ fresh = make_app()
+ async with fresh.run_test(size=(100, 30)) as pilot:
+ await settle(fresh, pilot)
+ assert fresh.theme == default
+
+
+def test_stylesheet_has_no_colour_literals():
+ """Colour belongs to the theme; the stylesheet may only name tokens."""
+ import re
+
+ from openspec_ui.app import OpenSpecUIApp
+
+ from conftest import REPO_ROOT
+
+ css = (REPO_ROOT / "openspec_ui" / OpenSpecUIApp.CSS_PATH).read_text()
+ # Strip comments and id selectors before looking for hex values.
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
+ css = re.sub(r"^\s*#[\w-]+", "", css, flags=re.M)
+ literals = re.findall(r"#[0-9a-fA-F]{3,8}\b", css)
+ assert not literals, f"hard-coded colours in stylesheet: {literals}"
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..81876e3
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,285 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+
+[[package]]
+name = "backports-asyncio-runner"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "linkify-it-py"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "uc-micro-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[package.optional-dependencies]
+linkify = [
+ { name = "linkify-it-py" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "openspec-ui"
+version = "0.3.0"
+source = { editable = "." }
+dependencies = [
+ { name = "textual" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+]
+
+[package.metadata]
+requires-dist = [{ name = "textual", specifier = ">=1.0.0" }]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.0" },
+ { name = "pytest-asyncio", specifier = ">=0.24" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "textual"
+version = "8.2.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py", extra = ["linkify"] },
+ { name = "mdit-py-plugins" },
+ { name = "platformdirs" },
+ { name = "pygments" },
+ { name = "rich" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "uc-micro-py"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
+]