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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 11 additions & 19 deletions src/copilot_usage/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/copilot_usage/docs/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/copilot_usage/docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` — per-turn token breakdown, tools used, API call timeline, code changes (static CLI output)

**Interactive mode**:
Expand All @@ -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**:
Expand Down
23 changes: 22 additions & 1 deletion tests/copilot_usage/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Loading