procclean 1.3.0__tar.gz → 2.0.0__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 (24) hide show
  1. procclean-2.0.0/LICENSE +21 -0
  2. {procclean-1.3.0 → procclean-2.0.0}/PKG-INFO +65 -9
  3. {procclean-1.3.0 → procclean-2.0.0}/README.md +61 -7
  4. {procclean-1.3.0 → procclean-2.0.0}/pyproject.toml +4 -2
  5. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/cli/docs.py +22 -14
  6. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/tui/app.py +105 -30
  7. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/__init__.py +0 -0
  8. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/__main__.py +0 -0
  9. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/cli/__init__.py +0 -0
  10. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/cli/commands.py +0 -0
  11. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/cli/parser.py +0 -0
  12. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/__init__.py +0 -0
  13. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/actions.py +0 -0
  14. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/constants.py +0 -0
  15. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/filters.py +0 -0
  16. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/memory.py +0 -0
  17. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/models.py +0 -0
  18. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/core/process.py +0 -0
  19. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/formatters/__init__.py +0 -0
  20. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/formatters/columns.py +0 -0
  21. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/formatters/output.py +0 -0
  22. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/tui/__init__.py +0 -0
  23. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/tui/app.tcss +0 -0
  24. {procclean-1.3.0 → procclean-2.0.0}/src/procclean/tui/screens.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaj Kowalski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: procclean
3
- Version: 1.3.0
3
+ Version: 2.0.0
4
4
  Summary: Interactive TUI for exploring and cleaning up processes - find orphans, memory hogs, and kill them
5
5
  Keywords: cleanup,kill,memory,orphan,process,terminal,tui
6
6
  Author: Kaj Kowalski
7
7
  Author-email: Kaj Kowalski <info@kajkowalski.nl>
8
8
  License-Expression: MIT
9
- Classifier: Development Status :: 4 - Beta
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
10
11
  Classifier: Environment :: Console
11
12
  Classifier: Intended Audience :: Developers
12
13
  Classifier: Intended Audience :: System Administrators
@@ -22,6 +23,7 @@ Requires-Dist: tabulate>=0.9.0
22
23
  Requires-Dist: textual>=7.0.0
23
24
  Requires-Python: >=3.14
24
25
  Project-URL: Homepage, https://procclean.kjanat.com
26
+ Project-URL: Issues, https://github.com/kjanat/procclean/issues
25
27
  Project-URL: Repository, https://github.com/kjanat/procclean
26
28
  Description-Content-Type: text/markdown
27
29
 
@@ -35,6 +37,8 @@ Description-Content-Type: text/markdown
35
37
 
36
38
  <p align="center">
37
39
  <a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/v/procclean" alt="PyPI"></a>
40
+ <a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/dm/procclean" alt="Downloads"></a>
41
+ <a href="https://github.com/kjanat/procclean/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/kjanat/procclean/ci.yml?branch=master" alt="CI"></a>
38
42
  <a href="https://github.com/kjanat/procclean/blob/master/LICENSE"><img src="https://img.shields.io/github/license/kjanat/procclean" alt="License"></a>
39
43
  <a href="https://procclean.kjanat.com"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
40
44
  <img src="https://img.shields.io/badge/python-3.14%2B-blue" alt="Python 3.14+">
@@ -44,13 +48,18 @@ Description-Content-Type: text/markdown
44
48
  ## Features
45
49
 
46
50
  - **Memory overview** - Real-time total/used/free/swap display
47
- - **Multiple views** - All processes, Orphaned, Process Groups, High Memory
48
- (>500MB)
51
+ - **Multiple views** - All, Orphaned, Killable, Process Groups, High Memory
49
52
  - **Orphan detection** - Finds processes whose parent died (PPID=1)
53
+ - **Killable detection** - Orphans safe to kill (not tmux, not system services)
54
+ - **Stale detection** - Flags processes with deleted executables
50
55
  - **Tmux awareness** - Won't flag tmux processes as orphan candidates
51
56
  - **Batch operations** - Select multiple processes and kill them at once
52
57
  - **Process grouping** - Find duplicate/similar processes consuming resources
58
+ - **Custom columns** - Select which columns to display in CLI output
59
+ - **Configurable thresholds** - Adjust memory filters via CLI flags
60
+ - **Preview mode** - Dry-run for kill operations with formatting options
53
61
  - **CLI mode** - Scriptable commands with JSON/CSV/Markdown output
62
+ - **Clickable TUI** - Click headers to sort, rows to select
54
63
 
55
64
  ## Installation
56
65
 
@@ -58,16 +67,24 @@ Description-Content-Type: text/markdown
58
67
  pip install procclean
59
68
  ```
60
69
 
61
- Or with [uv](https://docs.astral.sh/uv/):
70
+ Or with [uv](https://docs.astral.sh/uv/) (recommended):
62
71
 
63
72
  ```bash
64
73
  uv tool install procclean
65
74
  ```
66
75
 
76
+ Or with [pipx](https://pipx.pypa.io/):
77
+
78
+ ```bash
79
+ pipx install procclean
80
+ ```
81
+
67
82
  Run without installing:
68
83
 
69
84
  ```bash
70
85
  uvx procclean
86
+ # or
87
+ pipx run procclean
71
88
  ```
72
89
 
73
90
  ## Usage
@@ -81,24 +98,40 @@ procclean
81
98
  ### CLI Commands
82
99
 
83
100
  ```bash
101
+ # List processes
84
102
  procclean list # List processes (table)
103
+ procclean ls # Alias for 'list'
85
104
  procclean list -f json|csv|md # Different output formats
86
105
  procclean list -s mem|cpu|pid|name|cwd # Sort by field
106
+ procclean list -a # Sort ascending (default: descending)
87
107
  procclean list -o # Orphans only
88
- procclean list -m # High memory only
108
+ procclean list -m # High memory only (>500MB)
89
109
  procclean list -k # Killable orphans only
90
110
  procclean list --cwd # Filter by current directory
91
111
  procclean list --cwd /path/to/dir # Filter by specific cwd
112
+ procclean list -n 20 # Limit output to 20 processes
113
+ procclean list -c pid,name,rss_mb # Custom columns
114
+ procclean list --min-memory 10 # Only processes using >10 MB
115
+ procclean list --high-memory-threshold 1000 # High-mem at 1000 MB
92
116
 
117
+ # Process groups
93
118
  procclean groups # Show process groups
119
+ procclean g # Alias for 'groups'
120
+ procclean groups -f json # Groups as JSON
94
121
 
122
+ # Kill processes
95
123
  procclean kill <PID> [PID...] # Kill process(es)
96
124
  procclean kill -f <PID> # Force kill (SIGKILL)
97
125
  procclean kill --cwd /path -y # Kill all in cwd (skip confirm)
98
126
  procclean kill -k -y # Kill all killable orphans
99
127
  procclean kill -k --preview # Preview what would be killed
128
+ procclean kill -k --dry-run # Alias for --preview
129
+ procclean kill -k --preview -O json # Preview in JSON format
100
130
 
131
+ # Memory summary
101
132
  procclean mem # Show memory summary
133
+ procclean memory # Full name for 'mem'
134
+ procclean mem -f json # Memory info as JSON
102
135
  ```
103
136
 
104
137
  ## TUI Keybindings
@@ -110,6 +143,7 @@ procclean mem # Show memory summary
110
143
  | `k` | Kill selected (SIGTERM) |
111
144
  | `K` | Force kill (SIGKILL) |
112
145
  | `o` | Show orphans |
146
+ | `O` | Show killable |
113
147
  | `a` | Show all |
114
148
  | `g` | Show groups |
115
149
  | `w` | Filter by selected cwd |
@@ -124,12 +158,15 @@ procclean mem # Show memory summary
124
158
  | `5` | Sort by cwd |
125
159
  | `!` | Reverse sort order |
126
160
 
161
+ Click column headers to sort, click rows to toggle selection.
162
+
127
163
  ## Views
128
164
 
129
165
  - **All Processes** - All user processes sorted by memory usage
130
166
  - **Orphaned** - Processes with PPID=1 (parent died)
167
+ - **Killable** - Orphans safe to kill (not in tmux, not system services)
131
168
  - **Process Groups** - Similar processes grouped together
132
- - **High Memory** - Processes using >500MB RAM
169
+ - **High Memory** - Processes using >500MB RAM (configurable)
133
170
 
134
171
  ## Output Formats
135
172
 
@@ -140,6 +177,17 @@ CLI supports multiple output formats via `-f`:
140
177
  - `csv` - CSV for spreadsheets
141
178
  - `md` - Markdown table
142
179
 
180
+ ## Custom Columns
181
+
182
+ Use `-c` to specify which columns to display:
183
+
184
+ ```bash
185
+ procclean list -c pid,name,rss_mb,cwd
186
+ ```
187
+
188
+ Available columns: `pid`, `name`, `rss_mb`, `cpu_percent`, `cwd`, `ppid`,
189
+ `parent_name`, `status`, `cmdline`, `username`
190
+
143
191
  ## Requirements
144
192
 
145
193
  - Python 3.14+
@@ -151,13 +199,21 @@ CLI supports multiple output formats via `-f`:
151
199
  git clone https://github.com/kjanat/procclean
152
200
  cd procclean
153
201
  uv sync
154
- uv run pre-commit install
202
+ uv run pre-commit install --install-hooks
155
203
  ```
156
204
 
157
205
  Run tests:
158
206
 
159
207
  ```bash
160
- uv run pytest
208
+ uv run pytest # All tests
209
+ uv run pytest --cov -vv # With coverage
210
+ ```
211
+
212
+ Lint and type check:
213
+
214
+ ```bash
215
+ uv run ruff check src/
216
+ uv run ty check
161
217
  ```
162
218
 
163
219
  ## License
@@ -8,6 +8,8 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/v/procclean" alt="PyPI"></a>
11
+ <a href="https://pypi.org/project/procclean/"><img src="https://img.shields.io/pypi/dm/procclean" alt="Downloads"></a>
12
+ <a href="https://github.com/kjanat/procclean/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/kjanat/procclean/ci.yml?branch=master" alt="CI"></a>
11
13
  <a href="https://github.com/kjanat/procclean/blob/master/LICENSE"><img src="https://img.shields.io/github/license/kjanat/procclean" alt="License"></a>
12
14
  <a href="https://procclean.kjanat.com"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
13
15
  <img src="https://img.shields.io/badge/python-3.14%2B-blue" alt="Python 3.14+">
@@ -17,13 +19,18 @@
17
19
  ## Features
18
20
 
19
21
  - **Memory overview** - Real-time total/used/free/swap display
20
- - **Multiple views** - All processes, Orphaned, Process Groups, High Memory
21
- (>500MB)
22
+ - **Multiple views** - All, Orphaned, Killable, Process Groups, High Memory
22
23
  - **Orphan detection** - Finds processes whose parent died (PPID=1)
24
+ - **Killable detection** - Orphans safe to kill (not tmux, not system services)
25
+ - **Stale detection** - Flags processes with deleted executables
23
26
  - **Tmux awareness** - Won't flag tmux processes as orphan candidates
24
27
  - **Batch operations** - Select multiple processes and kill them at once
25
28
  - **Process grouping** - Find duplicate/similar processes consuming resources
29
+ - **Custom columns** - Select which columns to display in CLI output
30
+ - **Configurable thresholds** - Adjust memory filters via CLI flags
31
+ - **Preview mode** - Dry-run for kill operations with formatting options
26
32
  - **CLI mode** - Scriptable commands with JSON/CSV/Markdown output
33
+ - **Clickable TUI** - Click headers to sort, rows to select
27
34
 
28
35
  ## Installation
29
36
 
@@ -31,16 +38,24 @@
31
38
  pip install procclean
32
39
  ```
33
40
 
34
- Or with [uv](https://docs.astral.sh/uv/):
41
+ Or with [uv](https://docs.astral.sh/uv/) (recommended):
35
42
 
36
43
  ```bash
37
44
  uv tool install procclean
38
45
  ```
39
46
 
47
+ Or with [pipx](https://pipx.pypa.io/):
48
+
49
+ ```bash
50
+ pipx install procclean
51
+ ```
52
+
40
53
  Run without installing:
41
54
 
42
55
  ```bash
43
56
  uvx procclean
57
+ # or
58
+ pipx run procclean
44
59
  ```
45
60
 
46
61
  ## Usage
@@ -54,24 +69,40 @@ procclean
54
69
  ### CLI Commands
55
70
 
56
71
  ```bash
72
+ # List processes
57
73
  procclean list # List processes (table)
74
+ procclean ls # Alias for 'list'
58
75
  procclean list -f json|csv|md # Different output formats
59
76
  procclean list -s mem|cpu|pid|name|cwd # Sort by field
77
+ procclean list -a # Sort ascending (default: descending)
60
78
  procclean list -o # Orphans only
61
- procclean list -m # High memory only
79
+ procclean list -m # High memory only (>500MB)
62
80
  procclean list -k # Killable orphans only
63
81
  procclean list --cwd # Filter by current directory
64
82
  procclean list --cwd /path/to/dir # Filter by specific cwd
83
+ procclean list -n 20 # Limit output to 20 processes
84
+ procclean list -c pid,name,rss_mb # Custom columns
85
+ procclean list --min-memory 10 # Only processes using >10 MB
86
+ procclean list --high-memory-threshold 1000 # High-mem at 1000 MB
65
87
 
88
+ # Process groups
66
89
  procclean groups # Show process groups
90
+ procclean g # Alias for 'groups'
91
+ procclean groups -f json # Groups as JSON
67
92
 
93
+ # Kill processes
68
94
  procclean kill <PID> [PID...] # Kill process(es)
69
95
  procclean kill -f <PID> # Force kill (SIGKILL)
70
96
  procclean kill --cwd /path -y # Kill all in cwd (skip confirm)
71
97
  procclean kill -k -y # Kill all killable orphans
72
98
  procclean kill -k --preview # Preview what would be killed
99
+ procclean kill -k --dry-run # Alias for --preview
100
+ procclean kill -k --preview -O json # Preview in JSON format
73
101
 
102
+ # Memory summary
74
103
  procclean mem # Show memory summary
104
+ procclean memory # Full name for 'mem'
105
+ procclean mem -f json # Memory info as JSON
75
106
  ```
76
107
 
77
108
  ## TUI Keybindings
@@ -83,6 +114,7 @@ procclean mem # Show memory summary
83
114
  | `k` | Kill selected (SIGTERM) |
84
115
  | `K` | Force kill (SIGKILL) |
85
116
  | `o` | Show orphans |
117
+ | `O` | Show killable |
86
118
  | `a` | Show all |
87
119
  | `g` | Show groups |
88
120
  | `w` | Filter by selected cwd |
@@ -97,12 +129,15 @@ procclean mem # Show memory summary
97
129
  | `5` | Sort by cwd |
98
130
  | `!` | Reverse sort order |
99
131
 
132
+ Click column headers to sort, click rows to toggle selection.
133
+
100
134
  ## Views
101
135
 
102
136
  - **All Processes** - All user processes sorted by memory usage
103
137
  - **Orphaned** - Processes with PPID=1 (parent died)
138
+ - **Killable** - Orphans safe to kill (not in tmux, not system services)
104
139
  - **Process Groups** - Similar processes grouped together
105
- - **High Memory** - Processes using >500MB RAM
140
+ - **High Memory** - Processes using >500MB RAM (configurable)
106
141
 
107
142
  ## Output Formats
108
143
 
@@ -113,6 +148,17 @@ CLI supports multiple output formats via `-f`:
113
148
  - `csv` - CSV for spreadsheets
114
149
  - `md` - Markdown table
115
150
 
151
+ ## Custom Columns
152
+
153
+ Use `-c` to specify which columns to display:
154
+
155
+ ```bash
156
+ procclean list -c pid,name,rss_mb,cwd
157
+ ```
158
+
159
+ Available columns: `pid`, `name`, `rss_mb`, `cpu_percent`, `cwd`, `ppid`,
160
+ `parent_name`, `status`, `cmdline`, `username`
161
+
116
162
  ## Requirements
117
163
 
118
164
  - Python 3.14+
@@ -124,13 +170,21 @@ CLI supports multiple output formats via `-f`:
124
170
  git clone https://github.com/kjanat/procclean
125
171
  cd procclean
126
172
  uv sync
127
- uv run pre-commit install
173
+ uv run pre-commit install --install-hooks
128
174
  ```
129
175
 
130
176
  Run tests:
131
177
 
132
178
  ```bash
133
- uv run pytest
179
+ uv run pytest # All tests
180
+ uv run pytest --cov -vv # With coverage
181
+ ```
182
+
183
+ Lint and type check:
184
+
185
+ ```bash
186
+ uv run ruff check src/
187
+ uv run ty check
134
188
  ```
135
189
 
136
190
  ## License
@@ -3,15 +3,16 @@
3
3
 
4
4
  [project]
5
5
  name = "procclean"
6
- version = "1.3.0"
6
+ version = "2.0.0"
7
7
  description = "Interactive TUI for exploring and cleaning up processes - find orphans, memory hogs, and kill them"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.14"
10
10
  license = "MIT"
11
+ license-files = ["LICENSE"]
11
12
  authors = [{ name = "Kaj Kowalski", email = "info@kajkowalski.nl" }]
12
13
  keywords = ["cleanup", "kill", "memory", "orphan", "process", "terminal", "tui"]
13
14
  classifiers = [
14
- "Development Status :: 4 - Beta",
15
+ "Development Status :: 5 - Production/Stable",
15
16
  "Environment :: Console",
16
17
  "Intended Audience :: Developers",
17
18
  "Intended Audience :: System Administrators",
@@ -31,6 +32,7 @@ dependencies = [
31
32
 
32
33
  [project.urls]
33
34
  Homepage = "https://procclean.kjanat.com"
35
+ Issues = "https://github.com/kjanat/procclean/issues"
34
36
  Repository = "https://github.com/kjanat/procclean"
35
37
 
36
38
  [project.scripts]
@@ -4,9 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import io
7
- import os
8
7
  import re
9
8
  from collections import defaultdict
9
+ from functools import partial
10
10
  from pathlib import Path
11
11
 
12
12
  from markdown_it import MarkdownIt
@@ -52,23 +52,31 @@ def _capture_help(parser: argparse.ArgumentParser) -> str:
52
52
  Returns:
53
53
  HTML string with colored help output.
54
54
  """
55
- # Force colored output even when not in a TTY
56
- old_force_color = os.environ.get("FORCE_COLOR")
57
- os.environ["FORCE_COLOR"] = "1"
55
+ # Create a truecolor console for both formatting and export
56
+ # - color_system="truecolor" ensures hex colors like #98f641 aren't downgraded
57
+ # - width=80 ensures consistent text wrapping across environments
58
+ truecolor_console = Console(
59
+ file=io.StringIO(),
60
+ record=True,
61
+ force_terminal=True,
62
+ color_system="truecolor",
63
+ width=80,
64
+ )
65
+
66
+ # Save original formatter_class and restore after use
67
+ orig_formatter_class = parser.formatter_class
58
68
  try:
59
- parser.formatter_class = RichHelpFormatter
60
- text = Text.from_ansi(parser.format_help())
69
+ # Pass the truecolor console to RichHelpFormatter via partial
70
+ parser.formatter_class = partial(RichHelpFormatter, console=truecolor_console)
71
+ help_text = parser.format_help()
61
72
  finally:
62
- if old_force_color is None:
63
- os.environ.pop("FORCE_COLOR", None)
64
- else:
65
- os.environ["FORCE_COLOR"] = old_force_color
73
+ parser.formatter_class = orig_formatter_class
66
74
 
67
- # force_terminal=True ensures colored output even when not in a TTY
68
- console = Console(file=io.StringIO(), record=True, force_terminal=True)
69
- console.print(text, crop=False)
75
+ # Parse ANSI back to Rich Text and re-render for HTML export
76
+ text = Text.from_ansi(help_text)
77
+ truecolor_console.print(text, crop=False)
70
78
 
71
- return console.export_html(code_format=_CODE_FORMAT, inline_styles=True)
79
+ return truecolor_console.export_html(code_format=_CODE_FORMAT, inline_styles=True)
72
80
 
73
81
 
74
82
  def _argparser_to_markdown(
@@ -6,6 +6,7 @@ from textual import on, work
6
6
  from textual.app import App, ComposeResult
7
7
  from textual.binding import Binding
8
8
  from textual.containers import Horizontal, Vertical
9
+ from textual.coordinate import Coordinate
9
10
  from textual.reactive import reactive
10
11
  from textual.widgets import (
11
12
  DataTable,
@@ -15,6 +16,7 @@ from textual.widgets import (
15
16
  OptionList,
16
17
  Static,
17
18
  )
19
+ from textual.widgets.data_table import RowDoesNotExist
18
20
  from textual.widgets.option_list import Option
19
21
 
20
22
  from procclean.core import (
@@ -32,7 +34,7 @@ from procclean.core import (
32
34
  from .screens import ConfirmKillScreen
33
35
 
34
36
  # Type aliases
35
- ViewType = Literal["all", "orphans", "stale", "groups", "high-mem"]
37
+ ViewType = Literal["all", "orphans", "killable", "groups", "high-mem"]
36
38
  SortKey = Literal["memory", "cpu", "pid", "name", "cwd"]
37
39
 
38
40
 
@@ -53,7 +55,7 @@ class ProcessCleanerApp(App):
53
55
  Binding("k", "kill_selected", "Kill"),
54
56
  Binding("K", "force_kill_selected", "Force Kill"),
55
57
  Binding("o", "show_orphans", "Orphans"),
56
- Binding("t", "show_stale", "Stale"),
58
+ Binding("O", "show_killable", "Killable"),
57
59
  Binding("a", "show_all", "All"),
58
60
  Binding("g", "show_groups", "Groups"),
59
61
  Binding("w", "filter_cwd", "Filter CWD"),
@@ -94,7 +96,7 @@ class ProcessCleanerApp(App):
94
96
  yield OptionList(
95
97
  Option("All Processes", id="view-all"),
96
98
  Option("Orphaned", id="view-orphans"),
97
- Option("Stale (Updated)", id="view-stale"),
99
+ Option("Killable", id="view-killable"),
98
100
  Option("Process Groups", id="view-groups"),
99
101
  Option("High Memory (>500MB)", id="view-high-mem"),
100
102
  id="view-selector",
@@ -179,30 +181,50 @@ class ProcessCleanerApp(App):
179
181
  key_func = sort_keys.get(self.sort_key, sort_keys["memory"])
180
182
  return sorted(procs, key=key_func, reverse=self.sort_reverse)
181
183
 
184
+ def _filter_by_view(self) -> list[ProcessInfo]:
185
+ """Filter processes based on current view.
186
+
187
+ Returns:
188
+ Filtered list of processes for the current view.
189
+ """
190
+ if self.current_view == "orphans":
191
+ return [p for p in self.processes if p.is_orphan]
192
+ if self.current_view == "killable":
193
+ return [p for p in self.processes if p.is_orphan_candidate]
194
+ if self.current_view == "high-mem":
195
+ return [p for p in self.processes if p.rss_mb > HIGH_MEMORY_THRESHOLD_MB]
196
+ if self.current_view == "groups":
197
+ groups = find_similar_processes(self.processes)
198
+ return [p for group in groups.values() for p in group]
199
+ return list(self.processes)
200
+
201
+ @staticmethod
202
+ def _restore_cursor(table: DataTable, cursor_pid: int | None) -> None:
203
+ """Restore cursor to the row with the given PID.
204
+
205
+ Args:
206
+ table: The DataTable to restore cursor in.
207
+ cursor_pid: The PID to restore cursor to, or None to skip.
208
+ """
209
+ if cursor_pid is None:
210
+ return
211
+ try:
212
+ row_idx = table.get_row_index(str(cursor_pid))
213
+ except RowDoesNotExist:
214
+ if table.row_count:
215
+ table.move_cursor(row=0)
216
+ return
217
+ table.move_cursor(row=row_idx)
218
+
182
219
  def update_table(self) -> None:
183
220
  """Update the process table based on current view and sort."""
184
221
  table = self.query_one("#process-table", DataTable)
222
+ cursor_pid = self._get_pid_at_cursor()
185
223
  table.clear()
186
224
 
187
- if self.current_view == "orphans":
188
- procs = [p for p in self.processes if p.is_orphan]
189
- elif self.current_view == "stale":
190
- procs = [p for p in self.processes if p.exe_deleted]
191
- elif self.current_view == "high-mem":
192
- procs = [p for p in self.processes if p.rss_mb > HIGH_MEMORY_THRESHOLD_MB]
193
- elif self.current_view == "groups":
194
- groups = find_similar_processes(self.processes)
195
- procs = []
196
- for group_procs in groups.values():
197
- procs.extend(group_procs)
198
- else:
199
- procs = self.processes
200
-
201
- # Apply cwd filter
225
+ procs = self._filter_by_view()
202
226
  if self.cwd_filter:
203
227
  procs = filter_by_cwd(procs, self.cwd_filter)
204
-
205
- # Apply sorting
206
228
  procs = self._sort_processes(procs)
207
229
 
208
230
  for proc in procs:
@@ -229,6 +251,7 @@ class ProcessCleanerApp(App):
229
251
  key=str(proc.pid),
230
252
  )
231
253
 
254
+ self._restore_cursor(table, cursor_pid)
232
255
  self.update_status()
233
256
 
234
257
  def update_status(self) -> None:
@@ -245,13 +268,55 @@ class ProcessCleanerApp(App):
245
268
  view_map: dict[str, ViewType] = {
246
269
  "view-all": "all",
247
270
  "view-orphans": "orphans",
248
- "view-stale": "stale",
271
+ "view-killable": "killable",
249
272
  "view-groups": "groups",
250
273
  "view-high-mem": "high-mem",
251
274
  }
252
275
  if event.option.id and event.option.id in view_map:
253
276
  self.current_view = view_map[event.option.id]
254
277
 
278
+ @on(DataTable.RowSelected, "#process-table")
279
+ def on_row_clicked(self, event: DataTable.RowSelected) -> None:
280
+ """Toggle selection when a row is clicked."""
281
+ # Get PID from the row data (column 1 is PID)
282
+ # Guard against race: auto-refresh can remove rows mid-flight
283
+ try:
284
+ row_data = event.data_table.get_row(event.row_key)
285
+ pid = int(row_data[1])
286
+ except RowDoesNotExist:
287
+ return
288
+
289
+ # Toggle selection
290
+ if pid in self.selected_pids:
291
+ self.selected_pids.remove(pid)
292
+ new_value = "[ ]"
293
+ else:
294
+ self.selected_pids.add(pid)
295
+ new_value = "[X]"
296
+
297
+ # Update the clicked row's selection cell using row_key (not cursor_row)
298
+ selection_column_key = event.data_table.ordered_columns[0].key
299
+ event.data_table.update_cell(event.row_key, selection_column_key, new_value)
300
+ self.update_status()
301
+
302
+ @on(DataTable.HeaderSelected, "#process-table")
303
+ def on_header_clicked(self, event: DataTable.HeaderSelected) -> None:
304
+ """Sort by column when header is clicked."""
305
+ # Map column index to sort key.
306
+ # Sortable: PID(1), Name(2), RAM(3), CPU(4), CWD(5)
307
+ # Not sortable (no-op): Selection(0), PPID(6), Parent(7), Status(8)
308
+ # NOTE: Indexes must be updated if column order changes in update_table()
309
+ column_sort_map: dict[int, SortKey] = {
310
+ 1: "pid",
311
+ 2: "name",
312
+ 3: "memory",
313
+ 4: "cpu",
314
+ 5: "cwd",
315
+ }
316
+ col_idx = event.column_index
317
+ if col_idx in column_sort_map:
318
+ self._set_sort(column_sort_map[col_idx])
319
+
255
320
  def action_refresh(self) -> None:
256
321
  """Refresh process data."""
257
322
  self.refresh_data()
@@ -262,14 +327,14 @@ class ProcessCleanerApp(App):
262
327
 
263
328
  Returns:
264
329
  The PID at the current cursor position, or ``None`` if there is no
265
- current row selected.
330
+ current row selected or the table is empty.
266
331
  """
267
332
  table = self.query_one("#process-table", DataTable)
268
- if table.cursor_row is None:
333
+ if table.cursor_row is None or table.row_count == 0:
269
334
  return None
270
- row_key = table.get_row_at(table.cursor_row)
271
- # row_key is a tuple of cell values: (selected, pid, name, ...)
272
- return int(row_key[1])
335
+ row_data = table.get_row_at(table.cursor_row)
336
+ # row_data is a list of cell values: [selected, pid, name, ...]
337
+ return int(row_data[1])
273
338
 
274
339
  def _get_process_at_cursor(self) -> ProcessInfo | None:
275
340
  """Get the ProcessInfo at the current cursor position.
@@ -286,13 +351,23 @@ class ProcessCleanerApp(App):
286
351
 
287
352
  def action_toggle_select(self) -> None:
288
353
  """Toggle selection of current row."""
354
+ table = self.query_one("#process-table", DataTable)
355
+ if table.cursor_row is None:
356
+ return
357
+
289
358
  pid = self._get_pid_at_cursor()
290
359
  if pid is not None:
360
+ # Toggle selection
291
361
  if pid in self.selected_pids:
292
362
  self.selected_pids.remove(pid)
363
+ new_value = "[ ]"
293
364
  else:
294
365
  self.selected_pids.add(pid)
295
- self.update_table()
366
+ new_value = "[X]"
367
+
368
+ # Update just the selection cell, not the entire table
369
+ table.update_cell_at(Coordinate(table.cursor_row, 0), new_value)
370
+ self.update_status()
296
371
 
297
372
  def action_select_all_visible(self) -> None:
298
373
  """Select all visible processes."""
@@ -312,9 +387,9 @@ class ProcessCleanerApp(App):
312
387
  """Switch to orphans view."""
313
388
  self.current_view = "orphans"
314
389
 
315
- def action_show_stale(self) -> None:
316
- """Switch to stale processes view (deleted/updated executables)."""
317
- self.current_view = "stale"
390
+ def action_show_killable(self) -> None:
391
+ """Switch to killable orphans view (orphans not in tmux)."""
392
+ self.current_view = "killable"
318
393
 
319
394
  def action_show_all(self) -> None:
320
395
  """Switch to all processes view."""