agent-ps 0.6.2__tar.gz → 0.6.4__tar.gz
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.
- {agent_ps-0.6.2 → agent_ps-0.6.4}/PKG-INFO +8 -3
- {agent_ps-0.6.2 → agent_ps-0.6.4}/README.md +7 -2
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/__init__.py +1 -1
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/__main__.py +3 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/ui.py +264 -88
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/PKG-INFO +8 -3
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/SOURCES.txt +1 -0
- agent_ps-0.6.4/tests/test_terminal.py +127 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_ui.py +223 -5
- {agent_ps-0.6.2 → agent_ps-0.6.4}/LICENSE +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/__init__.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/antigravity.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/base.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/claude.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/codex.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/copilot.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/hermes.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/opencode.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/pilike.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/sqlite.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/collect.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/jsonl.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/procs.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/resume.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/util.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/dependency_links.txt +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/entry_points.txt +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/top_level.txt +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/pyproject.toml +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/setup.cfg +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_backends.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_procs.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_prune.py +0 -0
- {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_release_notes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-ps
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
4
4
|
Summary: A process table for coding agent sessions. Claude Code, Codex, OpenCode, Hermes, Pi, CommandCode and Copilot in one view, with models, idle time, disk use and one key to stop them.
|
|
5
5
|
Author: mkhuda
|
|
6
6
|
License: MIT License
|
|
@@ -207,6 +207,7 @@ something.
|
|
|
207
207
|
| esc | leave filter mode, or close the detail panel |
|
|
208
208
|
| space | pause refreshing |
|
|
209
209
|
| `r` | refresh now |
|
|
210
|
+
| `?` | show or hide this list |
|
|
210
211
|
| `q` | quit |
|
|
211
212
|
|
|
212
213
|
Those keys live at the bottom of the screen, under a line naming every agent on
|
|
@@ -233,9 +234,13 @@ PID AGENT SESSION STATUS MODEL UPTIME ACT
|
|
|
233
234
|
- claude notes ended opus-5 - 2h22m ago - - 84M
|
|
234
235
|
|
|
235
236
|
agents claude codex commandcode copilot hermes opencode pi sorted by disk, high to low
|
|
236
|
-
up/down
|
|
237
|
+
up/down move enter details k stop / filter s sort e hide ended ? keys b background p prune S reverse q quit
|
|
237
238
|
```
|
|
238
239
|
|
|
240
|
+
Keys drop from the right as the terminal narrows, `q quit` last of all. Press
|
|
241
|
+
`?` for the ones that never fit: `home`/`end`, `space` to pause, `r` to refresh
|
|
242
|
+
now, `y`/`n` to answer a confirmation.
|
|
243
|
+
|
|
239
244
|
`v` means descending and `^` ascending; `S` flips it. Numbers start at the
|
|
240
245
|
largest, names start at A, and rows with nothing in that column go to the end
|
|
241
246
|
either way.
|
|
@@ -422,7 +427,7 @@ install fetches. The build is reproducible: the same source always produces the
|
|
|
422
427
|
same bytes, so the committed executable can be checked against the tree.
|
|
423
428
|
|
|
424
429
|
```
|
|
425
|
-
|
|
430
|
+
03cc2271187211d38e27773db9067a2a71c5a7055d6776c5d85df178f58f4d0c agent-ps
|
|
426
431
|
```
|
|
427
432
|
|
|
428
433
|
Adding an agent takes one class and one line in the registry. See
|
|
@@ -161,6 +161,7 @@ something.
|
|
|
161
161
|
| esc | leave filter mode, or close the detail panel |
|
|
162
162
|
| space | pause refreshing |
|
|
163
163
|
| `r` | refresh now |
|
|
164
|
+
| `?` | show or hide this list |
|
|
164
165
|
| `q` | quit |
|
|
165
166
|
|
|
166
167
|
Those keys live at the bottom of the screen, under a line naming every agent on
|
|
@@ -187,9 +188,13 @@ PID AGENT SESSION STATUS MODEL UPTIME ACT
|
|
|
187
188
|
- claude notes ended opus-5 - 2h22m ago - - 84M
|
|
188
189
|
|
|
189
190
|
agents claude codex commandcode copilot hermes opencode pi sorted by disk, high to low
|
|
190
|
-
up/down
|
|
191
|
+
up/down move enter details k stop / filter s sort e hide ended ? keys b background p prune S reverse q quit
|
|
191
192
|
```
|
|
192
193
|
|
|
194
|
+
Keys drop from the right as the terminal narrows, `q quit` last of all. Press
|
|
195
|
+
`?` for the ones that never fit: `home`/`end`, `space` to pause, `r` to refresh
|
|
196
|
+
now, `y`/`n` to answer a confirmation.
|
|
197
|
+
|
|
193
198
|
`v` means descending and `^` ascending; `S` flips it. Numbers start at the
|
|
194
199
|
largest, names start at A, and rows with nothing in that column go to the end
|
|
195
200
|
either way.
|
|
@@ -376,7 +381,7 @@ install fetches. The build is reproducible: the same source always produces the
|
|
|
376
381
|
same bytes, so the committed executable can be checked against the tree.
|
|
377
382
|
|
|
378
383
|
```
|
|
379
|
-
|
|
384
|
+
03cc2271187211d38e27773db9067a2a71c5a7055d6776c5d85df178f58f4d0c agent-ps
|
|
380
385
|
```
|
|
381
386
|
|
|
382
387
|
Adding an agent takes one class and one line in the registry. See
|
|
@@ -299,6 +299,9 @@ def main():
|
|
|
299
299
|
except UnknownAgent as error:
|
|
300
300
|
print(error, file=sys.stderr)
|
|
301
301
|
return 2
|
|
302
|
+
# ncurses waits a second after ESC to see if a sequence follows, and esc
|
|
303
|
+
# cancels the filter, the panel and the confirmation
|
|
304
|
+
os.environ.setdefault("ESCDELAY", "25")
|
|
302
305
|
try:
|
|
303
306
|
curses.wrapper(lambda screen: Tui(screen, snapshot).loop())
|
|
304
307
|
except KeyboardInterrupt:
|
|
@@ -32,12 +32,15 @@ AGENT_COLOURS = (
|
|
|
32
32
|
curses.COLOR_WHITE,
|
|
33
33
|
)
|
|
34
34
|
|
|
35
|
-
#: A
|
|
36
|
-
#:
|
|
37
|
-
#:
|
|
38
|
-
#: these is the blue Antigravity is branded in, since it lands on that slot.
|
|
35
|
+
#: A terminal offering 256 colours has room for more without disturbing the
|
|
36
|
+
#: first seven, which people have already learned. The first of these is the
|
|
37
|
+
#: blue Antigravity is branded in, since it lands on that slot.
|
|
39
38
|
EXTRA_COLOURS = (69, 208, 141, 84, 173, 205)
|
|
40
39
|
|
|
40
|
+
#: Attributes that set an agent apart from an earlier one wearing the same
|
|
41
|
+
#: colour, tried in order as the colours run out a second time.
|
|
42
|
+
EXTRA_WEIGHT = (curses.A_UNDERLINE, curses.A_REVERSE)
|
|
43
|
+
|
|
41
44
|
|
|
42
45
|
def agent_label(row):
|
|
43
46
|
"""Agent name, with the sort of process appended when it is not a session."""
|
|
@@ -110,12 +113,22 @@ COLUMNS = [
|
|
|
110
113
|
]
|
|
111
114
|
|
|
112
115
|
|
|
113
|
-
def
|
|
116
|
+
def _agent_style(index):
|
|
117
|
+
"""A colour, and an attribute that sets it apart from an earlier repeat.
|
|
118
|
+
|
|
119
|
+
A basic terminal has seven usable colours, and an eighth agent wearing the
|
|
120
|
+
first one's is not a hypothetical past this point in the registry. On a
|
|
121
|
+
256-colour terminal it gets a colour of its own instead; on anything
|
|
122
|
+
smaller, the same colour with a distinguishing attribute.
|
|
123
|
+
"""
|
|
114
124
|
if index < len(AGENT_COLOURS):
|
|
115
|
-
return AGENT_COLOURS[index]
|
|
125
|
+
return AGENT_COLOURS[index], curses.A_BOLD
|
|
116
126
|
if curses.COLORS >= 256:
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
colour = EXTRA_COLOURS[(index - len(AGENT_COLOURS)) % len(EXTRA_COLOURS)]
|
|
128
|
+
return colour, curses.A_BOLD
|
|
129
|
+
generation = index // len(AGENT_COLOURS)
|
|
130
|
+
weight = EXTRA_WEIGHT[(generation - 1) % len(EXTRA_WEIGHT)]
|
|
131
|
+
return AGENT_COLOURS[index % len(AGENT_COLOURS)], curses.A_BOLD | weight
|
|
119
132
|
|
|
120
133
|
|
|
121
134
|
def column_span(label):
|
|
@@ -155,6 +168,26 @@ SORTS = (
|
|
|
155
168
|
("title", "TITLE", _text("title"), False),
|
|
156
169
|
)
|
|
157
170
|
|
|
171
|
+
#: Everything the key bar has no room for, and every key not in it at all.
|
|
172
|
+
#: Doubles as the documentation for keys that never appear on screen otherwise.
|
|
173
|
+
HELP = (
|
|
174
|
+
("up, down, j, K", "move the selection"),
|
|
175
|
+
("home, end", "jump to the first or last row"),
|
|
176
|
+
("enter", "details for a live session, or reopen an ended one"),
|
|
177
|
+
("s", "cycle the sort column"),
|
|
178
|
+
("S", "reverse the sort direction"),
|
|
179
|
+
("k", "stop the selected process and its children"),
|
|
180
|
+
("b", "stop every background helper"),
|
|
181
|
+
("p", "remove what an ended session left behind"),
|
|
182
|
+
("e", "show or hide ended sessions"),
|
|
183
|
+
("/", "filter by session, title, agent, model, directory, or pid"),
|
|
184
|
+
("esc", "leave filter mode, or close a panel"),
|
|
185
|
+
("space", "pause refreshing"),
|
|
186
|
+
("r", "refresh now"),
|
|
187
|
+
("y, n", "answer a confirmation"),
|
|
188
|
+
("q", "quit"),
|
|
189
|
+
)
|
|
190
|
+
|
|
158
191
|
|
|
159
192
|
def format_row(row, width):
|
|
160
193
|
cells = []
|
|
@@ -209,8 +242,7 @@ class Tui:
|
|
|
209
242
|
C_BACKGROUND = 3
|
|
210
243
|
C_SELECTED = 4
|
|
211
244
|
C_WARNING = 5
|
|
212
|
-
|
|
213
|
-
C_AGENT_BASE = 7
|
|
245
|
+
C_AGENT_BASE = 6
|
|
214
246
|
|
|
215
247
|
def __init__(self, screen, snapshot):
|
|
216
248
|
self.screen = screen
|
|
@@ -223,18 +255,48 @@ class Tui:
|
|
|
223
255
|
self.show_ended = False
|
|
224
256
|
self.filter_text = ""
|
|
225
257
|
self.editing_filter = False
|
|
226
|
-
# (
|
|
258
|
+
# (verb, detail, action): the verb and [y]/[n] must survive truncation,
|
|
259
|
+
# so the detail that may not fit comes last
|
|
227
260
|
self.pending = None
|
|
261
|
+
self.show_help = False
|
|
228
262
|
self.detail = None
|
|
229
263
|
self.sort = 0
|
|
230
264
|
self.descending = True
|
|
231
265
|
self.last_poll = 0.0
|
|
232
|
-
self.
|
|
266
|
+
self.all_rows = []
|
|
267
|
+
self.history = {"bytes": 0, "ended": 0, "recent": 0, "spare": 0,
|
|
268
|
+
"spare_sessions": 0}
|
|
269
|
+
|
|
270
|
+
def _put(self, y, x, text, n, attr=0):
|
|
271
|
+
"""addnstr that tolerates a screen too small for what it was told.
|
|
272
|
+
|
|
273
|
+
The bottom right cell always raises, even on correct code, and a window
|
|
274
|
+
that shrank between the layout and the draw raises everywhere.
|
|
275
|
+
"""
|
|
276
|
+
if n <= 0 or y < 0 or x < 0:
|
|
277
|
+
return
|
|
278
|
+
try:
|
|
279
|
+
self.screen.addnstr(y, x, text, n, attr)
|
|
280
|
+
except curses.error:
|
|
281
|
+
pass
|
|
233
282
|
|
|
234
283
|
def setup(self):
|
|
235
|
-
|
|
284
|
+
# a terminal whose terminfo has no `civis` raises rather than ignoring
|
|
285
|
+
try:
|
|
286
|
+
curses.curs_set(0)
|
|
287
|
+
except curses.error:
|
|
288
|
+
pass
|
|
236
289
|
self.agent_colour = {}
|
|
237
290
|
if not curses.has_colors():
|
|
291
|
+
# attributes work on every terminal, so they are the mechanism and
|
|
292
|
+
# colour, below, is only ever an enhancement on top of them
|
|
293
|
+
self.style = {
|
|
294
|
+
"header": curses.A_REVERSE,
|
|
295
|
+
"busy": curses.A_BOLD,
|
|
296
|
+
"background": curses.A_DIM,
|
|
297
|
+
"selected": curses.A_REVERSE | curses.A_BOLD,
|
|
298
|
+
"warning": curses.A_BOLD | curses.A_UNDERLINE,
|
|
299
|
+
}
|
|
238
300
|
self.screen.timeout(200)
|
|
239
301
|
return
|
|
240
302
|
curses.start_color()
|
|
@@ -244,20 +306,39 @@ class Tui:
|
|
|
244
306
|
curses.init_pair(self.C_BACKGROUND, curses.COLOR_BLUE, -1)
|
|
245
307
|
curses.init_pair(self.C_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
246
308
|
curses.init_pair(self.C_WARNING, curses.COLOR_YELLOW, -1)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
309
|
+
self.style = {
|
|
310
|
+
"header": curses.color_pair(self.C_HEADER),
|
|
311
|
+
"busy": curses.color_pair(self.C_BUSY),
|
|
312
|
+
"background": curses.color_pair(self.C_BACKGROUND),
|
|
313
|
+
"selected": curses.color_pair(self.C_SELECTED),
|
|
314
|
+
"warning": curses.color_pair(self.C_WARNING),
|
|
315
|
+
}
|
|
251
316
|
for index, name in enumerate(backends.names()):
|
|
317
|
+
colour, attr = _agent_style(index)
|
|
252
318
|
pair = self.C_AGENT_BASE + index
|
|
253
|
-
curses.init_pair(pair,
|
|
254
|
-
self.agent_colour[name] = curses.color_pair(pair)
|
|
319
|
+
curses.init_pair(pair, colour, -1)
|
|
320
|
+
self.agent_colour[name] = curses.color_pair(pair) | attr
|
|
255
321
|
self.screen.timeout(200)
|
|
256
322
|
|
|
257
323
|
def poll(self):
|
|
324
|
+
"""Read the machine. Everything here touches disk or shells out."""
|
|
258
325
|
# the same window already loaded to pair processes with sessions, so
|
|
259
326
|
# showing all of it costs nothing and makes sorting by size honest
|
|
260
|
-
|
|
327
|
+
self.all_rows = self.snapshot.rows(show_ended=self.show_ended,
|
|
328
|
+
limit=ATTACH_WINDOW)
|
|
329
|
+
self.last_poll = time.time()
|
|
330
|
+
self.history = self.snapshot.history()
|
|
331
|
+
self.reshape()
|
|
332
|
+
|
|
333
|
+
def reshape(self):
|
|
334
|
+
"""Filter and sort what the last poll returned, reading nothing.
|
|
335
|
+
|
|
336
|
+
Separate from `poll` because sorting and filtering are questions about
|
|
337
|
+
rows already in hand. Asking the machine again for every keystroke cost
|
|
338
|
+
a fifth of a second each time.
|
|
339
|
+
"""
|
|
340
|
+
held = self.selected()
|
|
341
|
+
rows = self.all_rows
|
|
261
342
|
if self.filter_text:
|
|
262
343
|
needle = self.filter_text.lower()
|
|
263
344
|
rows = [r for r in rows if matches_filter(r, needle)]
|
|
@@ -265,9 +346,11 @@ class Tui:
|
|
|
265
346
|
if key:
|
|
266
347
|
rows = sorted(rows, key=key, reverse=self.descending)
|
|
267
348
|
self.rows = rows
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
349
|
+
# follow the row rather than the position. Under a live sort the order
|
|
350
|
+
# changes beneath the cursor, and k acts on whatever it is now over.
|
|
351
|
+
current = self.same_row(held) if held else None
|
|
352
|
+
self.cursor = (self.rows.index(current) if current
|
|
353
|
+
else max(0, min(self.cursor, len(self.rows) - 1)))
|
|
271
354
|
# every pass builds new row objects, so a panel holding one from the
|
|
272
355
|
# last pass would sit there showing the cpu and uptime it had when it
|
|
273
356
|
# was opened
|
|
@@ -304,19 +387,31 @@ class Tui:
|
|
|
304
387
|
self.message = text
|
|
305
388
|
self.message_at = time.time()
|
|
306
389
|
|
|
390
|
+
#: Below this there is no layout worth attempting, only a message saying so.
|
|
391
|
+
MIN_HEIGHT = 6
|
|
392
|
+
MIN_WIDTH = 40
|
|
393
|
+
|
|
307
394
|
def draw(self):
|
|
308
395
|
self.screen.erase()
|
|
309
396
|
height, width = self.screen.getmaxyx()
|
|
397
|
+
if height < self.MIN_HEIGHT or width < self.MIN_WIDTH:
|
|
398
|
+
self.draw_too_small(height, width)
|
|
399
|
+
return
|
|
310
400
|
self.draw_summary(width)
|
|
401
|
+
if self.show_help:
|
|
402
|
+
self.draw_help(height, width)
|
|
403
|
+
self.screen.noutrefresh()
|
|
404
|
+
curses.doupdate()
|
|
405
|
+
return
|
|
311
406
|
if self.detail and not self.pending:
|
|
312
407
|
self.draw_detail(height, width)
|
|
313
408
|
self.screen.noutrefresh()
|
|
314
409
|
curses.doupdate()
|
|
315
410
|
return
|
|
316
411
|
|
|
317
|
-
self.screen.attron(
|
|
318
|
-
self.
|
|
319
|
-
self.screen.attroff(
|
|
412
|
+
self.screen.attron(self.style["header"])
|
|
413
|
+
self._put(2, 0, format_header(width).ljust(width - 1), width - 1)
|
|
414
|
+
self.screen.attroff(self.style["header"])
|
|
320
415
|
self.mark_sorted_column(width)
|
|
321
416
|
|
|
322
417
|
body = max(1, height - 5)
|
|
@@ -324,25 +419,25 @@ class Tui:
|
|
|
324
419
|
for index, row in enumerate(self.rows[first: first + body]):
|
|
325
420
|
line = format_row(row, width - 1)
|
|
326
421
|
if first + index == self.cursor:
|
|
327
|
-
attr =
|
|
422
|
+
attr = self.style["selected"]
|
|
328
423
|
line = line.ljust(width - 1)
|
|
329
424
|
elif row["kind"] == KIND_ENDED:
|
|
330
425
|
attr = curses.A_DIM
|
|
331
426
|
elif row["background"]:
|
|
332
|
-
attr =
|
|
427
|
+
attr = self.style["background"]
|
|
333
428
|
elif row["status"] == STATUS_BUSY:
|
|
334
|
-
attr =
|
|
429
|
+
attr = self.style["busy"]
|
|
335
430
|
else:
|
|
336
431
|
attr = curses.A_NORMAL
|
|
337
432
|
screen_row = 3 + index
|
|
338
|
-
self.
|
|
433
|
+
self._put(screen_row, 0, line, width - 1, attr)
|
|
339
434
|
# the cursor has to stay unmistakable, so a selected row keeps its
|
|
340
435
|
# own colour rather than being broken up by the agent's
|
|
341
436
|
if first + index != self.cursor:
|
|
342
437
|
self.paint_agent(screen_row, row, width, attr)
|
|
343
438
|
|
|
344
439
|
if not self.rows:
|
|
345
|
-
self.
|
|
440
|
+
self._put(4, 2, self.empty_message(), width - 3)
|
|
346
441
|
|
|
347
442
|
self.draw_footer(height, width)
|
|
348
443
|
self.screen.noutrefresh()
|
|
@@ -362,15 +457,17 @@ class Tui:
|
|
|
362
457
|
if start >= width - 1:
|
|
363
458
|
return
|
|
364
459
|
text = agent_label(row)[: size - 1].ljust(size)
|
|
365
|
-
self.
|
|
366
|
-
colour | (base & curses.A_DIM)
|
|
460
|
+
self._put(screen_row, start, text, min(size, width - 1 - start),
|
|
461
|
+
colour | (base & curses.A_DIM))
|
|
367
462
|
|
|
368
463
|
def mark_sorted_column(self, width):
|
|
369
464
|
"""Show which column is sorting, on the column itself.
|
|
370
465
|
|
|
371
466
|
A word in the summary line says the same thing, but the eye reading a
|
|
372
467
|
table looks at the table, so the marker belongs in the heading it
|
|
373
|
-
describes.
|
|
468
|
+
describes. Bold and underlined on top of the header's own colour,
|
|
469
|
+
rather than a colour of its own: a pair distinct from the header would
|
|
470
|
+
have needed a background too, and the closest free one is the cursor's.
|
|
374
471
|
"""
|
|
375
472
|
label = SORTS[self.sort][1]
|
|
376
473
|
if not label:
|
|
@@ -382,8 +479,8 @@ class Tui:
|
|
|
382
479
|
# the last column has no fixed width, so it is marked to its own length
|
|
383
480
|
cell = size or len(label) + 1
|
|
384
481
|
text = f"{label}{arrow}"[:cell].ljust(cell)
|
|
385
|
-
self.
|
|
386
|
-
|
|
482
|
+
self._put(2, start, text, min(cell, width - 1 - start),
|
|
483
|
+
self.style["header"] | curses.A_BOLD | curses.A_UNDERLINE)
|
|
387
484
|
|
|
388
485
|
def detail_groups(self, row, backend):
|
|
389
486
|
"""Everything known about one session, in the order it gets asked.
|
|
@@ -395,7 +492,7 @@ class Tui:
|
|
|
395
492
|
("agent", agent_label(row), self.agent_colour.get(row["agent"], 0)),
|
|
396
493
|
("session", row["session_id"] or "not matched to a session", 0),
|
|
397
494
|
("status", status_label(row),
|
|
398
|
-
|
|
495
|
+
self.style["busy"] if row["status"] == STATUS_BUSY else 0),
|
|
399
496
|
("model", row["model"] or "-", 0),
|
|
400
497
|
("title", row["title"] or "-", 0),
|
|
401
498
|
("directory", row["cwd"] or "-", 0),
|
|
@@ -408,7 +505,7 @@ class Tui:
|
|
|
408
505
|
process.append(("last turn", idle_label(row), 0))
|
|
409
506
|
if row["attach"] == ATTACH_INFERRED:
|
|
410
507
|
process.append(("paired", "matched by working directory, not reported",
|
|
411
|
-
|
|
508
|
+
self.style["warning"]))
|
|
412
509
|
|
|
413
510
|
usage = []
|
|
414
511
|
if row["pid"]:
|
|
@@ -438,22 +535,22 @@ class Tui:
|
|
|
438
535
|
row = self.detail
|
|
439
536
|
backend = self.snapshot.find_backend(row["agent"])
|
|
440
537
|
|
|
441
|
-
self.screen.attron(
|
|
442
|
-
self.
|
|
443
|
-
self.screen.attroff(
|
|
538
|
+
self.screen.attron(self.style["header"])
|
|
539
|
+
self._put(2, 0, " session details".ljust(width - 1), width - 1)
|
|
540
|
+
self.screen.attroff(self.style["header"])
|
|
444
541
|
|
|
445
542
|
screen_row = 4
|
|
446
543
|
for heading, entries in self.detail_groups(row, backend):
|
|
447
544
|
if not entries or screen_row >= height - 3:
|
|
448
545
|
continue
|
|
449
|
-
self.
|
|
546
|
+
self._put(screen_row, 2, heading.upper(), 12,
|
|
450
547
|
curses.A_BOLD | curses.A_DIM)
|
|
451
548
|
screen_row += 1
|
|
452
549
|
for label, value, attr in entries:
|
|
453
550
|
if screen_row >= height - 2:
|
|
454
551
|
break
|
|
455
552
|
if label:
|
|
456
|
-
self.
|
|
553
|
+
self._put(screen_row, 4, f"{label:>11}", 11,
|
|
457
554
|
curses.A_DIM)
|
|
458
555
|
# the command line is the one field that runs long, so it wraps
|
|
459
556
|
# instead of being cut off where it stops being useful
|
|
@@ -465,15 +562,41 @@ class Tui:
|
|
|
465
562
|
for chunk in chunks:
|
|
466
563
|
if screen_row >= height - 2:
|
|
467
564
|
break
|
|
468
|
-
self.
|
|
565
|
+
self._put(screen_row, 17, chunk, width - 18, attr)
|
|
469
566
|
screen_row += 1
|
|
470
567
|
screen_row += 1
|
|
471
568
|
|
|
472
569
|
keys = " enter or esc close"
|
|
473
570
|
if row["pid"]:
|
|
474
571
|
keys += " k stop this process"
|
|
475
|
-
self.
|
|
476
|
-
|
|
572
|
+
self._put(height - 1, 0, keys.ljust(width - 1), width - 1,
|
|
573
|
+
self.style["header"])
|
|
574
|
+
|
|
575
|
+
def draw_too_small(self, height, width):
|
|
576
|
+
"""Say why the table is gone, rather than drawing a broken one."""
|
|
577
|
+
for offset, text in enumerate((
|
|
578
|
+
"terminal too small",
|
|
579
|
+
f"{width}x{height}, need {self.MIN_WIDTH}x{self.MIN_HEIGHT}")):
|
|
580
|
+
if offset < height:
|
|
581
|
+
self._put(offset, 0, text, max(0, width - 1))
|
|
582
|
+
self.screen.noutrefresh()
|
|
583
|
+
curses.doupdate()
|
|
584
|
+
|
|
585
|
+
def draw_help(self, height, width):
|
|
586
|
+
"""Every key, for the ones the footer had no room to name."""
|
|
587
|
+
self.screen.attron(self.style["header"])
|
|
588
|
+
self._put(2, 0, " keys".ljust(width - 1), width - 1)
|
|
589
|
+
self.screen.attroff(self.style["header"])
|
|
590
|
+
screen_row = 4
|
|
591
|
+
for key, meaning in HELP:
|
|
592
|
+
if screen_row >= height - 2:
|
|
593
|
+
break
|
|
594
|
+
self._put(screen_row, 4, f"{key:<16}", min(16, max(0, width - 5)),
|
|
595
|
+
curses.A_BOLD)
|
|
596
|
+
self._put(screen_row, 21, meaning, max(0, width - 22))
|
|
597
|
+
screen_row += 1
|
|
598
|
+
self._put(height - 1, 0, " press any key to close".ljust(width - 1),
|
|
599
|
+
width - 1, self.style["header"])
|
|
477
600
|
|
|
478
601
|
def draw_summary(self, width):
|
|
479
602
|
"""Two lines: what is running, then what it is costing."""
|
|
@@ -482,7 +605,10 @@ class Tui:
|
|
|
482
605
|
background = sum(1 for r in live if r["background"])
|
|
483
606
|
sessions = len(live) - background
|
|
484
607
|
|
|
485
|
-
|
|
608
|
+
if self.filter_text and len(self.rows) != len(self.all_rows):
|
|
609
|
+
parts = [f"{len(self.rows)} of {len(self.all_rows)} rows"]
|
|
610
|
+
else:
|
|
611
|
+
parts = [f"{sessions} session" + ("s" if sessions != 1 else "")]
|
|
486
612
|
if busy:
|
|
487
613
|
parts.append(f"{busy} busy")
|
|
488
614
|
if background:
|
|
@@ -495,23 +621,40 @@ class Tui:
|
|
|
495
621
|
state = f"/{self.filter_text} " + state
|
|
496
622
|
|
|
497
623
|
self.screen.attron(curses.A_BOLD)
|
|
498
|
-
self.
|
|
624
|
+
self._put(0, 1, "agent-ps", width - 2)
|
|
499
625
|
self.screen.attroff(curses.A_BOLD)
|
|
500
|
-
self.
|
|
626
|
+
self._put(0, 10, f" {' '.join(parts)} {state}", width - 11)
|
|
501
627
|
|
|
502
|
-
|
|
628
|
+
# from live rows, not from self.rows: those are filtered, and a
|
|
629
|
+
# machine wide total that moves when you type a search term is wrong
|
|
630
|
+
disk = sum(r.get("disk", 0) for r in live)
|
|
503
631
|
stale = max(0, self.history["bytes"] - disk)
|
|
504
632
|
line = (f" cpu {sum(r['cpu'] for r in live):.1f}%"
|
|
505
633
|
f" mem {human_bytes(sum(r['rss'] for r in live), unit_kb=True)}"
|
|
506
634
|
f" disk {human_bytes(disk)} active"
|
|
507
635
|
f" history {self.history['ended']} sessions, {human_bytes(stale)}")
|
|
508
|
-
self.
|
|
636
|
+
self._put(1, 0, line, width - 1, curses.A_DIM)
|
|
509
637
|
|
|
510
638
|
note = self.advisory()
|
|
511
639
|
if note:
|
|
512
640
|
column = min(len(line) + 3, width - 2)
|
|
513
|
-
self.
|
|
514
|
-
|
|
641
|
+
self._put(1, column, note, max(0, width - column - 1),
|
|
642
|
+
self.style["warning"])
|
|
643
|
+
|
|
644
|
+
def empty_message(self):
|
|
645
|
+
"""Why the table has nothing in it, which is not always the same why.
|
|
646
|
+
|
|
647
|
+
A filter that matched nothing and no agent being found look identical
|
|
648
|
+
as an empty table; only the message tells them apart.
|
|
649
|
+
"""
|
|
650
|
+
if self.filter_text:
|
|
651
|
+
return f"No rows match /{self.filter_text}. Press esc to clear the filter."
|
|
652
|
+
ended = self.history["ended"]
|
|
653
|
+
if not self.show_ended and ended:
|
|
654
|
+
return (f"No sessions running. {ended} ended session"
|
|
655
|
+
f"{'s' if ended != 1 else ''} on record, press e to show them.")
|
|
656
|
+
return ("No coding agent sessions found. "
|
|
657
|
+
"Run `agent-ps agents` to see what was detected.")
|
|
515
658
|
|
|
516
659
|
def advisory(self):
|
|
517
660
|
"""One short note when something on screen deserves attention.
|
|
@@ -547,26 +690,50 @@ class Tui:
|
|
|
547
690
|
def draw_footer(self, height, width):
|
|
548
691
|
if self.editing_filter:
|
|
549
692
|
prompt = f" filter: {self.filter_text}"
|
|
550
|
-
self.
|
|
551
|
-
|
|
693
|
+
self._put(height - 1, 0, prompt.ljust(width - 1), width - 1,
|
|
694
|
+
self.style["selected"])
|
|
552
695
|
return
|
|
553
696
|
if self.pending:
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
697
|
+
verb, detail, _ = self.pending
|
|
698
|
+
# the answer keys must survive truncation, so they come first and
|
|
699
|
+
# the detail, which can run long, is what gets cut on a narrow screen
|
|
700
|
+
prefix = f" [y] {verb} [n] cancel"
|
|
701
|
+
room = width - 1 - len(prefix) - 3
|
|
702
|
+
text = f"{prefix} {detail[:room]}" if detail and room > 0 else prefix
|
|
703
|
+
self._put(height - 1, 0, text.ljust(width - 1), width - 1,
|
|
704
|
+
self.style["warning"])
|
|
557
705
|
return
|
|
558
706
|
# the note and the legend share a line: a note is worth interrupting the
|
|
559
707
|
# legend for, and it is gone again in a few seconds
|
|
560
708
|
if self.message and time.time() - self.message_at < MESSAGE_SECONDS:
|
|
561
|
-
self.
|
|
562
|
-
|
|
709
|
+
self._put(height - 2, 0, f" {self.message}", width - 1,
|
|
710
|
+
self.style["warning"])
|
|
563
711
|
else:
|
|
564
712
|
self.draw_legend(height - 2, width)
|
|
713
|
+
keys = self.key_bar(width)
|
|
714
|
+
self._put(height - 1, 0, keys.ljust(width - 1), width - 1,
|
|
715
|
+
self.style["header"])
|
|
716
|
+
|
|
717
|
+
def key_bar(self, width):
|
|
718
|
+
"""The bottom row, longest first so the least useful key drops first.
|
|
719
|
+
|
|
720
|
+
`q quit` is appended after fitting the rest, so a screen too narrow
|
|
721
|
+
for anything else still has an answer for how to leave.
|
|
722
|
+
"""
|
|
565
723
|
mode = "hide ended" if self.show_ended else "show ended"
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
724
|
+
# ordered by priority: everything up to and including "e" is kept as
|
|
725
|
+
# long as there is any room at all, the rest goes as the screen narrows
|
|
726
|
+
entries = [("up/down", "move"), ("enter", "details"), ("k", "stop"),
|
|
727
|
+
("/", "filter"), ("s", "sort"), ("e", mode), ("?", "keys"),
|
|
728
|
+
("b", "background"), ("p", "prune"), ("S", "reverse")]
|
|
729
|
+
tail = "q quit"
|
|
730
|
+
while entries:
|
|
731
|
+
text = (" " + " ".join(f"{k} {v}" for k, v in entries)
|
|
732
|
+
+ " " + tail)
|
|
733
|
+
if len(text) <= width - 1:
|
|
734
|
+
return text
|
|
735
|
+
entries.pop()
|
|
736
|
+
return " " + tail
|
|
570
737
|
|
|
571
738
|
def draw_legend(self, screen_row, width):
|
|
572
739
|
"""Which agents are on screen, each in its own colour.
|
|
@@ -587,7 +754,7 @@ class Tui:
|
|
|
587
754
|
placed.append((len(text), agent))
|
|
588
755
|
text += agent + " "
|
|
589
756
|
text = text.rstrip()
|
|
590
|
-
self.
|
|
757
|
+
self._put(screen_row, 0, text, width - 1, curses.A_DIM)
|
|
591
758
|
|
|
592
759
|
# a long agent list leaves no room for a sentence on an 80 column
|
|
593
760
|
# terminal, and dropping the note entirely is worse than abbreviating it
|
|
@@ -599,15 +766,15 @@ class Tui:
|
|
|
599
766
|
for note in (wordy, f"sort: {name} {arrow}" if column else "sort: agent"):
|
|
600
767
|
at = width - len(note) - 2
|
|
601
768
|
if at > len(text) + 3:
|
|
602
|
-
self.
|
|
603
|
-
curses.
|
|
769
|
+
self._put(screen_row, at, note, len(note),
|
|
770
|
+
curses.A_BOLD | curses.A_UNDERLINE)
|
|
604
771
|
break
|
|
605
772
|
|
|
606
773
|
for at, agent in placed:
|
|
607
774
|
if at >= width - 1:
|
|
608
775
|
continue
|
|
609
|
-
self.
|
|
610
|
-
self.agent_colour.get(agent,
|
|
776
|
+
self._put(screen_row, at, agent, min(len(agent), width - 1 - at),
|
|
777
|
+
self.agent_colour.get(agent, curses.A_BOLD))
|
|
611
778
|
|
|
612
779
|
def confirm_kill(self, row):
|
|
613
780
|
"""Ask before stopping, and say where the process is.
|
|
@@ -619,11 +786,11 @@ class Tui:
|
|
|
619
786
|
# the real process table, not the agent rows: a session's children are
|
|
620
787
|
# MCP servers and helpers, which are not rows and would be orphaned
|
|
621
788
|
order = collect_tree(row["pid"], children_of(proc_table()))
|
|
622
|
-
|
|
789
|
+
detail = short_path(row["cwd"], 40) if row["cwd"] else ""
|
|
623
790
|
if row["attach"] == ATTACH_INFERRED:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
791
|
+
detail += (", " if detail else "") + "matched by directory"
|
|
792
|
+
verb = f"stop {len(order)} process" + ("es" if len(order) != 1 else "")
|
|
793
|
+
self.pending = (verb, detail, lambda: self.apply_kill(order))
|
|
627
794
|
|
|
628
795
|
def confirm_prune(self, row):
|
|
629
796
|
"""Ask before removing what an ended session left behind.
|
|
@@ -647,10 +814,10 @@ class Tui:
|
|
|
647
814
|
return
|
|
648
815
|
total = sum(size for _, _, size in parts)
|
|
649
816
|
labels = sorted({label for label, _, _ in parts})
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
817
|
+
verb = f"remove {human_bytes(total)}"
|
|
818
|
+
detail = (f"{', '.join(labels)} from {short_path(name, 24)}, "
|
|
819
|
+
f"transcript kept")
|
|
820
|
+
self.pending = (verb, detail, lambda: self.apply_prune(row, parts))
|
|
654
821
|
|
|
655
822
|
def apply_prune(self, row, parts):
|
|
656
823
|
self.detail = None
|
|
@@ -709,6 +876,15 @@ class Tui:
|
|
|
709
876
|
if self.editing_filter:
|
|
710
877
|
return self.handle_filter(key)
|
|
711
878
|
|
|
879
|
+
if key == curses.KEY_RESIZE:
|
|
880
|
+
curses.update_lines_cols()
|
|
881
|
+
self.cursor = max(0, min(self.cursor, len(self.rows) - 1))
|
|
882
|
+
return True
|
|
883
|
+
|
|
884
|
+
if self.show_help:
|
|
885
|
+
self.show_help = False
|
|
886
|
+
return True
|
|
887
|
+
|
|
712
888
|
# moving around means the last result has been read, so drop the note
|
|
713
889
|
# early rather than leaving it to time out under a different row
|
|
714
890
|
if key in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_HOME, curses.KEY_END,
|
|
@@ -726,7 +902,7 @@ class Tui:
|
|
|
726
902
|
|
|
727
903
|
if self.pending:
|
|
728
904
|
if key in (ord("y"), ord("Y")):
|
|
729
|
-
action = self.pending[
|
|
905
|
+
action = self.pending[2]
|
|
730
906
|
self.pending = None
|
|
731
907
|
action()
|
|
732
908
|
elif key in (ord("n"), ord("N"), 27):
|
|
@@ -751,12 +927,10 @@ class Tui:
|
|
|
751
927
|
elif key == ord("s"):
|
|
752
928
|
self.sort = (self.sort + 1) % len(SORTS)
|
|
753
929
|
self.descending = SORTS[self.sort][3]
|
|
754
|
-
self.
|
|
755
|
-
self.poll()
|
|
930
|
+
self.reshape()
|
|
756
931
|
elif key == ord("S"):
|
|
757
932
|
self.descending = not self.descending
|
|
758
|
-
self.
|
|
759
|
-
self.poll()
|
|
933
|
+
self.reshape()
|
|
760
934
|
elif key in (ord("r"), ord("R")):
|
|
761
935
|
self.poll()
|
|
762
936
|
self.message = ""
|
|
@@ -767,6 +941,8 @@ class Tui:
|
|
|
767
941
|
self.poll()
|
|
768
942
|
elif key == ord("/"):
|
|
769
943
|
self.editing_filter = True
|
|
944
|
+
elif key == ord("?"):
|
|
945
|
+
self.show_help = True
|
|
770
946
|
elif key == ord("k"):
|
|
771
947
|
row = self.selected()
|
|
772
948
|
if row and row["pid"]:
|
|
@@ -782,8 +958,9 @@ class Tui:
|
|
|
782
958
|
elif key in (ord("b"), ord("B")):
|
|
783
959
|
background = [r["pid"] for r in self.rows if r["background"]]
|
|
784
960
|
if background:
|
|
785
|
-
|
|
786
|
-
|
|
961
|
+
verb = ("stop " + str(len(background)) + " background process"
|
|
962
|
+
+ ("es" if len(background) != 1 else ""))
|
|
963
|
+
self.pending = (verb, "", lambda: self.apply_kill(background))
|
|
787
964
|
else:
|
|
788
965
|
self.notify("No background processes.")
|
|
789
966
|
return True
|
|
@@ -791,11 +968,10 @@ class Tui:
|
|
|
791
968
|
def handle_filter(self, key):
|
|
792
969
|
if key in (10, 13, curses.KEY_ENTER):
|
|
793
970
|
self.editing_filter = False
|
|
794
|
-
self.poll()
|
|
795
971
|
elif key == 27:
|
|
796
972
|
self.editing_filter = False
|
|
797
973
|
self.filter_text = ""
|
|
798
|
-
self.
|
|
974
|
+
self.reshape()
|
|
799
975
|
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
800
976
|
# backspacing past the last character leaves filter mode, so the key
|
|
801
977
|
# hints come back without needing escape
|
|
@@ -805,10 +981,10 @@ class Tui:
|
|
|
805
981
|
self.filter_text = self.filter_text[:-1]
|
|
806
982
|
if not self.filter_text:
|
|
807
983
|
self.editing_filter = False
|
|
808
|
-
self.
|
|
984
|
+
self.reshape()
|
|
809
985
|
elif 32 <= key < 127:
|
|
810
986
|
self.filter_text += chr(key)
|
|
811
|
-
self.
|
|
987
|
+
self.reshape()
|
|
812
988
|
return True
|
|
813
989
|
|
|
814
990
|
def loop(self):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-ps
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
4
4
|
Summary: A process table for coding agent sessions. Claude Code, Codex, OpenCode, Hermes, Pi, CommandCode and Copilot in one view, with models, idle time, disk use and one key to stop them.
|
|
5
5
|
Author: mkhuda
|
|
6
6
|
License: MIT License
|
|
@@ -207,6 +207,7 @@ something.
|
|
|
207
207
|
| esc | leave filter mode, or close the detail panel |
|
|
208
208
|
| space | pause refreshing |
|
|
209
209
|
| `r` | refresh now |
|
|
210
|
+
| `?` | show or hide this list |
|
|
210
211
|
| `q` | quit |
|
|
211
212
|
|
|
212
213
|
Those keys live at the bottom of the screen, under a line naming every agent on
|
|
@@ -233,9 +234,13 @@ PID AGENT SESSION STATUS MODEL UPTIME ACT
|
|
|
233
234
|
- claude notes ended opus-5 - 2h22m ago - - 84M
|
|
234
235
|
|
|
235
236
|
agents claude codex commandcode copilot hermes opencode pi sorted by disk, high to low
|
|
236
|
-
up/down
|
|
237
|
+
up/down move enter details k stop / filter s sort e hide ended ? keys b background p prune S reverse q quit
|
|
237
238
|
```
|
|
238
239
|
|
|
240
|
+
Keys drop from the right as the terminal narrows, `q quit` last of all. Press
|
|
241
|
+
`?` for the ones that never fit: `home`/`end`, `space` to pause, `r` to refresh
|
|
242
|
+
now, `y`/`n` to answer a confirmation.
|
|
243
|
+
|
|
239
244
|
`v` means descending and `^` ascending; `S` flips it. Numbers start at the
|
|
240
245
|
largest, names start at A, and rows with nothing in that column go to the end
|
|
241
246
|
either way.
|
|
@@ -422,7 +427,7 @@ install fetches. The build is reproducible: the same source always produces the
|
|
|
422
427
|
same bytes, so the committed executable can be checked against the tree.
|
|
423
428
|
|
|
424
429
|
```
|
|
425
|
-
|
|
430
|
+
03cc2271187211d38e27773db9067a2a71c5a7055d6776c5d85df178f58f4d0c agent-ps
|
|
426
431
|
```
|
|
427
432
|
|
|
428
433
|
Adding an agent takes one class and one line in the registry. See
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The TUI against terminals it was not designed for.
|
|
2
|
+
|
|
3
|
+
These run the real program under a pseudo terminal, because the failures they
|
|
4
|
+
catch are ones no unit test sees: a window that shrinks after the layout was
|
|
5
|
+
computed, and a terminfo that cannot hide the cursor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import select
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import struct
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import unittest
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
21
|
+
|
|
22
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
ESCAPES = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import fcntl
|
|
27
|
+
import pty
|
|
28
|
+
import termios
|
|
29
|
+
HAVE_PTY = True
|
|
30
|
+
except ImportError: # pragma: no cover - Windows has no pty
|
|
31
|
+
HAVE_PTY = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@unittest.skipUnless(HAVE_PTY, "needs a pseudo terminal")
|
|
35
|
+
class UnderAPseudoTerminal(unittest.TestCase):
|
|
36
|
+
def setUp(self):
|
|
37
|
+
# an empty home, not the real one: the first poll otherwise scans
|
|
38
|
+
# whatever sessions actually exist on this machine, which is slow and
|
|
39
|
+
# unpredictable, and is exactly what made this suite flaky
|
|
40
|
+
self.home = tempfile.mkdtemp()
|
|
41
|
+
self.addCleanup(shutil.rmtree, self.home, ignore_errors=True)
|
|
42
|
+
|
|
43
|
+
def run_tui(self, rows, cols, shrink_to=None, term="xterm-256color"):
|
|
44
|
+
"""Start the TUI, optionally resize it, quit, and return its output."""
|
|
45
|
+
main, sub = pty.openpty()
|
|
46
|
+
fcntl.ioctl(sub, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
47
|
+
child = subprocess.Popen(
|
|
48
|
+
[sys.executable, "-m", "agent_ps"], stdin=sub, stdout=sub, stderr=sub,
|
|
49
|
+
cwd=ROOT, env=dict(os.environ, TERM=term, HOME=self.home))
|
|
50
|
+
os.close(sub)
|
|
51
|
+
|
|
52
|
+
def pump(seconds):
|
|
53
|
+
"""Read whatever arrives, for at most this long."""
|
|
54
|
+
got, until = b"", time.time() + seconds
|
|
55
|
+
while time.time() < until and child.poll() is None:
|
|
56
|
+
ready, _, _ = select.select([main], [], [], 0.05)
|
|
57
|
+
if not ready:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
chunk = os.read(main, 65536)
|
|
61
|
+
except OSError:
|
|
62
|
+
break
|
|
63
|
+
if not chunk:
|
|
64
|
+
break
|
|
65
|
+
got += chunk
|
|
66
|
+
until = min(until, time.time() + 0.3) # quiet means it settled
|
|
67
|
+
return got
|
|
68
|
+
|
|
69
|
+
out = pump(4.0)
|
|
70
|
+
if shrink_to and child.poll() is None:
|
|
71
|
+
fcntl.ioctl(main, termios.TIOCSWINSZ,
|
|
72
|
+
struct.pack("HHHH", shrink_to[0], shrink_to[1], 0, 0))
|
|
73
|
+
child.send_signal(signal.SIGWINCH)
|
|
74
|
+
out += pump(2.0)
|
|
75
|
+
if child.poll() is None:
|
|
76
|
+
try:
|
|
77
|
+
os.write(main, b"q")
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
out += pump(1.0)
|
|
81
|
+
try:
|
|
82
|
+
child.wait(timeout=3)
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
child.kill()
|
|
85
|
+
child.wait()
|
|
86
|
+
try:
|
|
87
|
+
while True:
|
|
88
|
+
chunk = os.read(main, 65536)
|
|
89
|
+
if not chunk:
|
|
90
|
+
break
|
|
91
|
+
out += chunk
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
os.close(main)
|
|
95
|
+
return ESCAPES.sub("", out.decode("utf8", "ignore"))
|
|
96
|
+
|
|
97
|
+
def assertNoCrash(self, text, what):
|
|
98
|
+
self.assertNotIn("Traceback", text, f"{what} crashed:\n{text[-600:]}")
|
|
99
|
+
self.assertNotIn("curses.error", text, f"{what} crashed:\n{text[-600:]}")
|
|
100
|
+
|
|
101
|
+
def test_a_normal_terminal(self):
|
|
102
|
+
self.assertNoCrash(self.run_tui(24, 120), "24x120")
|
|
103
|
+
|
|
104
|
+
def test_a_terminal_too_small_to_lay_out(self):
|
|
105
|
+
for rows, cols in ((4, 100), (1, 80), (2, 30)):
|
|
106
|
+
with self.subTest(f"{cols}x{rows}"):
|
|
107
|
+
seen = self.run_tui(rows, cols)
|
|
108
|
+
self.assertNoCrash(seen, f"{cols}x{rows}")
|
|
109
|
+
self.assertIn("too small", seen,
|
|
110
|
+
"a screen with no room must say so, not go blank")
|
|
111
|
+
|
|
112
|
+
def test_shrinking_the_window_while_it_runs(self):
|
|
113
|
+
# the layout is computed for one size and drawn against another
|
|
114
|
+
for shrink in ((3, 60), (1, 20)):
|
|
115
|
+
with self.subTest(f"{shrink[1]}x{shrink[0]}"):
|
|
116
|
+
self.assertNoCrash(self.run_tui(24, 120, shrink_to=shrink),
|
|
117
|
+
f"resize to {shrink}")
|
|
118
|
+
|
|
119
|
+
def test_terminals_that_cannot_hide_the_cursor(self):
|
|
120
|
+
# curs_set raises rather than returning a failure on these
|
|
121
|
+
for term in ("vt100", "vt220", "ansi", "dumb"):
|
|
122
|
+
with self.subTest(term):
|
|
123
|
+
self.assertNoCrash(self.run_tui(24, 120, term=term), term)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
unittest.main()
|
|
@@ -11,7 +11,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
11
11
|
import curses
|
|
12
12
|
|
|
13
13
|
from agent_ps import ui, util
|
|
14
|
-
from agent_ps.ui import Tui
|
|
14
|
+
from agent_ps.ui import SORTS, Tui
|
|
15
15
|
from agent_ps.backends.base import KIND_ENDED, KIND_SESSION, blank_row
|
|
16
16
|
from agent_ps.collect import is_live, idle_seconds
|
|
17
17
|
|
|
@@ -238,10 +238,11 @@ class PruneKey(unittest.TestCase):
|
|
|
238
238
|
made = self.tui()
|
|
239
239
|
made.confirm_prune(self.row())
|
|
240
240
|
self.assertIsNotNone(made.pending)
|
|
241
|
-
|
|
242
|
-
self.assertIn("web-app",
|
|
243
|
-
self.assertIn("subagents",
|
|
244
|
-
self.assertIn("transcript
|
|
241
|
+
verb, detail, _ = made.pending
|
|
242
|
+
self.assertIn("web-app", detail)
|
|
243
|
+
self.assertIn("subagents", detail)
|
|
244
|
+
self.assertIn("transcript kept", detail)
|
|
245
|
+
self.assertIn("remove", verb)
|
|
245
246
|
|
|
246
247
|
def test_a_running_session_is_refused_and_told_why(self):
|
|
247
248
|
made = self.tui()
|
|
@@ -341,3 +342,220 @@ class ReclaimableTotal(unittest.TestCase):
|
|
|
341
342
|
def test_a_recent_session_is_left_out(self):
|
|
342
343
|
made = self.snapshot([self.session("a", 30), self.session("b", 1)])
|
|
343
344
|
self.assertEqual(made.reclaimable(time.time() - 7 * 86400), (1_000_000, 1))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class Resilience(unittest.TestCase):
|
|
348
|
+
"""What the drawing layer does when the screen is not what it expected."""
|
|
349
|
+
|
|
350
|
+
class Screen:
|
|
351
|
+
def __init__(self, fail=False):
|
|
352
|
+
self.fail, self.calls = fail, []
|
|
353
|
+
|
|
354
|
+
def addnstr(self, y, x, text, n, attr=0):
|
|
355
|
+
self.calls.append((y, x, text, n))
|
|
356
|
+
if self.fail:
|
|
357
|
+
raise curses.error("addnwstr() returned ERR")
|
|
358
|
+
|
|
359
|
+
def test_a_screen_that_refuses_the_write_does_not_crash(self):
|
|
360
|
+
tui = Tui.__new__(Tui)
|
|
361
|
+
tui.screen = self.Screen(fail=True)
|
|
362
|
+
tui._put(0, 0, "anything", 8) # must not raise
|
|
363
|
+
|
|
364
|
+
def test_nothing_is_written_outside_the_screen(self):
|
|
365
|
+
tui = Tui.__new__(Tui)
|
|
366
|
+
tui.screen = self.Screen()
|
|
367
|
+
for y, x, n in ((-1, 0, 5), (0, -1, 5), (0, 0, 0), (0, 0, -3)):
|
|
368
|
+
tui._put(y, x, "x", n)
|
|
369
|
+
self.assertEqual(tui.screen.calls, [], "a doomed write was attempted")
|
|
370
|
+
|
|
371
|
+
def test_a_write_that_fits_goes_through(self):
|
|
372
|
+
tui = Tui.__new__(Tui)
|
|
373
|
+
tui.screen = self.Screen()
|
|
374
|
+
tui._put(1, 2, "hello", 5, 0)
|
|
375
|
+
self.assertEqual(tui.screen.calls, [(1, 2, "hello", 5)])
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class CursorFollowsTheRow(unittest.TestCase):
|
|
379
|
+
"""Under a live sort the order changes; the cursor must not drift.
|
|
380
|
+
|
|
381
|
+
This is a safety property, not a nicety: k acts on whatever the cursor is
|
|
382
|
+
over, so a cursor that moves on its own stops the wrong session.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def tui(self, rows, cursor=0, sort=0):
|
|
386
|
+
made = Tui.__new__(Tui)
|
|
387
|
+
made.all_rows = rows
|
|
388
|
+
made.rows = list(rows)
|
|
389
|
+
made.cursor = cursor
|
|
390
|
+
made.filter_text = ""
|
|
391
|
+
made.sort = sort
|
|
392
|
+
made.descending = True
|
|
393
|
+
made.detail = None
|
|
394
|
+
return made
|
|
395
|
+
|
|
396
|
+
def row(self, session, disk=0, **over):
|
|
397
|
+
return dict(blank_row("claude"), session_id=session, disk=disk, **over)
|
|
398
|
+
|
|
399
|
+
def test_the_cursor_stays_on_its_session_when_the_order_changes(self):
|
|
400
|
+
a, b, c = self.row("a", 10), self.row("b", 20), self.row("c", 30)
|
|
401
|
+
made = self.tui([a, b, c], cursor=0)
|
|
402
|
+
made.rows = [a, b, c]
|
|
403
|
+
held = made.selected()
|
|
404
|
+
self.assertEqual(held["session_id"], "a")
|
|
405
|
+
# the same three rows arrive sorted by disk, so "a" is now last
|
|
406
|
+
made.all_rows = [c, b, a]
|
|
407
|
+
made.sort = next(i for i, s in enumerate(SORTS) if s[0] == "disk")
|
|
408
|
+
made.reshape()
|
|
409
|
+
self.assertEqual(made.selected()["session_id"], "a",
|
|
410
|
+
"the cursor followed the position, not the session")
|
|
411
|
+
|
|
412
|
+
def test_the_cursor_falls_back_when_its_session_is_gone(self):
|
|
413
|
+
a, b = self.row("a"), self.row("b")
|
|
414
|
+
made = self.tui([a, b], cursor=1)
|
|
415
|
+
made.rows = [a, b]
|
|
416
|
+
made.all_rows = [a] # b ended between polls
|
|
417
|
+
made.reshape()
|
|
418
|
+
self.assertEqual(made.cursor, 0)
|
|
419
|
+
|
|
420
|
+
def test_filtering_keeps_the_cursor_on_a_row_that_survives(self):
|
|
421
|
+
a, b = self.row("a", title="keep me"), self.row("b", title="other")
|
|
422
|
+
made = self.tui([a, b], cursor=0)
|
|
423
|
+
made.rows = [a, b]
|
|
424
|
+
made.filter_text = "keep"
|
|
425
|
+
made.reshape()
|
|
426
|
+
self.assertEqual(made.rows, [a])
|
|
427
|
+
self.assertEqual(made.cursor, 0)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class ReshapeReadsNothing(unittest.TestCase):
|
|
431
|
+
"""Sorting and filtering are questions about rows already in hand."""
|
|
432
|
+
|
|
433
|
+
class Exploding:
|
|
434
|
+
def rows(self, *a, **k):
|
|
435
|
+
raise AssertionError("reshape asked the machine again")
|
|
436
|
+
|
|
437
|
+
def history(self, force=False):
|
|
438
|
+
raise AssertionError("reshape asked the machine again")
|
|
439
|
+
|
|
440
|
+
def test_reshape_never_touches_the_snapshot(self):
|
|
441
|
+
made = Tui.__new__(Tui)
|
|
442
|
+
made.snapshot = self.Exploding()
|
|
443
|
+
made.all_rows = [dict(blank_row("claude"), session_id="a")]
|
|
444
|
+
made.rows, made.cursor, made.detail = [], 0, None
|
|
445
|
+
made.filter_text, made.sort, made.descending = "a", 0, True
|
|
446
|
+
made.reshape() # must not raise
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class SummaryArithmetic(unittest.TestCase):
|
|
450
|
+
def test_history_starts_with_every_key_the_advisory_reads(self):
|
|
451
|
+
made = Tui.__new__(Tui)
|
|
452
|
+
Tui.__init__(made, None, None)
|
|
453
|
+
for key in ("bytes", "ended", "recent", "spare", "spare_sessions"):
|
|
454
|
+
self.assertIn(key, made.history)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class KeyBarPriority(unittest.TestCase):
|
|
458
|
+
"""The key bar drops low priority keys first, and q is never one of them."""
|
|
459
|
+
|
|
460
|
+
def bar(self, width, show_ended=False):
|
|
461
|
+
made = Tui.__new__(Tui)
|
|
462
|
+
made.show_ended = show_ended
|
|
463
|
+
return made.key_bar(width)
|
|
464
|
+
|
|
465
|
+
def test_everything_fits_on_a_wide_terminal(self):
|
|
466
|
+
wide = self.bar(250)
|
|
467
|
+
for key in ("up/down", "enter", "k", "b", "p", "e", "s", "S", "/", "?", "q"):
|
|
468
|
+
self.assertIn(key, wide.split())
|
|
469
|
+
|
|
470
|
+
def test_q_quit_survives_at_80_columns(self):
|
|
471
|
+
# this used to be the first casualty: the bar was 114 characters
|
|
472
|
+
bar = self.bar(80)
|
|
473
|
+
self.assertLessEqual(len(bar), 79)
|
|
474
|
+
self.assertIn("q quit", bar)
|
|
475
|
+
|
|
476
|
+
def test_it_never_exceeds_the_width_it_was_given(self):
|
|
477
|
+
for width in (250, 120, 100, 80, 60, 45, 40):
|
|
478
|
+
with self.subTest(width):
|
|
479
|
+
self.assertLessEqual(len(self.bar(width)), width - 1)
|
|
480
|
+
|
|
481
|
+
def test_ended_mode_changes_the_e_label(self):
|
|
482
|
+
self.assertIn("show ended", self.bar(250, show_ended=False))
|
|
483
|
+
self.assertIn("hide ended", self.bar(250, show_ended=True))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class ConfirmationLayout(unittest.TestCase):
|
|
487
|
+
"""The answer keys must survive truncation; the detail is what gives."""
|
|
488
|
+
|
|
489
|
+
def rendered(self, verb, detail, width):
|
|
490
|
+
prefix = f" [y] {verb} [n] cancel"
|
|
491
|
+
room = width - 1 - len(prefix) - 3
|
|
492
|
+
return f"{prefix} {detail[:room]}" if detail and room > 0 else prefix
|
|
493
|
+
|
|
494
|
+
def test_both_keys_are_present_at_every_realistic_width(self):
|
|
495
|
+
detail = ("in ~/projects/agent-ps/some/very/long/nested/directory, "
|
|
496
|
+
"matched by directory")
|
|
497
|
+
for width in (250, 100, 80, 60, 45):
|
|
498
|
+
with self.subTest(width):
|
|
499
|
+
text = self.rendered("stop 7 processes", detail, width)
|
|
500
|
+
self.assertIn("[y]", text[:width - 1])
|
|
501
|
+
self.assertIn("[n]", text[:width - 1])
|
|
502
|
+
|
|
503
|
+
def test_the_verb_is_never_truncated_even_with_no_room_for_detail(self):
|
|
504
|
+
text = self.rendered("stop 7 processes", "somewhere far away", 30)
|
|
505
|
+
self.assertIn("stop 7 processes", text)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class EmptyMessage(unittest.TestCase):
|
|
509
|
+
def make(self, filter_text="", show_ended=False, ended=0):
|
|
510
|
+
made = Tui.__new__(Tui)
|
|
511
|
+
made.filter_text = filter_text
|
|
512
|
+
made.show_ended = show_ended
|
|
513
|
+
made.history = {"ended": ended}
|
|
514
|
+
return made
|
|
515
|
+
|
|
516
|
+
def test_a_filter_with_no_matches_says_so(self):
|
|
517
|
+
said = self.make(filter_text="zzz").empty_message()
|
|
518
|
+
self.assertIn("/zzz", said)
|
|
519
|
+
self.assertIn("esc", said)
|
|
520
|
+
|
|
521
|
+
def test_hidden_ended_sessions_are_offered(self):
|
|
522
|
+
said = self.make(ended=12).empty_message()
|
|
523
|
+
self.assertIn("12", said)
|
|
524
|
+
self.assertIn("press e", said)
|
|
525
|
+
|
|
526
|
+
def test_nothing_at_all_points_at_agents_command(self):
|
|
527
|
+
said = self.make(show_ended=True).empty_message()
|
|
528
|
+
self.assertIn("agent-ps agents", said)
|
|
529
|
+
|
|
530
|
+
def test_a_filter_takes_priority_over_ended_sessions(self):
|
|
531
|
+
said = self.make(filter_text="zzz", ended=12).empty_message()
|
|
532
|
+
self.assertIn("zzz", said)
|
|
533
|
+
self.assertNotIn("12", said)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class AgentStyle(unittest.TestCase):
|
|
537
|
+
"""A colour that repeats must not look identical to the one before it."""
|
|
538
|
+
|
|
539
|
+
def test_the_eighth_agent_does_not_match_the_first_on_a_basic_terminal(self):
|
|
540
|
+
with mock.patch.object(curses, "COLORS", 8, create=True):
|
|
541
|
+
colour0, attr0 = ui._agent_style(0)
|
|
542
|
+
colour7, attr7 = ui._agent_style(7)
|
|
543
|
+
self.assertEqual(colour0, colour7, "the premise: colours do repeat")
|
|
544
|
+
self.assertNotEqual(attr0, attr7,
|
|
545
|
+
"same colour, same attribute: indistinguishable")
|
|
546
|
+
|
|
547
|
+
def test_a_256_colour_terminal_gives_the_eighth_agent_its_own_colour(self):
|
|
548
|
+
with mock.patch.object(curses, "COLORS", 256, create=True):
|
|
549
|
+
colour0, _ = ui._agent_style(0)
|
|
550
|
+
colour7, _ = ui._agent_style(7)
|
|
551
|
+
self.assertNotEqual(colour0, colour7)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class HelpOverlay(unittest.TestCase):
|
|
555
|
+
def test_every_key_in_the_bar_is_documented_somewhere(self):
|
|
556
|
+
# the bar can drop a key as the terminal narrows; the overlay must
|
|
557
|
+
# still say what it does
|
|
558
|
+
from agent_ps.ui import HELP
|
|
559
|
+
documented = " ".join(k for k, _ in HELP)
|
|
560
|
+
for key in ("k", "b", "p", "e", "s", "S", "/", "q", "space", "r"):
|
|
561
|
+
self.assertIn(key, documented)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|