agent-ps 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_ps/ui.py ADDED
@@ -0,0 +1,670 @@
1
+ """The table and the live view.
2
+
3
+ Nothing here knows which agent a row came from. Every backend fills the same
4
+ shape, so a column is one lookup and never a branch.
5
+ """
6
+
7
+ import curses
8
+ import time
9
+
10
+ from . import backends
11
+ from .collect import ATTACH_WINDOW, idle_seconds, is_live
12
+ from .backends.base import (ATTACH_INFERRED, KIND_ENDED, KIND_SESSION,
13
+ STATUS_BUSY)
14
+ from .procs import children_of, collect_tree, terminate
15
+ from .resume import open_in_terminal, resume_command
16
+ from .util import FAILURES, human_bytes, human_duration, short_path
17
+
18
+ REFRESH_SECONDS = 2.0
19
+ MESSAGE_SECONDS = 6.0
20
+
21
+ #: Assigned to agents in registry order, so a new backend gets a colour without
22
+ #: anything here having to learn its name.
23
+ AGENT_COLOURS = (
24
+ curses.COLOR_YELLOW,
25
+ curses.COLOR_CYAN,
26
+ curses.COLOR_MAGENTA,
27
+ curses.COLOR_GREEN,
28
+ curses.COLOR_BLUE,
29
+ curses.COLOR_RED,
30
+ curses.COLOR_WHITE,
31
+ )
32
+
33
+
34
+ def agent_label(row):
35
+ """Agent name, with the sort of process appended when it is not a session."""
36
+ kind = row["kind"]
37
+ if kind in (KIND_ENDED, KIND_SESSION):
38
+ return row["agent"]
39
+ return f"{row['agent']}:{kind}"
40
+
41
+
42
+ def status_label(row):
43
+ """What the session is doing, or a dash when nothing says.
44
+
45
+ A process with no session paired to it has no turn to report.
46
+ """
47
+ if row["kind"] == KIND_ENDED:
48
+ return "ended"
49
+ return row["status"] or "-"
50
+
51
+
52
+ def pid_label(row):
53
+ """PID, marked when the pairing with a session was inferred.
54
+
55
+ Only Claude Code records which process runs which session. Elsewhere the two
56
+ are matched on working directory, which cannot tell apart two sessions of the
57
+ same agent in one folder, so the guess is shown as a guess.
58
+ """
59
+ if not row["pid"]:
60
+ return "-"
61
+ return f"{row['pid']}?" if row["attach"] == ATTACH_INFERRED else str(row["pid"])
62
+
63
+
64
+ def model_label(row):
65
+ model = row.get("model", "")
66
+ if model.startswith("claude-"):
67
+ model = model[len("claude-"):]
68
+ return model or "-"
69
+
70
+
71
+ def idle_label(row):
72
+ """Time since the last turn, coarser the further back it goes.
73
+
74
+ A session last touched four months ago is old, and knowing it was 120 days
75
+ and 18 hours does not make it any older. Keeping long spans short is what
76
+ lets this column stay narrow enough to sit beside uptime.
77
+ """
78
+ seconds = idle_seconds(row)
79
+ if not seconds:
80
+ return "-"
81
+ if seconds < 60:
82
+ return "now"
83
+ if seconds < 30 * 86400:
84
+ return human_duration(seconds) + " ago"
85
+ months = seconds // (30 * 86400)
86
+ return f"{months}mo ago" if months < 12 else f"{months // 12}y ago"
87
+
88
+
89
+ COLUMNS = [
90
+ ("PID", 8, pid_label),
91
+ ("AGENT", 18, agent_label),
92
+ ("SESSION", 16, lambda r: r["name"] or "-"),
93
+ ("STATUS", 7, status_label),
94
+ ("MODEL", 20, model_label),
95
+ ("UPTIME", 8, lambda r: human_duration(r["uptime"]) if r["pid"] else "-"),
96
+ ("ACTIVE", 10, idle_label),
97
+ ("CPU", 6, lambda r: f"{r['cpu']:.1f}%" if r["pid"] else "-"),
98
+ ("MEM", 7, lambda r: human_bytes(r["rss"], unit_kb=True) if r["pid"] else "-"),
99
+ ("DISK", 8, lambda r: human_bytes(r["disk"]) if r.get("disk") else "-"),
100
+ ("DIR", 24, lambda r: short_path(r.get("cwd", ""), 23)),
101
+ ("TITLE", 0, lambda r: r["title"] or "-"),
102
+ ]
103
+
104
+
105
+ def column_span(label):
106
+ """Where a column starts and how wide it is, derived from COLUMNS itself.
107
+
108
+ Computed rather than written down, so changing a width in one place does not
109
+ quietly misalign anything that paints over a cell.
110
+ """
111
+ start = 0
112
+ for name, size, _ in COLUMNS:
113
+ if name == label:
114
+ return start, size
115
+ start += size
116
+ return 0, 0
117
+
118
+
119
+ #: Cycled with `s`. The first keeps the natural order: agents in registry order,
120
+ #: running sessions before ended ones. The rest sort everything together, since
121
+ #: the question they answer is which session is the largest or the busiest, and
122
+ #: that does not care whether its process is still alive.
123
+ def _text(field):
124
+ """Sort text A to Z, with the blanks pushed to the end either way."""
125
+ return lambda row: (not row[field], row[field].lower())
126
+
127
+
128
+ #: Each is a name, the column it marks, the value to sort on, and whether the
129
+ #: useful end comes first. Numbers read largest first, which is what makes the
130
+ #: question worth asking; names read A to Z.
131
+ SORTS = (
132
+ ("agent", None, None, True),
133
+ ("active", "ACTIVE", lambda r: r["last_active"] or 0, True),
134
+ ("disk", "DISK", lambda r: r.get("disk", 0), True),
135
+ ("cpu", "CPU", lambda r: r["cpu"], True),
136
+ ("mem", "MEM", lambda r: r["rss"], True),
137
+ ("uptime", "UPTIME", lambda r: r["uptime"], True),
138
+ ("session", "SESSION", _text("name"), False),
139
+ ("title", "TITLE", _text("title"), False),
140
+ )
141
+
142
+
143
+ def format_row(row, width):
144
+ cells = []
145
+ used = 0
146
+ for _, size, getter in COLUMNS:
147
+ value = getter(row)
148
+ if size == 0:
149
+ cells.append(value[:max(0, width - used - 1)])
150
+ else:
151
+ cells.append(f"{value[: size - 1]:<{size}}")
152
+ used += size
153
+ return "".join(cells)[:width]
154
+
155
+
156
+ def format_header(width):
157
+ parts = [label if size == 0 else f"{label[: size - 1]:<{size}}"
158
+ for label, size, _ in COLUMNS]
159
+ return "".join(parts)[:width]
160
+
161
+
162
+ def print_table(rows):
163
+ width = 220
164
+ print(format_header(width).rstrip())
165
+ for row in rows:
166
+ print(format_row(row, width).rstrip())
167
+
168
+
169
+ def _wrap(text, width):
170
+ words, lines, current = text.split(), [], ""
171
+ for word in words:
172
+ if current and len(current) + 1 + len(word) > width:
173
+ lines.append(current)
174
+ current = word
175
+ else:
176
+ current = f"{current} {word}" if current else word
177
+ lines.append(current)
178
+ return lines or [""]
179
+
180
+
181
+ def matches_filter(row, needle):
182
+ return (needle in row["name"].lower()
183
+ or needle in row["title"].lower()
184
+ or needle in row["agent"].lower()
185
+ or needle in model_label(row).lower()
186
+ or needle in row.get("cwd", "").lower()
187
+ or needle in str(row["pid"]))
188
+
189
+
190
+ class Tui:
191
+ C_HEADER = 1
192
+ C_BUSY = 2
193
+ C_BACKGROUND = 3
194
+ C_SELECTED = 4
195
+ C_WARNING = 5
196
+ C_SORT = 6
197
+ C_AGENT_BASE = 7
198
+
199
+ def __init__(self, screen, snapshot):
200
+ self.screen = screen
201
+ self.snapshot = snapshot
202
+ self.rows = []
203
+ self.cursor = 0
204
+ self.message = ""
205
+ self.message_at = 0.0
206
+ self.paused = False
207
+ self.show_ended = False
208
+ self.filter_text = ""
209
+ self.editing_filter = False
210
+ self.pending_kill = None
211
+ self.detail = None
212
+ self.sort = 0
213
+ self.descending = True
214
+ self.last_poll = 0.0
215
+ self.history = {"bytes": 0, "ended": 0, "recent": 0}
216
+
217
+ def setup(self):
218
+ curses.curs_set(0)
219
+ self.agent_colour = {}
220
+ if not curses.has_colors():
221
+ self.screen.timeout(200)
222
+ return
223
+ curses.start_color()
224
+ curses.use_default_colors()
225
+ curses.init_pair(self.C_HEADER, curses.COLOR_BLACK, curses.COLOR_WHITE)
226
+ curses.init_pair(self.C_BUSY, curses.COLOR_GREEN, -1)
227
+ curses.init_pair(self.C_BACKGROUND, curses.COLOR_BLUE, -1)
228
+ curses.init_pair(self.C_SELECTED, curses.COLOR_BLACK, curses.COLOR_CYAN)
229
+ curses.init_pair(self.C_WARNING, curses.COLOR_YELLOW, -1)
230
+ # the heading row is black on white, so the sorted column needs its own
231
+ # background. Cyan rather than yellow, because a light terminal theme
232
+ # renders a yellow background close enough to white to disappear.
233
+ curses.init_pair(self.C_SORT, curses.COLOR_BLACK, curses.COLOR_CYAN)
234
+ for index, name in enumerate(backends.names()):
235
+ pair = self.C_AGENT_BASE + index
236
+ curses.init_pair(pair, AGENT_COLOURS[index % len(AGENT_COLOURS)], -1)
237
+ self.agent_colour[name] = curses.color_pair(pair)
238
+ self.screen.timeout(200)
239
+
240
+ def poll(self):
241
+ # the same window already loaded to pair processes with sessions, so
242
+ # showing all of it costs nothing and makes sorting by size honest
243
+ rows = self.snapshot.rows(show_ended=self.show_ended, limit=ATTACH_WINDOW)
244
+ if self.filter_text:
245
+ needle = self.filter_text.lower()
246
+ rows = [r for r in rows if matches_filter(r, needle)]
247
+ key = SORTS[self.sort][2]
248
+ if key:
249
+ rows = sorted(rows, key=key, reverse=self.descending)
250
+ self.rows = rows
251
+ self.cursor = max(0, min(self.cursor, len(self.rows) - 1))
252
+ self.last_poll = time.time()
253
+ self.history = self.snapshot.history()
254
+
255
+ def selected(self):
256
+ return self.rows[self.cursor] if self.rows else None
257
+
258
+ def notify(self, text):
259
+ """Show a note under the key bar for a few seconds.
260
+
261
+ These report what an action just did, so they stop being useful the
262
+ moment attention moves on, and one left behind reads as the outcome of
263
+ whatever was done next.
264
+ """
265
+ self.message = text
266
+ self.message_at = time.time()
267
+
268
+ def draw(self):
269
+ self.screen.erase()
270
+ height, width = self.screen.getmaxyx()
271
+ self.draw_summary(width)
272
+ if self.detail and not self.pending_kill:
273
+ self.draw_detail(height, width)
274
+ self.screen.noutrefresh()
275
+ curses.doupdate()
276
+ return
277
+
278
+ self.screen.attron(curses.color_pair(self.C_HEADER))
279
+ self.screen.addnstr(2, 0, format_header(width).ljust(width - 1), width - 1)
280
+ self.screen.attroff(curses.color_pair(self.C_HEADER))
281
+ self.mark_sorted_column(width)
282
+
283
+ body = max(1, height - 5)
284
+ first = max(0, self.cursor - body + 1) if self.cursor >= body else 0
285
+ for index, row in enumerate(self.rows[first: first + body]):
286
+ line = format_row(row, width - 1)
287
+ if first + index == self.cursor:
288
+ attr = curses.color_pair(self.C_SELECTED)
289
+ line = line.ljust(width - 1)
290
+ elif row["kind"] == KIND_ENDED:
291
+ attr = curses.A_DIM
292
+ elif row["background"]:
293
+ attr = curses.color_pair(self.C_BACKGROUND)
294
+ elif row["status"] == STATUS_BUSY:
295
+ attr = curses.color_pair(self.C_BUSY)
296
+ else:
297
+ attr = curses.A_NORMAL
298
+ screen_row = 3 + index
299
+ self.screen.addnstr(screen_row, 0, line, width - 1, attr)
300
+ # the cursor has to stay unmistakable, so a selected row keeps its
301
+ # own colour rather than being broken up by the agent's
302
+ if first + index != self.cursor:
303
+ self.paint_agent(screen_row, row, width, attr)
304
+
305
+ if not self.rows:
306
+ self.screen.addnstr(4, 2, "No coding agent sessions running.", width - 3)
307
+
308
+ self.draw_footer(height, width)
309
+ self.screen.noutrefresh()
310
+ curses.doupdate()
311
+
312
+ def paint_agent(self, screen_row, row, width, base):
313
+ """Repaint the agent cell in that agent's colour.
314
+
315
+ With five agents on screen the name is read constantly, and a colour is
316
+ quicker to recognise than a word. Only this cell is coloured; the rest of
317
+ the row still carries what the row is doing.
318
+ """
319
+ colour = self.agent_colour.get(row["agent"])
320
+ if not colour:
321
+ return
322
+ start, size = column_span("AGENT")
323
+ if start >= width - 1:
324
+ return
325
+ text = agent_label(row)[: size - 1].ljust(size)
326
+ self.screen.addnstr(screen_row, start, text, min(size, width - 1 - start),
327
+ colour | (base & curses.A_DIM) | curses.A_BOLD)
328
+
329
+ def mark_sorted_column(self, width):
330
+ """Show which column is sorting, on the column itself.
331
+
332
+ A word in the summary line says the same thing, but the eye reading a
333
+ table looks at the table, so the marker belongs in the heading it
334
+ describes.
335
+ """
336
+ label = SORTS[self.sort][1]
337
+ if not label:
338
+ return
339
+ start, size = column_span(label)
340
+ if start >= width - 1:
341
+ return
342
+ arrow = "v" if self.descending else "^"
343
+ # the last column has no fixed width, so it is marked to its own length
344
+ cell = size or len(label) + 1
345
+ text = f"{label}{arrow}"[:cell].ljust(cell)
346
+ self.screen.addnstr(2, start, text, min(cell, width - 1 - start),
347
+ curses.color_pair(self.C_SORT) | curses.A_BOLD)
348
+
349
+ def draw_detail(self, height, width):
350
+ """Everything known about one session, on one screen."""
351
+ row = self.detail
352
+ backend = self.snapshot.find_backend(row["agent"])
353
+ lines = [
354
+ ("agent", agent_label(row)),
355
+ ("session", row["session_id"] or "not matched to a session"),
356
+ ("status", status_label(row)),
357
+ ("model", row["model"] or "-"),
358
+ ("directory", row["cwd"] or "-"),
359
+ ("title", row["title"] or "-"),
360
+ ("pid", f"{row['pid']} (parent {row['ppid']})"),
361
+ ("uptime", f"{human_duration(row['uptime'])} last turn {idle_label(row)}"),
362
+ ("usage", f"cpu {row['cpu']:.1f}% memory "
363
+ f"{human_bytes(row['rss'], unit_kb=True)} disk "
364
+ f"{human_bytes(row['disk']) if row['disk'] else '-'}"),
365
+ ]
366
+ if row["attach"] == ATTACH_INFERRED:
367
+ lines.append(("paired", "matched by working directory, not reported"))
368
+ if backend and row["session_id"]:
369
+ lines.extend(backend.details(row))
370
+ lines.append(("command", row["cmd"] or "-"))
371
+
372
+ self.screen.attron(curses.color_pair(self.C_HEADER))
373
+ self.screen.addnstr(2, 0, " session details".ljust(width - 1), width - 1)
374
+ self.screen.attroff(curses.color_pair(self.C_HEADER))
375
+
376
+ screen_row = 4
377
+ for label, value in lines:
378
+ if screen_row >= height - 2:
379
+ break
380
+ self.screen.addnstr(screen_row, 2, f"{label:>10}", 10, curses.A_DIM)
381
+ # the command line is the one field that runs long, so it wraps
382
+ # instead of being cut off where it stops being useful
383
+ for chunk in _wrap(str(value), max(10, width - 16)):
384
+ if screen_row >= height - 2:
385
+ break
386
+ self.screen.addnstr(screen_row, 14, chunk, width - 15)
387
+ screen_row += 1
388
+ keys = " enter or esc close"
389
+ if row["pid"]:
390
+ keys += " k stop this process"
391
+ self.screen.addnstr(height - 1, 0, keys.ljust(width - 1), width - 1,
392
+ curses.color_pair(self.C_HEADER))
393
+
394
+ def draw_summary(self, width):
395
+ """Two lines: what is running, then what it is costing."""
396
+ live = [r for r in self.rows if is_live(r)]
397
+ busy = sum(1 for r in live if r["status"] == STATUS_BUSY)
398
+ background = sum(1 for r in live if r["background"])
399
+ sessions = len(live) - background
400
+
401
+ parts = [f"{sessions} session" + ("s" if sessions != 1 else "")]
402
+ if busy:
403
+ parts.append(f"{busy} busy")
404
+ if background:
405
+ parts.append(f"{background} background")
406
+
407
+ state = "PAUSED" if self.paused else "LIVE"
408
+ if self.show_ended:
409
+ state = "ALL " + state
410
+ if self.filter_text:
411
+ state = f"/{self.filter_text} " + state
412
+
413
+ self.screen.attron(curses.A_BOLD)
414
+ self.screen.addnstr(0, 1, "agent-ps", width - 2)
415
+ self.screen.attroff(curses.A_BOLD)
416
+ self.screen.addnstr(0, 10, f" {' '.join(parts)} {state}", width - 11)
417
+
418
+ disk = sum(r.get("disk", 0) for r in self.rows)
419
+ stale = max(0, self.history["bytes"] - disk)
420
+ line = (f" cpu {sum(r['cpu'] for r in live):.1f}%"
421
+ f" mem {human_bytes(sum(r['rss'] for r in live), unit_kb=True)}"
422
+ f" disk {human_bytes(disk)} active"
423
+ f" history {self.history['ended']} sessions, {human_bytes(stale)}")
424
+ self.screen.addnstr(1, 0, line, width - 1, curses.A_DIM)
425
+
426
+ note = self.advisory()
427
+ if note:
428
+ column = min(len(line) + 3, width - 2)
429
+ self.screen.addnstr(1, column, note, max(0, width - column - 1),
430
+ curses.color_pair(self.C_WARNING))
431
+
432
+ def advisory(self):
433
+ """One short note when something on screen deserves attention.
434
+
435
+ Only the most useful is shown. A wall of warnings trains people to ignore
436
+ the line entirely.
437
+ """
438
+ # an empty table because a command would not run is a different thing
439
+ # from an empty table, and saying so first matters more than anything
440
+ # else this line could report
441
+ if FAILURES:
442
+ return f"could not run {', '.join(sorted(FAILURES))}; rows may be missing"
443
+
444
+ orphans = [r for r in self.rows if r["background"] and r["uptime"] > 3600]
445
+ if orphans:
446
+ return f"{len(orphans)} background helper(s) over an hour old, press b"
447
+
448
+ stale = [r for r in self.rows if r["pid"] and idle_seconds(r) > 86400]
449
+ if stale:
450
+ return f"{len(stale)} session(s) untouched for over a day, still holding memory"
451
+
452
+ recent = self.history["recent"]
453
+ if recent and not self.show_ended:
454
+ return f"{recent} session(s) ended in the past week, press e to show them"
455
+ return ""
456
+
457
+ def draw_footer(self, height, width):
458
+ if self.editing_filter:
459
+ prompt = f" filter: {self.filter_text}"
460
+ self.screen.addnstr(height - 1, 0, prompt.ljust(width - 1), width - 1,
461
+ curses.color_pair(self.C_SELECTED))
462
+ return
463
+ if self.pending_kill:
464
+ _, order, note = self.pending_kill
465
+ text = f" Stop {len(order)} process(es){note}? [y] confirm [n] cancel"
466
+ self.screen.addnstr(height - 1, 0, text.ljust(width - 1), width - 1,
467
+ curses.color_pair(self.C_WARNING))
468
+ return
469
+ # the note and the legend share a line: a note is worth interrupting the
470
+ # legend for, and it is gone again in a few seconds
471
+ if self.message and time.time() - self.message_at < MESSAGE_SECONDS:
472
+ self.screen.addnstr(height - 2, 0, f" {self.message}", width - 1,
473
+ curses.color_pair(self.C_WARNING))
474
+ else:
475
+ self.draw_legend(height - 2, width)
476
+ mode = "hide ended" if self.show_ended else "show ended"
477
+ keys = (" up/down select enter details k stop b background"
478
+ f" e {mode} s sort S reverse / filter q quit")
479
+ self.screen.addnstr(height - 1, 0, keys.ljust(width - 1), width - 1,
480
+ curses.color_pair(self.C_HEADER))
481
+
482
+ def draw_legend(self, screen_row, width):
483
+ """Which agents are on screen, each in its own colour.
484
+
485
+ It sits above the keys rather than in the header, next to the column it
486
+ explains being less useful than being where the eye already goes.
487
+ """
488
+ agents = sorted({r["agent"] for r in self.rows})
489
+ if not agents:
490
+ return
491
+ text = " agents " + " ".join(agents)
492
+ self.screen.addnstr(screen_row, 0, text, width - 1, curses.A_DIM)
493
+
494
+ # a long agent list leaves no room for a sentence on an 80 column
495
+ # terminal, and dropping the note entirely is worse than abbreviating it
496
+ name, column, _, _ = SORTS[self.sort]
497
+ arrow = "v" if self.descending else "^"
498
+ wordy = ("sorted by agent, running first" if not column
499
+ else f"sorted by {name}, "
500
+ f"{'high to low' if self.descending else 'low to high'}")
501
+ for note in (wordy, f"sort: {name} {arrow}" if column else "sort: agent"):
502
+ at = width - len(note) - 2
503
+ if at > len(text) + 3:
504
+ self.screen.addnstr(screen_row, at, note, len(note),
505
+ curses.color_pair(self.C_SORT) | curses.A_BOLD)
506
+ break
507
+
508
+ for agent in agents:
509
+ at = text.find(agent)
510
+ if at < 0 or at >= width - 1:
511
+ continue
512
+ self.screen.addnstr(screen_row, at, agent, min(len(agent), width - 1 - at),
513
+ self.agent_colour.get(agent, 0) | curses.A_BOLD)
514
+
515
+ def confirm_kill(self, row):
516
+ """Ask before stopping, and say where the process is.
517
+
518
+ A PID matched by working directory could be the wrong session, so the
519
+ directory is part of the question rather than something to check
520
+ afterwards.
521
+ """
522
+ order = collect_tree(row["pid"], children_of(self.rows))
523
+ note = f" in {short_path(row['cwd'], 40)}" if row["cwd"] else ""
524
+ if row["attach"] == ATTACH_INFERRED:
525
+ note += ", session matched by directory"
526
+ self.pending_kill = (row["pid"], order, note)
527
+
528
+ def open_selected(self):
529
+ """Enter does the obvious thing for the row it is on.
530
+
531
+ A session that has ended is one you would want back, so it reopens. One
532
+ that is running is already open, so the useful answer is what it is doing
533
+ and what it has spent.
534
+ """
535
+ row = self.selected()
536
+ if not row:
537
+ return
538
+ if is_live(row):
539
+ self.detail = row
540
+ return
541
+ self.resume_selected(row)
542
+
543
+ def resume_selected(self, row):
544
+ if not row["session_id"]:
545
+ self.notify("No session id for that row.")
546
+ return
547
+ backend = self.snapshot.find_backend(row["agent"])
548
+ if not backend or not backend.resume_binary:
549
+ self.notify(f"{row['agent']} sessions cannot be reopened from a terminal.")
550
+ return
551
+ command = resume_command(backend, row["session_id"], row.get("cwd", ""))
552
+ ok, note = open_in_terminal(command)
553
+ self.notify(note if ok else f"{note} Run: {command}")
554
+
555
+ def apply_kill(self):
556
+ _, order, _ = self.pending_kill
557
+ self.pending_kill = None
558
+ self.detail = None
559
+ stopped = [p for p in order if terminate(p)]
560
+ self.notify(f"Stopped {len(stopped)} of {len(order)} processes.")
561
+ self.poll()
562
+
563
+ def handle(self, key):
564
+ if self.editing_filter:
565
+ return self.handle_filter(key)
566
+
567
+ # moving around means the last result has been read, so drop the note
568
+ # early rather than leaving it to time out under a different row
569
+ if key in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_HOME, curses.KEY_END,
570
+ ord("j"), ord("K")):
571
+ self.message = ""
572
+
573
+ if self.detail and not self.pending_kill:
574
+ if key == ord("k") and self.detail["pid"]:
575
+ self.confirm_kill(self.detail)
576
+ elif key in (10, 13, curses.KEY_ENTER, 27, ord("q")):
577
+ self.detail = None
578
+ return True
579
+
580
+ if self.pending_kill:
581
+ if key in (ord("y"), ord("Y")):
582
+ self.apply_kill()
583
+ elif key in (ord("n"), ord("N"), 27):
584
+ self.pending_kill = None
585
+ self.notify("Cancelled.")
586
+ return True
587
+
588
+ if key in (ord("q"), ord("Q")):
589
+ return False
590
+ if key in (10, 13, curses.KEY_ENTER):
591
+ self.open_selected()
592
+ elif key in (curses.KEY_DOWN, ord("j")):
593
+ self.cursor = min(self.cursor + 1, max(0, len(self.rows) - 1))
594
+ elif key in (curses.KEY_UP, ord("K")):
595
+ self.cursor = max(0, self.cursor - 1)
596
+ elif key == curses.KEY_HOME:
597
+ self.cursor = 0
598
+ elif key == curses.KEY_END:
599
+ self.cursor = max(0, len(self.rows) - 1)
600
+ elif key == ord(" "):
601
+ self.paused = not self.paused
602
+ elif key == ord("s"):
603
+ self.sort = (self.sort + 1) % len(SORTS)
604
+ self.descending = SORTS[self.sort][3]
605
+ self.cursor = 0
606
+ self.poll()
607
+ elif key == ord("S"):
608
+ self.descending = not self.descending
609
+ self.cursor = 0
610
+ self.poll()
611
+ elif key in (ord("r"), ord("R")):
612
+ self.poll()
613
+ self.message = ""
614
+ elif key in (ord("e"), ord("E")):
615
+ self.show_ended = not self.show_ended
616
+ self.cursor = 0
617
+ self.snapshot.history(force=True)
618
+ self.poll()
619
+ elif key == ord("/"):
620
+ self.editing_filter = True
621
+ elif key == ord("k"):
622
+ row = self.selected()
623
+ if row and row["pid"]:
624
+ self.confirm_kill(row)
625
+ elif row and is_live(row):
626
+ self.notify(f"{row['agent']} has no process of its own to stop.")
627
+ elif row:
628
+ self.notify("That session has already ended.")
629
+ elif key in (ord("b"), ord("B")):
630
+ background = [r["pid"] for r in self.rows if r["background"]]
631
+ if background:
632
+ self.pending_kill = (background[0], background, "")
633
+ else:
634
+ self.notify("No background processes.")
635
+ return True
636
+
637
+ def handle_filter(self, key):
638
+ if key in (10, 13, curses.KEY_ENTER):
639
+ self.editing_filter = False
640
+ self.poll()
641
+ elif key == 27:
642
+ self.editing_filter = False
643
+ self.filter_text = ""
644
+ self.poll()
645
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
646
+ # backspacing past the last character leaves filter mode, so the key
647
+ # hints come back without needing escape
648
+ if not self.filter_text:
649
+ self.editing_filter = False
650
+ else:
651
+ self.filter_text = self.filter_text[:-1]
652
+ if not self.filter_text:
653
+ self.editing_filter = False
654
+ self.poll()
655
+ elif 32 <= key < 127:
656
+ self.filter_text += chr(key)
657
+ self.poll()
658
+ return True
659
+
660
+ def loop(self):
661
+ self.setup()
662
+ self.poll()
663
+ while True:
664
+ self.draw()
665
+ key = self.screen.getch()
666
+ if key != -1 and not self.handle(key):
667
+ break
668
+ if not self.paused and not self.pending_kill:
669
+ if time.time() - self.last_poll >= REFRESH_SECONDS:
670
+ self.poll()