diff --git a/README.md b/README.md index f4ee146..1743fce 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This shows the full summary dashboard with a numbered session list. From there: Each sub-view has a "Press Enter to go back" prompt to return home. -If `watchdog` is installed, the display auto-refreshes when session files change (2-second debounce). Install it with `uv add watchdog` for live-updating views. +The display auto-refreshes when session files change (2-second debounce). #### Commands diff --git a/src/copilot_usage/cli.py b/src/copilot_usage/cli.py index 976ee64..cb6dd1d 100644 --- a/src/copilot_usage/cli.py +++ b/src/copilot_usage/cli.py @@ -16,6 +16,7 @@ import click from rich.console import Console from rich.table import Table +from watchdog.observers import Observer # type: ignore[import-untyped] from copilot_usage.models import SessionSummary from copilot_usage.parser import ( @@ -122,30 +123,21 @@ def dispatch(self, event: object) -> None: # noqa: ANN001 self._change_event.set() -def _start_observer(session_path: Path, change_event: threading.Event) -> object | None: - """Start a watchdog observer if available. Returns the observer or None.""" - try: - from watchdog.observers import Observer # type: ignore[import-untyped] - - handler = _FileChangeHandler(change_event) - observer = Observer() - observer.schedule(handler, str(session_path), recursive=True) # type: ignore[arg-type] - observer.daemon = True - observer.start() - return observer - except ImportError: - return None +def _start_observer(session_path: Path, change_event: threading.Event) -> object: + """Start a watchdog observer monitoring *session_path* for changes.""" + handler = _FileChangeHandler(change_event) + observer = Observer() + observer.schedule(handler, str(session_path), recursive=True) # type: ignore[arg-type] + observer.daemon = True + observer.start() + return observer def _stop_observer(observer: object | None) -> None: """Stop a watchdog observer if running.""" if observer is not None: - stop = getattr(observer, "stop", None) - if callable(stop): - stop() - join = getattr(observer, "join", None) - if callable(join): - join(timeout=2) + observer.stop() # type: ignore[union-attr] + observer.join(timeout=2) # type: ignore[union-attr] def _interactive_loop(path: Path | None) -> None: diff --git a/src/copilot_usage/docs/implementation.md b/src/copilot_usage/docs/implementation.md index 1f7ac78..5e0c5d0 100644 --- a/src/copilot_usage/docs/implementation.md +++ b/src/copilot_usage/docs/implementation.md @@ -262,7 +262,7 @@ observer.daemon = True observer.start() ``` -The observer is optional — if `watchdog` is not installed, the import fails silently and `observer` stays `None` (`cli.py:131`). +The observer watches the session-state directory; if the directory doesn't exist at startup, no observer is created and auto-refresh is simply skipped. ### `_FileChangeHandler` with 2-second debounce diff --git a/src/copilot_usage/docs/plan.md b/src/copilot_usage/docs/plan.md index 758c575..7c51000 100644 --- a/src/copilot_usage/docs/plan.md +++ b/src/copilot_usage/docs/plan.md @@ -27,7 +27,7 @@ Standards: follows `microsasa/project-standards` - **`~/.copilot/logs/process-*.log`** — CompactionProcessor lines show real-time token utilization **Commands**: -- `copilot-usage` — launches Rich interactive mode with numbered session list, cost view, and watchdog-based auto-refresh (2-second debounce when `watchdog` is installed) +- `copilot-usage` — launches Rich interactive mode with numbered session list, cost view, and watchdog-based auto-refresh (2-second debounce) - `copilot-usage session ` — per-turn token breakdown, tools used, API call timeline, code changes (static CLI output) **Interactive mode**: @@ -36,7 +36,7 @@ The main interface. Launches a Rich-based interactive loop in the terminal: - **Session detail**: enter a session number to drill into per-turn breakdown - **Cost view**: press `c` to see premium request breakdown per session, per model - **Manual refresh**: press `r` to reload session data -- **Auto-refresh**: if `watchdog` is installed, monitors `events.jsonl` files for changes and auto-refreshes the current view (2-second debounce). This provides the live-updating dashboard experience. +- **Auto-refresh**: monitors `events.jsonl` files for changes and auto-refreshes the current view (2-second debounce). This provides the live-updating dashboard experience. - **Quit**: press `q` to exit **Data philosophy**: diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index b1e0bcb..baef5a2 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -3,13 +3,18 @@ from __future__ import annotations import json +import threading from datetime import UTC, datetime from pathlib import Path from typing import Any from click.testing import CliRunner -from copilot_usage.cli import main +from copilot_usage.cli import ( + _start_observer, # pyright: ignore[reportPrivateUsage] + _stop_observer, # pyright: ignore[reportPrivateUsage] + main, +) # --------------------------------------------------------------------------- # Helpers @@ -479,3 +484,19 @@ def test_interactive_no_sessions(tmp_path: Path) -> None: result = runner.invoke(main, ["--path", str(tmp_path)], input="q\n") assert result.exit_code == 0 assert "No sessions" in result.output + + +# --------------------------------------------------------------------------- +# Watchdog observer tests +# --------------------------------------------------------------------------- + + +def test_start_observer_returns_running_observer(tmp_path: Path) -> None: + """_start_observer returns a non-None, alive observer for an existing dir.""" + change_event = threading.Event() + observer = _start_observer(tmp_path, change_event) # pyright: ignore[reportUnknownVariableType] + try: + assert observer is not None + assert observer.is_alive() # type: ignore[union-attr] + finally: + _stop_observer(observer) # pyright: ignore[reportUnknownArgumentType]