procclean 1.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.
procclean/tui/app.py ADDED
@@ -0,0 +1,401 @@
1
+ """Main TUI application."""
2
+
3
+ from typing import ClassVar, Literal
4
+
5
+ from textual import on, work
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.reactive import reactive
10
+ from textual.widgets import (
11
+ DataTable,
12
+ Footer,
13
+ Header,
14
+ Label,
15
+ OptionList,
16
+ Static,
17
+ )
18
+ from textual.widgets.option_list import Option
19
+
20
+ from procclean.core import (
21
+ CWD_MAX_WIDTH,
22
+ CWD_TRUNCATE_WIDTH,
23
+ HIGH_MEMORY_THRESHOLD_MB,
24
+ ProcessInfo,
25
+ filter_by_cwd,
26
+ find_similar_processes,
27
+ get_memory_summary,
28
+ get_process_list,
29
+ kill_processes,
30
+ )
31
+
32
+ from .screens import ConfirmKillScreen
33
+
34
+ # Type aliases
35
+ ViewType = Literal["all", "orphans", "groups", "high-mem"]
36
+ SortKey = Literal["memory", "cpu", "pid", "name", "cwd"]
37
+
38
+
39
+ class ProcessCleanerApp(App):
40
+ """TUI for exploring and cleaning up processes."""
41
+
42
+ CSS_PATH = "app.tcss"
43
+
44
+ # Reactive state - watchers auto-trigger UI updates
45
+ current_view = reactive[ViewType]("all")
46
+ sort_key = reactive[SortKey]("memory")
47
+ sort_reverse = reactive(True)
48
+ cwd_filter = reactive[str | None](None)
49
+
50
+ BINDINGS: ClassVar = [
51
+ Binding("q", "quit", "Quit"),
52
+ Binding("r", "refresh", "Refresh"),
53
+ Binding("k", "kill_selected", "Kill"),
54
+ Binding("K", "force_kill_selected", "Force Kill"),
55
+ Binding("o", "show_orphans", "Orphans"),
56
+ Binding("a", "show_all", "All"),
57
+ Binding("g", "show_groups", "Groups"),
58
+ Binding("w", "filter_cwd", "Filter CWD"),
59
+ Binding("W", "clear_cwd_filter", "Clear CWD"),
60
+ Binding("space", "toggle_select", "Select"),
61
+ Binding("s", "select_all_visible", "Select All"),
62
+ Binding("c", "clear_selection", "Clear"),
63
+ # Sorting bindings
64
+ Binding("1", "sort_memory", "Sort:Mem"),
65
+ Binding("2", "sort_cpu", "Sort:CPU"),
66
+ Binding("3", "sort_pid", "Sort:PID"),
67
+ Binding("4", "sort_name", "Sort:Name"),
68
+ Binding("5", "sort_cwd", "Sort:CWD"),
69
+ Binding("!", "toggle_sort_order", "Reverse"),
70
+ ]
71
+
72
+ def __init__(self) -> None:
73
+ """Initialize the TUI application."""
74
+ super().__init__()
75
+ self.processes: list[ProcessInfo] = []
76
+ self.selected_pids: set[int] = set()
77
+
78
+ def compose(self) -> ComposeResult: # noqa: PLR6301
79
+ """Build the TUI layout.
80
+
81
+ Yields:
82
+ ComposeResult: Widgets that form the application layout.
83
+ """
84
+ yield Header()
85
+ with Horizontal(id="memory-bar"):
86
+ yield Static("", id="mem-total")
87
+ yield Static("", id="mem-used")
88
+ yield Static("", id="mem-free")
89
+ yield Static("", id="swap")
90
+ with Horizontal(id="main-container"):
91
+ with Vertical(id="sidebar"):
92
+ yield Label("Views", id="sidebar-title")
93
+ yield OptionList(
94
+ Option("All Processes", id="view-all"),
95
+ Option("Orphaned", id="view-orphans"),
96
+ Option("Process Groups", id="view-groups"),
97
+ Option("High Memory (>500MB)", id="view-high-mem"),
98
+ id="view-selector",
99
+ )
100
+ with Vertical(id="content"):
101
+ yield DataTable(id="process-table")
102
+ yield Static("", id="status-bar")
103
+ yield Footer()
104
+
105
+ def on_mount(self) -> None:
106
+ """Initialize app after mounting."""
107
+ self.title = "ProcClean"
108
+ self.sub_title = "Process Cleanup Tool"
109
+
110
+ table = self.query_one("#process-table", DataTable)
111
+ table.cursor_type = "row"
112
+ table.add_columns(
113
+ "", "PID", "Name", "RAM (MB)", "CPU%", "CWD", "PPID", "Parent", "Status"
114
+ )
115
+
116
+ self.refresh_data()
117
+ # Auto-refresh every 5 seconds
118
+ self.set_interval(5.0, self.refresh_data)
119
+
120
+ # Reactive watchers - auto-update table when state changes
121
+ def watch_current_view(self) -> None:
122
+ """Update table when view changes."""
123
+ self.update_table()
124
+
125
+ def watch_sort_key(self) -> None:
126
+ """Update table when sort key changes."""
127
+ self.update_table()
128
+
129
+ def watch_sort_reverse(self) -> None:
130
+ """Update table when sort order changes."""
131
+ self.update_table()
132
+
133
+ def watch_cwd_filter(self) -> None:
134
+ """Update table when cwd filter changes."""
135
+ self.update_table()
136
+
137
+ def refresh_data(self) -> None:
138
+ """Trigger async refresh of process list and memory info."""
139
+ self._fetch_data()
140
+
141
+ @work(thread=True)
142
+ def _fetch_data(self) -> None:
143
+ """Fetch process data in background thread."""
144
+ mem = get_memory_summary()
145
+ procs = get_process_list(min_memory_mb=5.0)
146
+ self.call_from_thread(self._update_data, mem, procs)
147
+
148
+ def _update_data(self, mem: dict[str, float], procs: list[ProcessInfo]) -> None:
149
+ """Update UI with fetched data (called from main thread)."""
150
+ self.query_one("#mem-total", Static).update(f"Total: {mem['total_gb']:.1f}G")
151
+ self.query_one("#mem-used", Static).update(
152
+ f"Used: {mem['used_gb']:.1f}G ({mem['percent']:.0f}%)"
153
+ )
154
+ self.query_one("#mem-free", Static).update(f"Free: {mem['free_gb']:.1f}G")
155
+ self.query_one("#swap", Static).update(
156
+ f"Swap: {mem['swap_used_gb']:.1f}G/{mem['swap_total_gb']:.1f}G"
157
+ )
158
+ self.processes = procs
159
+ self.update_table()
160
+
161
+ def _sort_processes(self, procs: list[ProcessInfo]) -> list[ProcessInfo]:
162
+ """Sort processes by current sort key and order.
163
+
164
+ Args:
165
+ procs: The processes to sort.
166
+
167
+ Returns:
168
+ A new list of processes sorted according to the current sort settings.
169
+ """
170
+ sort_keys = {
171
+ "memory": lambda p: p.rss_mb,
172
+ "cpu": lambda p: p.cpu_percent,
173
+ "pid": lambda p: p.pid,
174
+ "name": lambda p: p.name.lower(),
175
+ "cwd": lambda p: (p.cwd or "").lower(),
176
+ }
177
+ key_func = sort_keys.get(self.sort_key, sort_keys["memory"])
178
+ return sorted(procs, key=key_func, reverse=self.sort_reverse)
179
+
180
+ def update_table(self) -> None:
181
+ """Update the process table based on current view and sort."""
182
+ table = self.query_one("#process-table", DataTable)
183
+ table.clear()
184
+
185
+ if self.current_view == "orphans":
186
+ procs = [p for p in self.processes if p.is_orphan]
187
+ elif self.current_view == "high-mem":
188
+ procs = [p for p in self.processes if p.rss_mb > HIGH_MEMORY_THRESHOLD_MB]
189
+ elif self.current_view == "groups":
190
+ groups = find_similar_processes(self.processes)
191
+ procs = []
192
+ for group_procs in groups.values():
193
+ procs.extend(group_procs)
194
+ else:
195
+ procs = self.processes
196
+
197
+ # Apply cwd filter
198
+ if self.cwd_filter:
199
+ procs = filter_by_cwd(procs, self.cwd_filter)
200
+
201
+ # Apply sorting
202
+ procs = self._sort_processes(procs)
203
+
204
+ for proc in procs:
205
+ selected = "[X]" if proc.pid in self.selected_pids else "[ ]"
206
+ orphan_marker = " [orphan]" if proc.is_orphan else ""
207
+ tmux_marker = " [tmux]" if proc.in_tmux else ""
208
+ status = f"{proc.status}{orphan_marker}{tmux_marker}"
209
+
210
+ cwd = proc.cwd or "?"
211
+ if len(cwd) > CWD_MAX_WIDTH:
212
+ cwd = "..." + cwd[-CWD_TRUNCATE_WIDTH:]
213
+
214
+ table.add_row(
215
+ selected,
216
+ str(proc.pid),
217
+ proc.name[:20],
218
+ f"{proc.rss_mb:.1f}",
219
+ f"{proc.cpu_percent:.1f}",
220
+ cwd,
221
+ str(proc.ppid),
222
+ proc.parent_name[:15],
223
+ status,
224
+ key=str(proc.pid),
225
+ )
226
+
227
+ self.update_status()
228
+
229
+ def update_status(self) -> None:
230
+ """Update status bar with selection info."""
231
+ selected_mb = sum(
232
+ p.rss_mb for p in self.processes if p.pid in self.selected_pids
233
+ )
234
+ msg = f"Selected: {len(self.selected_pids)} processes ({selected_mb:.1f} MB)"
235
+ self.query_one("#status-bar", Static).update(msg)
236
+
237
+ @on(OptionList.OptionSelected, "#view-selector")
238
+ def on_view_change(self, event: OptionList.OptionSelected) -> None:
239
+ """Handle view selection changes."""
240
+ view_map: dict[str, ViewType] = {
241
+ "view-all": "all",
242
+ "view-orphans": "orphans",
243
+ "view-groups": "groups",
244
+ "view-high-mem": "high-mem",
245
+ }
246
+ if event.option.id and event.option.id in view_map:
247
+ self.current_view = view_map[event.option.id]
248
+
249
+ def action_refresh(self) -> None:
250
+ """Refresh process data."""
251
+ self.refresh_data()
252
+ self.notify("Refreshed")
253
+
254
+ def _get_pid_at_cursor(self) -> int | None:
255
+ """Get the PID of the process at the current cursor position.
256
+
257
+ Returns:
258
+ The PID at the current cursor position, or ``None`` if there is no
259
+ current row selected.
260
+ """
261
+ table = self.query_one("#process-table", DataTable)
262
+ if table.cursor_row is None:
263
+ return None
264
+ row_key = table.get_row_at(table.cursor_row)
265
+ # row_key is a tuple of cell values: (selected, pid, name, ...)
266
+ return int(row_key[1])
267
+
268
+ def _get_process_at_cursor(self) -> ProcessInfo | None:
269
+ """Get the ProcessInfo at the current cursor position.
270
+
271
+ Returns:
272
+ The ``ProcessInfo`` for the process at the current cursor position,
273
+ or ``None`` if there is no current row selected or the PID cannot be
274
+ resolved to a process in the current list.
275
+ """
276
+ pid = self._get_pid_at_cursor()
277
+ if pid is None:
278
+ return None
279
+ return next((p for p in self.processes if p.pid == pid), None)
280
+
281
+ def action_toggle_select(self) -> None:
282
+ """Toggle selection of current row."""
283
+ pid = self._get_pid_at_cursor()
284
+ if pid is not None:
285
+ if pid in self.selected_pids:
286
+ self.selected_pids.remove(pid)
287
+ else:
288
+ self.selected_pids.add(pid)
289
+ self.update_table()
290
+
291
+ def action_select_all_visible(self) -> None:
292
+ """Select all visible processes."""
293
+ table = self.query_one("#process-table", DataTable)
294
+ for row_idx in range(table.row_count):
295
+ row = table.get_row_at(row_idx)
296
+ pid = int(row[1])
297
+ self.selected_pids.add(pid)
298
+ self.update_table()
299
+
300
+ def action_clear_selection(self) -> None:
301
+ """Clear all selections."""
302
+ self.selected_pids.clear()
303
+ self.update_table()
304
+
305
+ def action_show_orphans(self) -> None:
306
+ """Switch to orphans view."""
307
+ self.current_view = "orphans"
308
+
309
+ def action_show_all(self) -> None:
310
+ """Switch to all processes view."""
311
+ self.current_view = "all"
312
+
313
+ def action_show_groups(self) -> None:
314
+ """Switch to process groups view."""
315
+ self.current_view = "groups"
316
+
317
+ def _set_sort(self, key: SortKey) -> None:
318
+ """Set sort key and update table."""
319
+ if self.sort_key == key:
320
+ # Same key, toggle order
321
+ self.sort_reverse = not self.sort_reverse
322
+ else:
323
+ self.sort_key = key
324
+ # Default order: descending for numeric, ascending for name
325
+ self.sort_reverse = key != "name"
326
+ order = "desc" if self.sort_reverse else "asc"
327
+ self.notify(f"Sort: {key} ({order})")
328
+
329
+ def action_sort_memory(self) -> None:
330
+ """Sort the table by resident memory usage."""
331
+ self._set_sort("memory")
332
+
333
+ def action_sort_cpu(self) -> None:
334
+ """Sort the table by CPU usage percentage."""
335
+ self._set_sort("cpu")
336
+
337
+ def action_sort_pid(self) -> None:
338
+ """Sort the table by PID."""
339
+ self._set_sort("pid")
340
+
341
+ def action_sort_name(self) -> None:
342
+ """Sort the table by process name."""
343
+ self._set_sort("name")
344
+
345
+ def action_sort_cwd(self) -> None:
346
+ """Sort the table by current working directory."""
347
+ self._set_sort("cwd")
348
+
349
+ def action_toggle_sort_order(self) -> None:
350
+ """Toggle the current sort order (ascending/descending)."""
351
+ self.sort_reverse = not self.sort_reverse
352
+ order = "desc" if self.sort_reverse else "asc"
353
+ self.notify(f"Sort: {self.sort_key} ({order})")
354
+
355
+ def action_filter_cwd(self) -> None:
356
+ """Filter by cwd of currently selected row."""
357
+ proc = self._get_process_at_cursor()
358
+ if proc and proc.cwd and proc.cwd != "?":
359
+ self.cwd_filter = proc.cwd
360
+ self.notify(f"Filter: cwd={self.cwd_filter}")
361
+ else:
362
+ self.notify("Cannot filter: unknown cwd", severity="warning")
363
+
364
+ def action_clear_cwd_filter(self) -> None:
365
+ """Clear the cwd filter."""
366
+ self.cwd_filter = None
367
+ self.notify("CWD filter cleared")
368
+
369
+ def _do_kill(self, force: bool = False) -> None:
370
+ if not self.selected_pids:
371
+ self.notify("No processes selected", severity="warning")
372
+ return
373
+
374
+ procs = [p for p in self.processes if p.pid in self.selected_pids]
375
+
376
+ def handle_confirm(confirmed: bool | None) -> None:
377
+ if confirmed:
378
+ self._execute_kill(list(self.selected_pids), force)
379
+
380
+ self.push_screen(ConfirmKillScreen(procs, force=force), handle_confirm)
381
+
382
+ @work(thread=True)
383
+ def _execute_kill(self, pids: list[int], force: bool) -> None:
384
+ """Execute kill in background thread."""
385
+ results = kill_processes(pids, force=force)
386
+ success = sum(1 for _, ok, _ in results if ok)
387
+ self.call_from_thread(self._on_kill_complete, success, len(results))
388
+
389
+ def _on_kill_complete(self, success: int, total: int) -> None:
390
+ """Handle kill completion (called from main thread)."""
391
+ self.notify(f"Killed {success}/{total} processes")
392
+ self.selected_pids.clear()
393
+ self.refresh_data()
394
+
395
+ def action_kill_selected(self) -> None:
396
+ """Send SIGTERM to all selected processes (after confirmation)."""
397
+ self._do_kill(force=False)
398
+
399
+ def action_force_kill_selected(self) -> None:
400
+ """Send SIGKILL to all selected processes (after confirmation)."""
401
+ self._do_kill(force=True)
procclean/tui/app.tcss ADDED
@@ -0,0 +1,87 @@
1
+ /* ProcClean TUI Styles */
2
+
3
+ #confirm-dialog {
4
+ width: 60;
5
+ height: auto;
6
+ border: thick $primary;
7
+ background: $surface;
8
+ padding: 1 2;
9
+ }
10
+
11
+ #confirm-title {
12
+ text-style: bold;
13
+ width: 100%;
14
+ content-align: center middle;
15
+ margin-bottom: 1;
16
+ }
17
+
18
+ #confirm-subtitle {
19
+ color: $text-muted;
20
+ width: 100%;
21
+ content-align: center middle;
22
+ margin-bottom: 1;
23
+ }
24
+
25
+ #process-list-container {
26
+ height: auto;
27
+ max-height: 15;
28
+ margin-bottom: 1;
29
+ }
30
+
31
+ #confirm-buttons {
32
+ width: 100%;
33
+ height: 3;
34
+ align: center middle;
35
+ }
36
+
37
+ #confirm-buttons Button {
38
+ margin: 0 1;
39
+ }
40
+
41
+ #memory-bar {
42
+ height: 3;
43
+ padding: 0 1;
44
+ background: $surface;
45
+ border-bottom: solid $primary;
46
+ }
47
+
48
+ #memory-bar Static {
49
+ width: auto;
50
+ margin-right: 2;
51
+ }
52
+
53
+ #main-container {
54
+ height: 1fr;
55
+ }
56
+
57
+ #sidebar {
58
+ width: 30;
59
+ border-right: solid $primary;
60
+ padding: 1;
61
+ }
62
+
63
+ #sidebar-title {
64
+ text-style: bold;
65
+ margin-bottom: 1;
66
+ }
67
+
68
+ #content {
69
+ width: 1fr;
70
+ padding: 1;
71
+ }
72
+
73
+ #status-bar {
74
+ height: 1;
75
+ background: $primary;
76
+ color: $text;
77
+ padding: 0 1;
78
+ }
79
+
80
+ DataTable {
81
+ height: 1fr;
82
+ }
83
+
84
+ .selected-count {
85
+ color: $warning;
86
+ text-style: bold;
87
+ }
@@ -0,0 +1,79 @@
1
+ """TUI modal screens."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Container, Horizontal, Vertical
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Button, Label
11
+
12
+ from procclean.core import CONFIRM_PREVIEW_LIMIT, ProcessInfo
13
+
14
+
15
+ class ConfirmKillScreen(ModalScreen[bool]):
16
+ """Modal screen to confirm killing processes."""
17
+
18
+ BINDINGS: ClassVar = [
19
+ Binding("y", "confirm", "Yes"),
20
+ Binding("n", "cancel", "No"),
21
+ Binding("escape", "cancel", "Cancel"),
22
+ ]
23
+
24
+ def __init__(self, processes: list[ProcessInfo], force: bool = False) -> None:
25
+ """Initialize the confirmation screen.
26
+
27
+ Args:
28
+ processes: Processes that may be killed if confirmed.
29
+ force: Whether the operation is a force kill.
30
+ """
31
+ super().__init__()
32
+ self.processes = processes
33
+ self.force = force
34
+
35
+ def compose(self) -> ComposeResult:
36
+ """Compose child widgets for the confirmation dialog.
37
+
38
+ This method is called by Textual when the screen is mounted or recomposed.
39
+ It builds the dialog UI, including a preview list of processes and
40
+ confirmation buttons.
41
+
42
+ Yields:
43
+ Child widgets that make up the confirmation dialog.
44
+ """
45
+ total_mb = sum(p.rss_mb for p in self.processes)
46
+ action = "FORCE KILL" if self.force else "Kill"
47
+
48
+ with Container(id="confirm-dialog"):
49
+ yield Label(
50
+ f"{action} {len(self.processes)} process(es)?", id="confirm-title"
51
+ )
52
+ yield Label(f"Will free ~{total_mb:.1f} MB", id="confirm-subtitle")
53
+ with Vertical(id="process-list-container"):
54
+ for proc in self.processes[:CONFIRM_PREVIEW_LIMIT]:
55
+ yield Label(f" {proc.pid}: {proc.name} ({proc.rss_mb:.1f} MB)")
56
+ if len(self.processes) > CONFIRM_PREVIEW_LIMIT:
57
+ remaining = len(self.processes) - CONFIRM_PREVIEW_LIMIT
58
+ yield Label(f" ... and {remaining} more")
59
+ with Horizontal(id="confirm-buttons"):
60
+ yield Button("Yes (y)", id="yes", variant="error")
61
+ yield Button("No (n)", id="no", variant="primary")
62
+
63
+ def action_confirm(self) -> None:
64
+ """Confirm killing the selected processes."""
65
+ self.dismiss(True)
66
+
67
+ def action_cancel(self) -> None:
68
+ """Cancel process killing."""
69
+ self.dismiss(False)
70
+
71
+ @on(Button.Pressed, "#yes")
72
+ def on_yes(self) -> None:
73
+ """Handle the Yes button being pressed."""
74
+ self.dismiss(True)
75
+
76
+ @on(Button.Pressed, "#no")
77
+ def on_no(self) -> None:
78
+ """Handle the No button being pressed."""
79
+ self.dismiss(False)