fleetwatcher 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fleetwatch/tui.py ADDED
@@ -0,0 +1,475 @@
1
+ """Full-screen Textual dashboard for fleetwatch.
2
+
3
+ Importing this module must never touch the terminal — only :func:`run_tui`
4
+ starts the app, so the module stays safe to import headless (CI, ``--export-json``,
5
+ tests). The app is built against a *duck-typed* aggregator with this interface::
6
+
7
+ agg.refresh() -> None
8
+ agg.sessions() -> list[SessionState] # already needs-first sorted
9
+ agg.counts() -> dict[str, int]
10
+ agg.request_summary(key) -> None # optional / may be a no-op
11
+
12
+ We never import the concrete aggregator here so this module and ``core.py`` can
13
+ be written concurrently without colliding.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from typing import Optional
20
+
21
+ from rich.text import Text
22
+
23
+ from textual.app import App, ComposeResult
24
+ from textual.binding import Binding
25
+ from textual.containers import Horizontal, Vertical
26
+ from textual.widgets import DataTable, Footer, Header, Static
27
+
28
+ from . import config
29
+ from .models import SessionState, State
30
+ from .palette import (
31
+ state_label,
32
+ state_style as _state_color,
33
+ vendor_style as _vendor_color,
34
+ )
35
+ from .render import humanize_age
36
+
37
+
38
+ _TODO_GLYPHS = {
39
+ "completed": "✔",
40
+ "in_progress": "▸",
41
+ "pending": "○",
42
+ }
43
+
44
+ _COUNT_ORDER = ("active", "waiting", "idle", "done", "error")
45
+
46
+
47
+ def _legend() -> Text:
48
+ """One-line key (glyph + color per state) for the header strip."""
49
+ legend = Text("key: ", style="dim")
50
+ first = True
51
+ for st in (State.ACTIVE, State.WAITING, State.IDLE, State.DONE, State.ERROR):
52
+ if not first:
53
+ legend.append(" ")
54
+ first = False
55
+ legend.append(state_label(st), style=_state_color(st))
56
+ return legend
57
+
58
+
59
+ def _counts_text(counts: Optional[dict]) -> Text:
60
+ if not counts:
61
+ return Text("no counts", style="dim")
62
+ text = Text()
63
+ first = True
64
+ for name in _COUNT_ORDER:
65
+ if name not in counts:
66
+ continue
67
+ if not first:
68
+ text.append(" ")
69
+ first = False
70
+ style = _state_color(State(name)) if name in State._value2member_map_ else "white"
71
+ text.append(f"{name} {counts[name]}", style=style)
72
+ total = counts.get("total")
73
+ if total is not None:
74
+ text.append(f" total {total}", style="bold")
75
+ return text
76
+
77
+
78
+ def _excerpt(text: str, width: int = 200) -> str:
79
+ text = (text or "").strip().replace("\r", "")
80
+ if len(text) <= width:
81
+ return text
82
+ return text[: width - 1].rstrip() + "…"
83
+
84
+
85
+ class StatusBar(Static):
86
+ """Header strip: counts + last-refresh clock + color legend."""
87
+
88
+ def update_status(self, counts: Optional[dict], refreshed_at: float) -> None:
89
+ clock = time.strftime("%H:%M:%S", time.localtime(refreshed_at))
90
+ line = Text()
91
+ line.append_text(_counts_text(counts))
92
+ line.append(" ")
93
+ line.append(f"refreshed {clock}", style="cyan")
94
+ line.append(" ")
95
+ line.append_text(_legend())
96
+ self.update(line)
97
+
98
+
99
+ def build_detail(s: SessionState) -> Text:
100
+ """Rich Text for the detail panel.
101
+
102
+ Kept module-level so it can be rendered and tested without a running app.
103
+ The panel's structural accents — the title, every field label, every
104
+ section header — all take the session's vendor color, so the right pane is
105
+ color-coded by provider exactly the way the list is. The state line keeps
106
+ its own state color, ``needs`` stays alert-yellow, and an error is red.
107
+ """
108
+ acc = _vendor_color(s.vendor) # this panel's provider accent
109
+ body = Text()
110
+ body.append(s.project or "(no project)", style=f"bold {acc}")
111
+ if s.cwd:
112
+ body.append(f"\n{s.cwd}", style="dim")
113
+ body.append("\n\n")
114
+
115
+ body.append("vendor ", style=acc)
116
+ body.append(f"{s.vendor}\n", style=acc)
117
+ body.append("state ", style=acc)
118
+ body.append(state_label(s.state), style=_state_color(s.state))
119
+ if s.needs_attention:
120
+ body.append(" !", style="bold bright_red")
121
+ body.append("\n")
122
+
123
+ if s.needs:
124
+ body.append("needs ", style=acc)
125
+ body.append(f"{s.needs}\n", style="bright_yellow")
126
+
127
+ # Model-written summary, falling back to the adapter's one-liner.
128
+ body.append("\nsummary\n", style=f"bold {acc}")
129
+ body.append(_excerpt(s.summary if s.summary else s.doing) or "(none)")
130
+ body.append("\n")
131
+
132
+ if s.todos:
133
+ body.append("\ntodos\n", style=f"bold {acc}")
134
+ for t in s.todos:
135
+ glyph = _TODO_GLYPHS.get(t.status, "○")
136
+ # Completed work recedes (the ✔ carries it); in-progress is the one
137
+ # to watch. No green anywhere, same as the state palette.
138
+ style = "grey58" if t.status == "completed" else (
139
+ "bright_yellow" if t.status == "in_progress" else "white"
140
+ )
141
+ body.append(f" {glyph} ", style=style)
142
+ body.append(f"{t.text}\n")
143
+
144
+ if s.last_user:
145
+ body.append("\nlast user\n", style=f"bold {acc}")
146
+ body.append(_excerpt(s.last_user, 160) + "\n")
147
+ if s.last_agent:
148
+ body.append("\nlast agent\n", style=f"bold {acc}")
149
+ body.append(_excerpt(s.last_agent, 160) + "\n")
150
+
151
+ if s.error:
152
+ body.append("\nerror\n", style="bold bright_red")
153
+ body.append(_excerpt(s.error, 200), style="bright_red")
154
+
155
+ return body
156
+
157
+
158
+ class DetailPanel(Static):
159
+ """Side panel describing the currently selected session."""
160
+
161
+ def show_placeholder(self) -> None:
162
+ self.update(Text("Select a session to see details.", style="dim"))
163
+
164
+ def show_session(self, s: SessionState) -> None:
165
+ self.update(build_detail(s))
166
+
167
+
168
+ class FleetApp(App):
169
+ """The live dashboard. Build with an aggregator, then ``run()`` it."""
170
+
171
+ TITLE = "fleetwatch"
172
+
173
+ CSS = """
174
+ Screen { layout: vertical; }
175
+ StatusBar { height: 1; padding: 0 1; background: $panel; }
176
+ #main { height: 1fr; }
177
+ #table { width: 2fr; }
178
+ DetailPanel {
179
+ width: 1fr;
180
+ padding: 0 1;
181
+ border-left: solid $primary;
182
+ }
183
+ """
184
+
185
+ BINDINGS = [
186
+ Binding("q", "quit", "Quit"),
187
+ Binding("r", "refresh_now", "Refresh"),
188
+ Binding("s", "summary", "Summarize"),
189
+ Binding("S", "summarize_all", "Summ. all"),
190
+ Binding("j", "cursor_down", "Down", show=False),
191
+ Binding("k", "cursor_up", "Up", show=False),
192
+ ]
193
+
194
+ _COLUMNS = ("vendor", "project", "state", "idle", "!", "doing")
195
+
196
+ def __init__(self, agg) -> None:
197
+ super().__init__()
198
+ self._agg = agg
199
+ # Show a host column only when remote hosts are being watched, so the
200
+ # single-machine view stays uncluttered.
201
+ self._show_host = bool(getattr(agg, "hosts", None))
202
+ # Rows in display order; index aligns with DataTable row order. Keys are
203
+ # the session.key tuple so we can re-select the same session after a
204
+ # refresh even if its position changed.
205
+ self._rows: list[SessionState] = []
206
+ self._refreshed_at: float = time.time()
207
+
208
+ # --- composition -----------------------------------------------------
209
+ def compose(self) -> ComposeResult:
210
+ yield Header(show_clock=False)
211
+ yield StatusBar(id="status")
212
+ with Horizontal(id="main"):
213
+ with Vertical(id="table"):
214
+ yield DataTable(id="sessions", cursor_type="row", zebra_stripes=True)
215
+ yield DetailPanel(id="detail")
216
+ yield Footer()
217
+
218
+ def on_mount(self) -> None:
219
+ table = self.query_one("#sessions", DataTable)
220
+ columns = (("host",) + self._COLUMNS) if self._show_host else self._COLUMNS
221
+ for col in columns:
222
+ table.add_column(col, key=col)
223
+ self.query_one("#detail", DetailPanel).show_placeholder()
224
+ self._reload(initial=True)
225
+ # Live refresh on the configured interval.
226
+ self.set_interval(config.REFRESH_INTERVAL, self._tick)
227
+
228
+ # --- data flow -------------------------------------------------------
229
+ def _tick(self) -> None:
230
+ try:
231
+ self._agg.refresh()
232
+ except Exception: # a flaky scan should never crash the screen
233
+ pass
234
+ self._reload()
235
+
236
+ def _reload(self, initial: bool = False) -> None:
237
+ """Repopulate the table from the aggregator, preserving the selection."""
238
+ table = self.query_one("#sessions", DataTable)
239
+
240
+ # Remember which session the cursor was on, by stable key.
241
+ selected_key = None
242
+ if not initial and self._rows and table.row_count:
243
+ idx = table.cursor_row
244
+ if idx is not None and 0 <= idx < len(self._rows):
245
+ selected_key = self._rows[idx].key
246
+
247
+ sessions = list(self._agg.sessions())
248
+ self._rows = sessions
249
+ self._refreshed_at = time.time()
250
+
251
+ table.clear()
252
+ now = self._refreshed_at
253
+ for s in sessions:
254
+ table.add_row(*self._row_cells(s, now), key=self._row_key(s))
255
+
256
+ self.query_one("#status", StatusBar).update_status(
257
+ self._safe_counts(), self._refreshed_at
258
+ )
259
+
260
+ # Restore the cursor onto the same session when it still exists.
261
+ new_index = 0
262
+ if selected_key is not None:
263
+ for i, s in enumerate(sessions):
264
+ if s.key == selected_key:
265
+ new_index = i
266
+ break
267
+ if sessions:
268
+ table.move_cursor(row=new_index)
269
+ self._show_detail(new_index)
270
+ else:
271
+ self.query_one("#detail", DetailPanel).show_placeholder()
272
+
273
+ def _safe_counts(self) -> Optional[dict]:
274
+ try:
275
+ return self._agg.counts()
276
+ except Exception:
277
+ return None
278
+
279
+ def _row_key(self, s: SessionState) -> str:
280
+ return "|".join(s.key)
281
+
282
+ def _row_cells(self, s: SessionState, now: float):
283
+ state_cell = Text(state_label(s.state), style=_state_color(s.state))
284
+ vendor_cell = Text(s.vendor, style=_vendor_color(s.vendor))
285
+ bang = Text("!", style="bold bright_red") if s.needs_attention else Text("")
286
+ age = Text(humanize_age(s.last_activity, now), style="grey58")
287
+ doing = s.needs if (s.needs_attention and s.needs) else s.doing
288
+ # The reason a session wants you reads in alert yellow; routine activity
289
+ # stays plain, so the eye lands on the rows that need action.
290
+ doing_cell = (
291
+ Text(_excerpt(doing, 80), style="bright_yellow")
292
+ if (s.needs_attention and s.needs)
293
+ else _excerpt(doing, 80)
294
+ )
295
+ cells = [vendor_cell, s.project, state_cell, age, bang, doing_cell]
296
+ if self._show_host:
297
+ cells.insert(0, Text(s.source, style="grey58"))
298
+ return tuple(cells)
299
+
300
+ def _show_detail(self, index: int) -> None:
301
+ panel = self.query_one("#detail", DetailPanel)
302
+ if 0 <= index < len(self._rows):
303
+ s = self._rows[index]
304
+ panel.show_session(s)
305
+ self._ensure_summary(s)
306
+ else:
307
+ panel.show_placeholder()
308
+
309
+ def _ensure_summary(self, s: SessionState) -> None:
310
+ """Generate a real plain-language summary for the session being viewed,
311
+ so the detail panel shows a sentence rather than the raw ``doing`` line.
312
+
313
+ Skipped for ACTIVE sessions (their live ``doing`` is the freshest signal)
314
+ and a no-op once a summary is cached, so browsing costs at most one model
315
+ call per distinct idle/done/waiting session. The summary lands in the
316
+ background and appears on the next refresh tick.
317
+ """
318
+ if s.summary or s.state == State.ACTIVE:
319
+ return
320
+ request = getattr(self._agg, "request_summary", None)
321
+ if not callable(request):
322
+ return
323
+ try:
324
+ request(s.key, force=False)
325
+ except TypeError:
326
+ # Aggregator without the force kwarg (older/fake): fall back.
327
+ try:
328
+ request(s.key)
329
+ except Exception:
330
+ pass
331
+ except Exception:
332
+ pass
333
+
334
+ def _selected(self) -> Optional[SessionState]:
335
+ table = self.query_one("#sessions", DataTable)
336
+ idx = table.cursor_row
337
+ if idx is not None and 0 <= idx < len(self._rows):
338
+ return self._rows[idx]
339
+ return None
340
+
341
+ # --- events ----------------------------------------------------------
342
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
343
+ table = self.query_one("#sessions", DataTable)
344
+ idx = table.cursor_row
345
+ if idx is not None:
346
+ self._show_detail(idx)
347
+
348
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
349
+ table = self.query_one("#sessions", DataTable)
350
+ idx = table.cursor_row
351
+ if idx is not None:
352
+ self._show_detail(idx)
353
+
354
+ # --- actions ---------------------------------------------------------
355
+ def action_refresh_now(self) -> None:
356
+ self._tick()
357
+
358
+ def action_summary(self) -> None:
359
+ s = self._selected()
360
+ if s is None:
361
+ return
362
+ request = getattr(self._agg, "request_summary", None)
363
+ if callable(request):
364
+ try:
365
+ request(s.key)
366
+ except Exception:
367
+ pass
368
+
369
+ def action_summarize_all(self) -> None:
370
+ fn = getattr(self._agg, "summarize_all", None)
371
+ if not callable(fn):
372
+ return
373
+ try:
374
+ n = fn()
375
+ except Exception:
376
+ n = 0
377
+ try:
378
+ if n:
379
+ self.notify(f"Summarizing {n} session{'s' if n != 1 else ''}…")
380
+ else:
381
+ self.notify("Summaries are off (no API key).", severity="warning")
382
+ except Exception:
383
+ pass
384
+
385
+ def action_cursor_down(self) -> None:
386
+ table = self.query_one("#sessions", DataTable)
387
+ table.action_cursor_down()
388
+
389
+ def action_cursor_up(self) -> None:
390
+ table = self.query_one("#sessions", DataTable)
391
+ table.action_cursor_up()
392
+
393
+
394
+ def run_tui(agg) -> None:
395
+ """Build and run the full-screen dashboard against ``agg``.
396
+
397
+ This is the only entry point that touches the terminal.
398
+ """
399
+ FleetApp(agg).run()
400
+
401
+
402
+ # --- demo / manual smoke test ------------------------------------------------
403
+ # Guarded so importing this module never builds fixtures or starts the app.
404
+ if __name__ == "__main__":
405
+ from .models import TodoItem
406
+
407
+ class _FakeAggregator:
408
+ """A fixed fleet covering every State, for ``python -m fleetwatch.tui``."""
409
+
410
+ def __init__(self) -> None:
411
+ now = time.time()
412
+ self._sessions = [
413
+ SessionState(
414
+ vendor="claude", session_id="w1", project="fleetwatch",
415
+ cwd="/Users/luke/workspace/fleetwatch", state=State.WAITING,
416
+ last_activity=now - 8,
417
+ doing="ran the test suite",
418
+ needs="approve running pytest?",
419
+ summary="Claude is blocked on a permission prompt: it wants to run the "
420
+ "fleetwatch test suite and is waiting for you to approve it.",
421
+ last_user="please add the TUI tests",
422
+ last_agent="May I run pytest tests/test_tui.py?",
423
+ ),
424
+ SessionState(
425
+ vendor="codex", session_id="e1", project="orrery",
426
+ cwd="/Users/luke/workspace/orrery", state=State.ERROR,
427
+ last_activity=now - 40,
428
+ doing="build failed",
429
+ needs="TypeScript build error in orbit.ts",
430
+ error="error TS2345: Argument of type 'string' is not assignable.",
431
+ last_user="fix the build",
432
+ last_agent="The build is failing on orbit.ts line 42.",
433
+ ),
434
+ SessionState(
435
+ vendor="claude", session_id="a1", project="whatcolor",
436
+ cwd="/Users/luke/workspace/whatcolor", state=State.ACTIVE,
437
+ last_activity=now - 2,
438
+ doing="editing ContentView.swift",
439
+ todos=[
440
+ TodoItem("scaffold the picker", "completed"),
441
+ TodoItem("wire up the palette", "in_progress"),
442
+ TodoItem("add accessibility labels", "pending"),
443
+ ],
444
+ last_user="add a color picker",
445
+ last_agent="Adding the picker now.",
446
+ ),
447
+ SessionState(
448
+ vendor="grok", session_id="i1", project="cube",
449
+ cwd="/Users/luke/workspace/cube", state=State.IDLE,
450
+ last_activity=now - 600, doing="waiting for next instruction",
451
+ ),
452
+ SessionState(
453
+ vendor="codex", session_id="d1", project="mandaza",
454
+ cwd="/Users/luke/workspace/mandaza", state=State.DONE,
455
+ last_activity=now - 7200, doing="shipped the release",
456
+ ),
457
+ ]
458
+
459
+ def refresh(self) -> None:
460
+ pass
461
+
462
+ def sessions(self):
463
+ return list(self._sessions)
464
+
465
+ def counts(self):
466
+ c = {k: 0 for k in ("active", "waiting", "idle", "done", "error")}
467
+ for s in self._sessions:
468
+ c[str(s.state)] += 1
469
+ c["total"] = len(self._sessions)
470
+ return c
471
+
472
+ def request_summary(self, key):
473
+ pass
474
+
475
+ run_tui(_FakeAggregator())
fleetwatch/util.py ADDED
@@ -0,0 +1,28 @@
1
+ """Small shared helpers for turning raw tool input into human one-liners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _CD_PREFIX = re.compile(r"^cd\s+\S+\s*&&\s*(.+)$", re.DOTALL)
8
+
9
+
10
+ def clean_command(cmd: str, width: int = 48) -> str:
11
+ """Condense a shell command into a short, readable label.
12
+
13
+ Strips heredoc bodies, collapses whitespace and newlines, peels a leading
14
+ ``cd <dir> &&`` so the real command shows, and truncates with an ellipsis.
15
+ """
16
+ if not cmd:
17
+ return ""
18
+ s = cmd.replace("\r", " ")
19
+ # Drop heredoc markers and the body after them (pure noise in a status line).
20
+ if "<<" in s:
21
+ s = s.split("<<", 1)[0]
22
+ s = re.sub(r"\s+", " ", s).strip()
23
+ m = _CD_PREFIX.match(s)
24
+ if m:
25
+ s = m.group(1).strip()
26
+ if len(s) > width:
27
+ s = s[: width - 1].rstrip() + "…"
28
+ return s