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.
Files changed (34) hide show
  1. {agent_ps-0.6.2 → agent_ps-0.6.4}/PKG-INFO +8 -3
  2. {agent_ps-0.6.2 → agent_ps-0.6.4}/README.md +7 -2
  3. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/__init__.py +1 -1
  4. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/__main__.py +3 -0
  5. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/ui.py +264 -88
  6. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/PKG-INFO +8 -3
  7. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/SOURCES.txt +1 -0
  8. agent_ps-0.6.4/tests/test_terminal.py +127 -0
  9. {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_ui.py +223 -5
  10. {agent_ps-0.6.2 → agent_ps-0.6.4}/LICENSE +0 -0
  11. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/__init__.py +0 -0
  12. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/antigravity.py +0 -0
  13. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/base.py +0 -0
  14. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/claude.py +0 -0
  15. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/codex.py +0 -0
  16. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/copilot.py +0 -0
  17. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/hermes.py +0 -0
  18. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/opencode.py +0 -0
  19. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/pilike.py +0 -0
  20. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/backends/sqlite.py +0 -0
  21. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/collect.py +0 -0
  22. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/jsonl.py +0 -0
  23. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/procs.py +0 -0
  24. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/resume.py +0 -0
  25. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps/util.py +0 -0
  26. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/dependency_links.txt +0 -0
  27. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/entry_points.txt +0 -0
  28. {agent_ps-0.6.2 → agent_ps-0.6.4}/agent_ps.egg-info/top_level.txt +0 -0
  29. {agent_ps-0.6.2 → agent_ps-0.6.4}/pyproject.toml +0 -0
  30. {agent_ps-0.6.2 → agent_ps-0.6.4}/setup.cfg +0 -0
  31. {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_backends.py +0 -0
  32. {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_procs.py +0 -0
  33. {agent_ps-0.6.2 → agent_ps-0.6.4}/tests/test_prune.py +0 -0
  34. {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.2
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 select enter details k stop b background e hide ended s sort S reverse / filter q quit
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
- fb81708bdedfee2dea7f83bf92cc3b4f32596a02af7d2e6383c92acd57790a4b agent-ps
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 select enter details k stop b background e hide ended s sort S reverse / filter q quit
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
- fb81708bdedfee2dea7f83bf92cc3b4f32596a02af7d2e6383c92acd57790a4b agent-ps
384
+ 03cc2271187211d38e27773db9067a2a71c5a7055d6776c5d85df178f58f4d0c agent-ps
380
385
  ```
381
386
 
382
387
  Adding an agent takes one class and one line in the registry. See
@@ -1,3 +1,3 @@
1
1
  """agent-ps: a process table for coding agent sessions."""
2
2
 
3
- VERSION = "0.6.2"
3
+ VERSION = "0.6.4"
@@ -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 basic terminal has only seven usable colours, and the eighth agent would
36
- #: have worn the first one's. A terminal offering 256 has room for more without
37
- #: disturbing the first seven, which people have already learned. The first of
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 _agent_colour(index):
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
- return EXTRA_COLOURS[(index - len(AGENT_COLOURS)) % len(EXTRA_COLOURS)]
118
- return AGENT_COLOURS[index % len(AGENT_COLOURS)]
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
- C_SORT = 6
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
- # (question, action), so one y/n path serves every destructive key
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.history = {"bytes": 0, "ended": 0, "recent": 0}
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
- curses.curs_set(0)
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
- # the heading row is black on white, so the sorted column needs its own
248
- # background. Cyan rather than yellow, because a light terminal theme
249
- # renders a yellow background close enough to white to disappear.
250
- curses.init_pair(self.C_SORT, curses.COLOR_BLACK, curses.COLOR_CYAN)
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, _agent_colour(index), -1)
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
- rows = self.snapshot.rows(show_ended=self.show_ended, limit=ATTACH_WINDOW)
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
- self.cursor = max(0, min(self.cursor, len(self.rows) - 1))
269
- self.last_poll = time.time()
270
- self.history = self.snapshot.history()
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(curses.color_pair(self.C_HEADER))
318
- self.screen.addnstr(2, 0, format_header(width).ljust(width - 1), width - 1)
319
- self.screen.attroff(curses.color_pair(self.C_HEADER))
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 = curses.color_pair(self.C_SELECTED)
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 = curses.color_pair(self.C_BACKGROUND)
427
+ attr = self.style["background"]
333
428
  elif row["status"] == STATUS_BUSY:
334
- attr = curses.color_pair(self.C_BUSY)
429
+ attr = self.style["busy"]
335
430
  else:
336
431
  attr = curses.A_NORMAL
337
432
  screen_row = 3 + index
338
- self.screen.addnstr(screen_row, 0, line, width - 1, attr)
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.screen.addnstr(4, 2, "No coding agent sessions running.", width - 3)
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.screen.addnstr(screen_row, start, text, min(size, width - 1 - start),
366
- colour | (base & curses.A_DIM) | curses.A_BOLD)
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.screen.addnstr(2, start, text, min(cell, width - 1 - start),
386
- curses.color_pair(self.C_SORT) | curses.A_BOLD)
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
- curses.color_pair(self.C_BUSY) if row["status"] == STATUS_BUSY else 0),
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
- curses.color_pair(self.C_WARNING)))
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(curses.color_pair(self.C_HEADER))
442
- self.screen.addnstr(2, 0, " session details".ljust(width - 1), width - 1)
443
- self.screen.attroff(curses.color_pair(self.C_HEADER))
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.screen.addnstr(screen_row, 2, heading.upper(), 12,
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.screen.addnstr(screen_row, 4, f"{label:>11}", 11,
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.screen.addnstr(screen_row, 17, chunk, width - 18, attr)
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.screen.addnstr(height - 1, 0, keys.ljust(width - 1), width - 1,
476
- curses.color_pair(self.C_HEADER))
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
- parts = [f"{sessions} session" + ("s" if sessions != 1 else "")]
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.screen.addnstr(0, 1, "agent-ps", width - 2)
624
+ self._put(0, 1, "agent-ps", width - 2)
499
625
  self.screen.attroff(curses.A_BOLD)
500
- self.screen.addnstr(0, 10, f" {' '.join(parts)} {state}", width - 11)
626
+ self._put(0, 10, f" {' '.join(parts)} {state}", width - 11)
501
627
 
502
- disk = sum(r.get("disk", 0) for r in self.rows)
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.screen.addnstr(1, 0, line, width - 1, curses.A_DIM)
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.screen.addnstr(1, column, note, max(0, width - column - 1),
514
- curses.color_pair(self.C_WARNING))
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.screen.addnstr(height - 1, 0, prompt.ljust(width - 1), width - 1,
551
- curses.color_pair(self.C_SELECTED))
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
- text = f" {self.pending[0]} [y] confirm [n] cancel"
555
- self.screen.addnstr(height - 1, 0, text.ljust(width - 1), width - 1,
556
- curses.color_pair(self.C_WARNING))
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.screen.addnstr(height - 2, 0, f" {self.message}", width - 1,
562
- curses.color_pair(self.C_WARNING))
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
- keys = (" up/down enter details k stop b background p prune"
567
- f" e {mode} s sort S reverse / filter q quit")
568
- self.screen.addnstr(height - 1, 0, keys.ljust(width - 1), width - 1,
569
- curses.color_pair(self.C_HEADER))
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.screen.addnstr(screen_row, 0, text, width - 1, curses.A_DIM)
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.screen.addnstr(screen_row, at, note, len(note),
603
- curses.color_pair(self.C_SORT) | curses.A_BOLD)
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.screen.addnstr(screen_row, at, agent, min(len(agent), width - 1 - at),
610
- self.agent_colour.get(agent, 0) | curses.A_BOLD)
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
- note = f" in {short_path(row['cwd'], 40)}" if row["cwd"] else ""
789
+ detail = short_path(row["cwd"], 40) if row["cwd"] else ""
623
790
  if row["attach"] == ATTACH_INFERRED:
624
- note += ", session matched by directory"
625
- self.pending = (f"Stop {len(order)} process(es){note}?",
626
- lambda: self.apply_kill(order))
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
- self.pending = (
651
- f"Remove {human_bytes(total)} of {', '.join(labels)} from "
652
- f"{short_path(name, 24)}? The transcript is kept.",
653
- lambda: self.apply_prune(row, parts))
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[1]
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.cursor = 0
755
- self.poll()
930
+ self.reshape()
756
931
  elif key == ord("S"):
757
932
  self.descending = not self.descending
758
- self.cursor = 0
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
- self.pending = (f"Stop {len(background)} background process(es)?",
786
- lambda: self.apply_kill(background))
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.poll()
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.poll()
984
+ self.reshape()
809
985
  elif 32 <= key < 127:
810
986
  self.filter_text += chr(key)
811
- self.poll()
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.2
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 select enter details k stop b background e hide ended s sort S reverse / filter q quit
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
- fb81708bdedfee2dea7f83bf92cc3b4f32596a02af7d2e6383c92acd57790a4b agent-ps
430
+ 03cc2271187211d38e27773db9067a2a71c5a7055d6776c5d85df178f58f4d0c agent-ps
426
431
  ```
427
432
 
428
433
  Adding an agent takes one class and one line in the registry. See
@@ -28,4 +28,5 @@ tests/test_backends.py
28
28
  tests/test_procs.py
29
29
  tests/test_prune.py
30
30
  tests/test_release_notes.py
31
+ tests/test_terminal.py
31
32
  tests/test_ui.py
@@ -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
- question = made.pending[0]
242
- self.assertIn("web-app", question)
243
- self.assertIn("subagents", question)
244
- self.assertIn("transcript is kept", question)
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