Skip to content

feat(cli): interactive watcher manager TUI (cix watch manage)#139

Open
dvcdsys wants to merge 2 commits into
developfrom
feat/watch-manage-tui
Open

feat(cli): interactive watcher manager TUI (cix watch manage)#139
dvcdsys wants to merge 2 commits into
developfrom
feat/watch-manage-tui

Conversation

@dvcdsys

@dvcdsys dvcdsys commented Jul 10, 2026

Copy link
Copy Markdown
Owner

What

Adds cix watch manage (alias ui) — a full-screen TUI to centrally manage file-watcher daemons — plus a client.DeleteProject method and a bugfix in config.AddProject.

Keys: s stop · r restart · S start · a/space toggle auto_watch · A start all auto_watch · x stop all · d delete project (confirmed) · l/enter tail log · g refresh · ? help.

Why

Watcher management was fragmented and non-interactive: cix watch list printed a static snapshot, and stopping one meant re-typing its path in a separate cix watch stop <path>. There was no restart command at all, and no single place to see every watcher and act on individual ones.

Separately, ProjectEntry.AutoWatch was written by cix init but no code ever read it — dead metadata. A project flagged auto_watch: true never came back after a reboot or a watch stop --all.

How

Rows are local-first. The list unions the local known-project set (~/.cix/config.yaml projects:) with the running daemons (daemon.ListAll()). Listing, stopping, and toggling need no server; the server is only a preflight on start and an optional best-effort LastIndexedAt enrichment. The list is windowed (listWindow) so long project lists stay navigable on short terminals.

Concurrency. Local actions (stop, stop-all, auto_watch toggle) are filesystem-only and microsecond-fast, so they run synchronously inside Update. Anything that talks to the server — the start/restart preflight, A (start-all-auto), delete, and the last-indexed enrichment — runs off the event loop as a tea.Cmd that only returns a message, never touches the Model: a slow or unreachable server (the client timeout is 600 s) can't freeze rendering or input. A single-flight busy gate rejects other mutating keys while a background action is running, the enrichment is single-flight too (a stalled server can't stack one hung request per 2 s tick), and StartAllAuto probes /health once per batch instead of once per pending project. The log-tail overlay reads only the last 64 KiB of the (unrotated, append-only) watcher log.

Preflight is load-bearing, not cosmetic. daemon.Start returns success on fork, not on child-survival — the child self-exits if the project isn't registered, leaving a phantom "running" row until the next prune. GuardedStart (server reachable + project registered) now gates every start and is shared: runWatchDaemon reuses it instead of duplicating the check.

Delete ordering. Stop the watcher first (otherwise a reindex can re-create the project between the server delete and the local cleanup) → delete server-side → drop the local config entry and log. A 404 maps to client.ErrProjectGone and is treated as an idempotent success (already deleted server-side → still clean up locally). Any other server error (403/5xx/network) aborts before local state is touched, so a live project is never orphaned. d requires an explicit y confirmation.

Authorization note: DELETE /api/v1/projects/{hash} is guarded by requireProjectOwnership (owner or admin), so the CLI's normal API key deletes the user's own local projects — no admin key needed and no new endpoint was added.

Restart is guard → stop → start (guard first, so a server-down restart never leaves the user with nothing running). It lives in watchtui, not daemon, because it needs the client — daemon deliberately doesn't import it.

Bugfix: config.AddProject dropped auto_watch updates

for _, p := range cfg.Projects {   // p is a copy — []ProjectEntry is a slice of values
    if p.Path == path {
        p.AutoWatch = autoWatch    // mutates the copy...
        return Save(cfg)           // ...saves the unchanged config

Updating the flag on an already-registered project silently did nothing. Fixed by indexing the slice, with a regression test (TestAddProject_UpdatesAutoWatchOnExisting) that fails against the old code. Side benefit: cix init -w=... now persists on re-run. a/space toggles the flag; A finally acts on it.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Docs
  • CI / infra

Checklist

  • Tested against a running API server
  • go vet ./... passes (CLI changes)
  • pytest tests/ passes (API changes) — n/a, no API changes
  • No secrets or API keys committed

Verification

  • go build ./..., go vet ./..., full go test ./... green. 30 new tests: pure mergeItems / listWindow, and Update driven with synthetic tea.KeyMsg for every action (stop/start/restart/stop-all/start-auto/toggle/delete-confirm), incl. the confirm-cancel and error paths.
  • Driven end-to-end in a real pty against live data: renders Watchers — 3 running / 35 known with real PIDs and auto tags, ? opens help, arrow keys scroll the window (↑ 1 more … ↑ 14 more), clean quit, and a clean refusal (exit 1, no panic) when stdout is not a TTY.
  • Delete confirmation verified by pressing only d (prompt rendered, then cancelled) — nothing was deleted.
  • auto_watch toggle verified end-to-end in an isolated HOME: the flag flipped false → true and persisted to disk, neighbouring project untouched.

🤖 Generated with Claude Code

dvcdsys and others added 2 commits July 10, 2026 16:10
Managing watchers was fragmented: `cix watch list` printed a static
snapshot and stopping one meant re-typing its path in a separate
command. There was no restart at all, and no single place to see every
watcher and act on individual ones.

Add `cix watch manage` (alias `ui`) — a bubbletea TUI mirroring
internal/config/tui. Rows union the local known-project list
(~/.cix/config.yaml `projects:`) with the running daemons, so listing,
stopping and toggling need no server; the server is only a preflight on
start plus an optional last-indexed enrichment. The list is windowed so
long project lists stay navigable on short terminals.

Keys: s stop · r restart · S start · a/space toggle auto_watch ·
A start all auto_watch · x stop all · d delete project (confirmed) ·
l/enter tail log · g refresh · ? help.

Daemon actions run synchronously inside Update (they are local and
microsecond); only the network last-indexed load is a tea.Cmd, and it
only returns a message, so a tick can never race an in-flight action.

Also:

- client: add DeleteProject, mapping 404 to ErrProjectGone so deleting
  an already-deleted project is an idempotent success. Delete stops the
  watcher first, so a reindex cannot re-create the project between the
  server delete and the local cleanup; a non-404 server error aborts
  before local state is touched.
- watchtui: GuardedStart (server reachable + project registered) is now
  shared by `cix watch` and the TUI — runWatchDaemon reuses it instead
  of duplicating the preflight.
- config: fix AddProject silently dropping an auto_watch update. It
  assigned to a range-loop copy and then saved the unchanged config, so
  the flag never changed for an already-registered project. This also
  makes `cix init -w=...` persist on re-run. auto_watch was previously
  inert — nothing read it — and `A` now acts on it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review follow-ups on the watcher manager:

- start / restart / start-all-auto / delete now run as background
  tea.Cmds behind a single-flight busy gate. They preflight (or delete
  on) the server, and the HTTP client timeout is 600s — running them
  inside Update froze rendering and input for the whole wait when the
  server was slow or unreachable. Local actions (stop, stop-all,
  auto_watch toggle) stay synchronous.
- StartAllAuto probes /health once per batch instead of once per
  pending project; the server-down worst case was one connect timeout
  multiplied by every auto_watch project.
- The last-indexed enrichment is single-flight: the 2s tick no longer
  stacks another hung ListProjects call behind a stalled server.
- readTail seeks to the last 64 KiB of the watcher log instead of
  reading the whole file (logs are append-only and never rotated), and
  drops the partial line at the cut.

Verified with go test -race, plus a pty run of the real binary: first
frame, help overlay, delete-confirm cancel path, clean quit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant