Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions openspec/changes/add-live-kanban/tasks.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
## 1. Change probe in client

- [ ] 1.1 Add `OpenSpecClient.has_changed() -> bool` that stats the mtimes of `<root>/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 `<root>/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
- [ ] 6.7 Commit on a `feat/live-kanban` branch with Conventional Commit messages and open a PR referencing the change
- [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*
- [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*
24 changes: 24 additions & 0 deletions openspec_ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------------

Expand Down Expand Up @@ -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:
Expand Down
83 changes: 82 additions & 1 deletion openspec_ui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------

Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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):
Expand Down
45 changes: 41 additions & 4 deletions openspec_ui/screens/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]

Expand All @@ -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 ------------------------------------------------------------

Expand All @@ -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()
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 11 additions & 4 deletions openspec_ui/screens/detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 36 additions & 0 deletions openspec_ui/screens/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 ------------------------------------------------------------

Expand All @@ -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
Expand Down Expand Up @@ -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()
Loading