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/__init__.py +3 -0
- fleetwatch/adapters/__init__.py +36 -0
- fleetwatch/adapters/base.py +112 -0
- fleetwatch/adapters/claude.py +425 -0
- fleetwatch/adapters/codex.py +411 -0
- fleetwatch/adapters/gemini.py +377 -0
- fleetwatch/adapters/grok.py +491 -0
- fleetwatch/cli.py +83 -0
- fleetwatch/config.py +43 -0
- fleetwatch/core.py +241 -0
- fleetwatch/models.py +110 -0
- fleetwatch/palette.py +107 -0
- fleetwatch/remote.py +104 -0
- fleetwatch/render.py +157 -0
- fleetwatch/summarize.py +141 -0
- fleetwatch/tailer.py +63 -0
- fleetwatch/tui.py +475 -0
- fleetwatch/util.py +28 -0
- fleetwatcher-0.4.0.dist-info/METADATA +200 -0
- fleetwatcher-0.4.0.dist-info/RECORD +24 -0
- fleetwatcher-0.4.0.dist-info/WHEEL +5 -0
- fleetwatcher-0.4.0.dist-info/entry_points.txt +3 -0
- fleetwatcher-0.4.0.dist-info/licenses/LICENSE +21 -0
- fleetwatcher-0.4.0.dist-info/top_level.txt +1 -0
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
|