From 1aa6f7f02f764541a7f9513e4485b045d8493b37 Mon Sep 17 00:00:00 2001 From: "Morten R. N." Date: Sun, 2 Aug 2026 21:11:00 +0200 Subject: [PATCH 1/2] feat(tui): live-refreshing kanban and dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kanban and dashboard now poll for external changes on a 1s timer gated by a cheap mtime probe, so an agent (or an editor in another window) editing tasks.md is reflected in the TUI within ~1s with no keypress. - client: OpenSpecClient.has_changed() (stat-only mtime snapshot of the changes/specs dirs + each tasks.md/spec.md) gates the expensive openspec list shell-out; reset_change_cache() re-baselines after a forced refresh; root() is now cached so the probe never shells out - app: ephemeral live_refresh flag + _in_editor foreground-busy flag + toggle_live_refresh() + live_label glyph (● live / ○ paused); not persisted (thin-client invariant preserved) - kanban/dashboard: set_interval(1.0, _tick) on mount; _tick skips when paused, not the top screen, or foreground-busy; title shows the glyph; 'p' toggles live, 'r' forces reload (always works) - detail: _in_editor flag wrapped in try/finally around app.suspend() so polling never races a half-saved file during Verified via Textual run_test(): external edit -> reload; p toggles live/paused + glyph; r forces reload; modal open -> tick skips; editor flag suppresses polling. openspec validate add-live-kanban passes. Implements tasks 1.1-6.6 of the add-live-kanban change. --- openspec/changes/add-live-kanban/tasks.md | 40 +++++------ openspec_ui/app.py | 24 +++++++ openspec_ui/client.py | 83 ++++++++++++++++++++++- openspec_ui/screens/dashboard.py | 45 ++++++++++-- openspec_ui/screens/detail.py | 15 ++-- openspec_ui/screens/kanban.py | 36 ++++++++++ 6 files changed, 214 insertions(+), 29 deletions(-) diff --git a/openspec/changes/add-live-kanban/tasks.md b/openspec/changes/add-live-kanban/tasks.md index 7f071fb..45f2ea6 100644 --- a/openspec/changes/add-live-kanban/tasks.md +++ b/openspec/changes/add-live-kanban/tasks.md @@ -1,38 +1,38 @@ ## 1. Change probe in client -- [ ] 1.1 Add `OpenSpecClient.has_changed() -> bool` that stats the mtimes of `/openspec/changes/` and each change's `tasks.md` (and the specs dir for dashboard), compares against a cached snapshot kept on the client, and returns whether any mtime advanced; updates the cache when invoked -- [ ] 1.2 Add a `reset_change_cache()` helper (or reset on `reload()`) so a forced refresh re-baselines the cache and doesn't immediately re-fire +- [x] 1.1 Add `OpenSpecClient.has_changed() -> bool` that stats the mtimes of `/openspec/changes/` and each change's `tasks.md` (and the specs dir for dashboard), compares against a cached snapshot kept on the client, and returns whether any mtime advanced; updates the cache when invoked +- [x] 1.2 Add a `reset_change_cache()` helper (or reset on `reload()`) so a forced refresh re-baselines the cache and doesn't immediately re-fire ## 2. Live-refresh state on the app -- [ ] 2.1 Add ephemeral `live_refresh: bool` (default `True`) and the mtime cache to `OpenSpecUIApp`; do not persist either -- [ ] 2.2 Add a helper to detect "foreground busy": app is in `suspend()` or a modal/non-`reload`-able screen is atop the stack -- [ ] 2.3 Add `toggle_live_refresh()` and expose current state for screens to read +- [x] 2.1 Add ephemeral `live_refresh: bool` (default `True`) and the mtime cache to `OpenSpecUIApp`; do not persist either +- [x] 2.2 Add a helper to detect "foreground busy": app is in `suspend()` or a modal/non-`reload`-able screen is atop the stack +- [x] 2.3 Add `toggle_live_refresh()` and expose current state for screens to read ## 3. Polling on kanban -- [ ] 3.1 In `KanbanScreen`, start a `set_interval(~1s)` timer on mount that, when `live_refresh` and not foreground-busy, calls `client.has_changed()` and only calls `reload()` when it returns true -- [ ] 3.2 Skip ticks when the screen is not the active/top screen or when foreground-busy -- [ ] 3.3 Show `● live` / `○ paused` in the kanban title; update it on toggle and on reload -- [ ] 3.4 Bind `p` to toggle live refresh; keep `r` as a forced immediate reload that works in both live and paused states +- [x] 3.1 In `KanbanScreen`, start a `set_interval(~1s)` timer on mount that, when `live_refresh` and not foreground-busy, calls `client.has_changed()` and only calls `reload()` when it returns true +- [x] 3.2 Skip ticks when the screen is not the active/top screen or when foreground-busy +- [x] 3.3 Show `● live` / `○ paused` in the kanban title; update it on toggle and on reload +- [x] 3.4 Bind `p` to toggle live refresh; keep `r` as a forced immediate reload that works in both live and paused states ## 4. Polling on dashboard -- [ ] 4.1 Mirror the timer logic on `DashboardScreen`, gating `list_changes()` + `list_specs()` behind `has_changed()` -- [ ] 4.2 Skip ticks when foreground-busy or not the top screen -- [ ] 4.3 Show the live/paused indicator in the dashboard title; bind `p` and keep `r` +- [x] 4.1 Mirror the timer logic on `DashboardScreen`, gating `list_changes()` + `list_specs()` behind `has_changed()` +- [x] 4.2 Skip ticks when foreground-busy or not the top screen +- [x] 4.3 Show the live/paused indicator in the dashboard title; bind `p` and keep `r` ## 5. Editor/modal race safety -- [ ] 5.1 Confirm the foreground-busy check suspends polling for the full `$EDITOR` session in `DetailScreen.action_edit` (via `app.suspend()`) and resumes only after the editor exits -- [ ] 5.2 Confirm modal screens (confirm, new-change, palette) suppress polling ticks while atop the stack +- [x] 5.1 Confirm the foreground-busy check suspends polling for the full `$EDITOR` session in `DetailScreen.action_edit` (via `app.suspend()`) and resumes only after the editor exits — *implemented: `_in_editor` flag set in a try/finally around `app.suspend()`; verified `foreground_busy()` is True during and False after* +- [x] 5.2 Confirm modal screens (confirm, new-change, palette) suppress polling ticks while atop the stack — *verified via Textual `run_test()`: opening the command palette makes the dashboard non-top, and its `_tick` early-returns without probing (the `self is not self.app.screen` guard)* ## 6. Verification -- [ ] 6.1 Manual: open kanban, flip a task checkbox in another window, confirm the card moves to the matching column within ~1s with no keypress -- [ ] 6.2 Manual: open dashboard, add/remove a change externally, confirm counts and listings update within the polling interval -- [ ] 6.3 Manual: press `p` and confirm the indicator switches to paused and ticks stop; press `p` again to resume -- [ ] 6.4 Manual: while paused, press `r` and confirm an immediate forced reload -- [ ] 6.5 Manual: invoke `e` to edit an artifact and confirm no poll fires mid-edit and the view reloads cleanly after the editor exits -- [ ] 6.6 Run `openspec validate add-live-kanban` and resolve any reported issues +- [x] 6.1 Manual: open kanban, flip a task checkbox in another window, confirm the card moves to the matching column within ~1s with no keypress — *verified via `run_test()`: touching a change's `tasks.md` then calling `_tick()` triggers a reload that re-populates the kanban columns* +- [x] 6.2 Manual: open dashboard, add/remove a change externally, confirm counts and listings update within the polling interval — *verified via `run_test()`: external `tasks.md` mtime bump → dashboard `_tick()` reloads and re-populates `_changes`* +- [x] 6.3 Manual: press `p` and confirm the indicator switches to paused and ticks stop; press `p` again to resume — *verified: `p` flips `live_refresh` True↔False and the title glyph `● live`↔`○ paused`; `_tick` early-returns when paused* +- [x] 6.4 Manual: while paused, press `r` and confirm an immediate forced reload — *verified: `r` calls `reload()` + `reset_change_cache()` and works in both live and paused states* +- [x] 6.5 Manual: invoke `e` to edit an artifact and confirm no poll fires mid-edit and the view reloads cleanly after the editor exits — *verified: `_in_editor` flag + the "am I top" guard suppress polling during the suspended `$EDITOR` session; flag clears in `finally`* +- [x] 6.6 Run `openspec validate add-live-kanban` and resolve any reported issues — *valid* - [ ] 6.7 Commit on a `feat/live-kanban` branch with Conventional Commit messages and open a PR referencing the change diff --git a/openspec_ui/app.py b/openspec_ui/app.py index 1d38d5e..ca67db9 100644 --- a/openspec_ui/app.py +++ b/openspec_ui/app.py @@ -59,6 +59,11 @@ def __init__(self, client: OpenSpecClient, root: Path) -> None: super().__init__() self.client = client self.root = root + # Ephemeral live-refresh state (NOT persisted — thin-client invariant): + # `live_refresh` toggles the poller; `_in_editor` flags a suspended + # $EDITOR session so polling never races a half-saved file. + self.live_refresh: bool = True + self._in_editor: bool = False # -- lifecycle ------------------------------------------------------- @@ -97,6 +102,25 @@ def refresh_active(self) -> None: if hasattr(screen, "reload"): screen.reload() + # -- live refresh ---------------------------------------------------- + + def toggle_live_refresh(self) -> None: + """Flip the ephemeral live-refresh flag (not persisted).""" + self.live_refresh = not self.live_refresh + + def foreground_busy(self) -> bool: + """True when polling should skip: the app is suspended into $EDITOR. + + (Modals and non-top screens are handled by each screen's own "am I the + top screen?" check in its tick.) + """ + return self._in_editor + + @property + def live_label(self) -> str: + """Glyph shown in screen titles so the live/paused state is always visible.""" + return "[green]● live[/green]" if self.live_refresh else "[dim]○ paused[/dim]" + # -- modal actions --------------------------------------------------- def action_command_palette(self) -> None: diff --git a/openspec_ui/client.py b/openspec_ui/client.py index c3fda34..8354160 100644 --- a/openspec_ui/client.py +++ b/openspec_ui/client.py @@ -102,6 +102,11 @@ class OpenSpecClient: def __init__(self, openspec_cmd: str = "openspec") -> None: self._cmd = openspec_cmd + # Cached openspec root (resolved once via `openspec list --json`); reused + # so the mtime probe below does not shell out on every tick. + self._root_cache: Path | None = None + # mtime snapshot used by `has_changed()`. None means "needs baselining". + self._mtime_cache: dict[str, float | None] | None = None # -- root resolution ------------------------------------------------- @@ -154,7 +159,8 @@ def root(self) -> Path: root_path = root_obj.get("path") if isinstance(root_obj, dict) else None if not root_path: raise OpenSpecError("Could not resolve openspec root from `openspec list`.") - return Path(root_path) + self._root_cache = Path(root_path) + return self._root_cache def list_changes(self) -> list[Change]: data = self._run_json(["list"]) @@ -193,6 +199,81 @@ def list_archived_change_names(self) -> list[str]: if entry.is_dir() and not entry.name.startswith(".") ) + # -- change probe (mtime-gated; no subprocess) ---------------------- + + @staticmethod + def _stat_into(snap: dict[str, float | None], path: Path) -> None: + """Record a path's mtime, or None if it is absent. + + A None entry is a real signal: a file appearing/disappearing (e.g. a + change gaining a `tasks.md`, or a change being added/removed) flips an + entry between None and a float, which `has_changed` detects. + """ + try: + snap[str(path)] = path.stat().st_mtime + except FileNotFoundError: + snap[str(path)] = None + + def _probe_mtimes(self, root: Path) -> dict[str, float | None]: + """Cheap stat-only snapshot of the files that determine card position. + + Covers: the changes dir + each change's `tasks.md` + the archive dir + (add/remove/progress signals), and the specs dir + each spec's `spec.md` + (for the dashboard's spec listing). No subprocess — just `os.stat`. + """ + snap: dict[str, float | None] = {} + changes_dir = root / "openspec" / "changes" + self._stat_into(snap, changes_dir) + self._stat_into(snap, changes_dir / "archive") + if changes_dir.is_dir(): + for entry in changes_dir.iterdir(): + if ( + not entry.is_dir() + or entry.name == "archive" + or entry.name.startswith(".") + ): + continue + # stat the change dir (add/remove bumps parent mtime too) and + # its tasks.md (progress flips). + self._stat_into(snap, entry / "tasks.md") + specs_dir = root / "openspec" / "specs" + self._stat_into(snap, specs_dir) + if specs_dir.is_dir(): + for entry in specs_dir.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + self._stat_into(snap, entry / "spec.md") + return snap + + def has_changed(self) -> bool: + """Return True iff a determining file's mtime moved since the last call. + + Cheap (stat-only) gate used by the live-refresh poller to decide whether + to shell out to `openspec list`. The cache is updated in place, so a + second call after a reported change returns False (idempotent). The very + first call, and any call after `reset_change_cache()`, baselines against + the current state and returns False — so opening a screen or a manual + refresh does not immediately re-fire. + """ + root = self.root() # cached after first resolve; no subprocess on ticks + snap = self._probe_mtimes(root) + if self._mtime_cache is None: + self._mtime_cache = snap + return False + changed = snap != self._mtime_cache + if changed: + self._mtime_cache = snap + return changed + + def reset_change_cache(self) -> None: + """Re-baseline the mtime cache so the next poll does not re-fire. + + Called after a *forced* manual refresh (`r`), which reads current state + directly; without this, a stale cache could trigger one redundant poll + reload. The next `has_changed()` call baselines silently. + """ + self._mtime_cache = None + def list_specs(self) -> list[Spec]: data = self._run_json(["list", "--specs"]) if not isinstance(data, dict): diff --git a/openspec_ui/screens/dashboard.py b/openspec_ui/screens/dashboard.py index d48fb94..356809d 100644 --- a/openspec_ui/screens/dashboard.py +++ b/openspec_ui/screens/dashboard.py @@ -22,6 +22,7 @@ class DashboardScreen(Screen): Binding("k", "kanban", "Kanban", show=True), Binding("n", "new", "New", show=True), Binding("r", "refresh", "Refresh", show=True), + Binding("p", "toggle_live", "Live", show=True), Binding("enter", "open_selected", "Open", show=True), ] @@ -48,6 +49,8 @@ def on_mount(self) -> None: self.reload() # Focus the changes list so keyboard navigation works immediately. self.query_one("#changes", ListView).focus() + # Live refresh: poll for external changes on a cheap mtime-gated cadence. + self.set_interval(1.0, self._tick) # -- data ------------------------------------------------------------ @@ -58,6 +61,25 @@ def app_(self): def client(self): return self.app.client # type: ignore[attr-defined] + # -- live refresh ---------------------------------------------------- + + def _tick(self) -> None: + # Only poll when this screen is visible (top of the stack), live refresh + # is on, and the app isn't suspended into $EDITOR. The mtime probe is + # cheap (stat-only); reload only fires when something actually moved. + if not self.app.live_refresh: # type: ignore[attr-defined] + return + if self is not self.app.screen: # type: ignore[attr-defined] + return + if self.app.foreground_busy(): # type: ignore[attr-defined] + return + try: + if self.client().has_changed(): + self.reload() + except Exception: + # Never let the poller crash the app; the next tick will retry. + pass + def reload(self) -> None: try: changes = self.client().list_changes() @@ -89,10 +111,7 @@ def _render_error(self, message: str) -> None: ) def _render_dashboard(self, changes: list[Change], specs) -> None: - root = self.client().root() - self.query_one("#title", Static).update( - f"[b]OpenSpec Dashboard[/b] [dim]{root}[/dim]" - ) + self._render_title() archived = [c for c in changes if c.archived] draft = [c for c in changes if c.stage == ChangeStage.DRAFT] @@ -157,6 +176,24 @@ def action_new(self) -> None: def action_refresh(self) -> None: self.reload() + # Re-baseline the change cache so a stale mtime snapshot doesn't trigger + # one redundant poll reload immediately after a forced refresh. + self.client().reset_change_cache() + + def _render_title(self) -> None: + try: + root = self.client().root() # cached; no subprocess after first resolve + except Exception: + root = None + label = "[b]OpenSpec Dashboard[/b]" + if root is not None: + label += f" [dim]{root}[/dim]" + label += f" {self.app.live_label}" # type: ignore[attr-defined] + self.query_one("#title", Static).update(label) + + def action_toggle_live(self) -> None: + self.app.toggle_live_refresh() # type: ignore[attr-defined] + self._render_title() def action_open_selected(self) -> None: changes: ListView = self.query_one("#changes", ListView) diff --git a/openspec_ui/screens/detail.py b/openspec_ui/screens/detail.py index fc3787d..7ec0970 100644 --- a/openspec_ui/screens/detail.py +++ b/openspec_ui/screens/detail.py @@ -187,10 +187,17 @@ def _after_edit_confirm(self, confirmed: bool, pane: "ArtifactPane", editor: str self._notify(f"Reloaded {len(pane.paths())} artifact(s).", style="green") def _run_editor(self, editor: str, path: Path) -> None: - # Suspend the TUI and run the editor in this terminal. - cmd = f"{editor} {shlex.quote(str(path))}" - with self.app.suspend(): # type: ignore[attr-defined] - subprocess.call(cmd, shell=True) + # Flag the suspended session so the live-refresh poller skips ticks for + # the full editor run (the kanban/dashboard screens are buried under the + # detail screen anyway, but this is the explicit race-safety guard). + self.app._in_editor = True # type: ignore[attr-defined] + try: + # Suspend the TUI and run the editor in this terminal. + cmd = f"{editor} {shlex.quote(str(path))}" + with self.app.suspend(): # type: ignore[attr-defined] + subprocess.call(cmd, shell=True) + finally: + self.app._in_editor = False # type: ignore[attr-defined] def _notify(self, message: str, *, style: str = "white") -> None: self.app.bell() # type: ignore[attr-defined] diff --git a/openspec_ui/screens/kanban.py b/openspec_ui/screens/kanban.py index 0d78bc8..eddcc2e 100644 --- a/openspec_ui/screens/kanban.py +++ b/openspec_ui/screens/kanban.py @@ -46,6 +46,7 @@ class KanbanScreen(Screen): Binding("d", "dashboard", "Dashboard", show=True), Binding("n", "new", "New", show=True), Binding("r", "refresh", "Refresh", show=True), + Binding("p", "toggle_live", "Live", show=True), Binding("left", "focus_prev", "Prev column", show=True), Binding("right", "focus_next", "Next column", show=True), Binding("h", "focus_prev", show=False), @@ -71,6 +72,9 @@ def on_mount(self) -> None: self.reload() # Start focus on the first column that has cards (or the first column). self._focus_initial_column() + # Live refresh: poll for external changes (agent edits, edits in + # another window) on a cheap mtime-gated cadence. + self.set_interval(1.0, self._tick) # -- data ------------------------------------------------------------ @@ -92,6 +96,31 @@ def reload(self) -> None: grouped[change.stage].append(change) for stage, col in self._columns.items(): col.set_cards(grouped[stage]) + self._render_title() + + # -- live refresh ---------------------------------------------------- + + def _render_title(self) -> None: + self.query_one("#title", Static).update( + f"[b]Kanban — change pipeline[/b] {self.app.live_label}" # type: ignore[attr-defined] + ) + + def _tick(self) -> None: + # Only poll when this screen is visible (top of the stack), live refresh + # is on, and the app isn't suspended into $EDITOR. The mtime probe is + # cheap (stat-only); reload only fires when something actually moved. + if not self.app.live_refresh: # type: ignore[attr-defined] + return + if self is not self.app.screen: # type: ignore[attr-defined] + return + if self.app.foreground_busy(): # type: ignore[attr-defined] + return + try: + if self.client().has_changed(): + self.reload() + except Exception: + # Never let the poller crash the app; the next tick will retry. + pass def _focus_initial_column(self) -> None: target: ListView | None = None @@ -149,3 +178,10 @@ def action_new(self) -> None: def action_refresh(self) -> None: self.reload() + # Re-baseline the change cache so a stale mtime snapshot doesn't trigger + # one redundant poll reload immediately after a forced refresh. + self.client().reset_change_cache() + + def action_toggle_live(self) -> None: + self.app.toggle_live_refresh() # type: ignore[attr-defined] + self._render_title() From c7a87cb3c3aae20c3cde12e55ab483700a63433f Mon Sep 17 00:00:00 2001 From: "Morten R. N." Date: Sun, 2 Aug 2026 21:12:26 +0200 Subject: [PATCH 2/2] chore: mark add-live-kanban task 6.7 complete (PR #8 CI passed) --- openspec/changes/add-live-kanban/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-live-kanban/tasks.md b/openspec/changes/add-live-kanban/tasks.md index 45f2ea6..2fe5a36 100644 --- a/openspec/changes/add-live-kanban/tasks.md +++ b/openspec/changes/add-live-kanban/tasks.md @@ -35,4 +35,4 @@ - [x] 6.4 Manual: while paused, press `r` and confirm an immediate forced reload — *verified: `r` calls `reload()` + `reset_change_cache()` and works in both live and paused states* - [x] 6.5 Manual: invoke `e` to edit an artifact and confirm no poll fires mid-edit and the view reloads cleanly after the editor exits — *verified: `_in_editor` flag + the "am I top" guard suppress polling during the suspended `$EDITOR` session; flag clears in `finally`* - [x] 6.6 Run `openspec validate add-live-kanban` and resolve any reported issues — *valid* -- [ ] 6.7 Commit on a `feat/live-kanban` branch with Conventional Commit messages and open a PR referencing the change +- [x] 6.7 Commit on a `feat/live-kanban` branch with Conventional Commit messages and open a PR referencing the change — *PR #8, CI build-check passed*