procclean 1.2.1__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.2.1 → procclean-2.0.0}/PKG-INFO +65 -9
  3. {procclean-1.2.1 → procclean-2.0.0}/README.md +61 -7
  4. {procclean-1.2.1 → procclean-2.0.0}/pyproject.toml +5 -2
  5. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/docs.py +22 -14
  6. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/__init__.py +10 -1
  7. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/filters.py +15 -0
  8. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/models.py +1 -0
  9. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/process.py +29 -5
  10. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/app.py +108 -23
  11. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/__init__.py +0 -0
  12. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/__main__.py +0 -0
  13. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/__init__.py +0 -0
  14. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/commands.py +0 -0
  15. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/cli/parser.py +0 -0
  16. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/actions.py +0 -0
  17. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/constants.py +0 -0
  18. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/core/memory.py +0 -0
  19. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/formatters/__init__.py +0 -0
  20. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/formatters/columns.py +0 -0
  21. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/formatters/output.py +0 -0
  22. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/__init__.py +0 -0
  23. {procclean-1.2.1 → procclean-2.0.0}/src/procclean/tui/app.tcss +0 -0
  24. {procclean-1.2.1 → 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.2.1
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.2.1"
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]
@@ -41,6 +43,7 @@ dev = [
41
43
  "pre-commit>=4.5.1",
42
44
  ]
43
45
  lint = [
46
+ "dprint-py>=0.51.1.0",
44
47
  "pylint>=4.0.4",
45
48
  "ruff>=0.14.10",
46
49
  "tombi>=0.7.14",
@@ -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(
@@ -15,12 +15,19 @@ from .filters import (
15
15
  filter_high_memory,
16
16
  filter_killable,
17
17
  filter_orphans,
18
+ filter_stale,
18
19
  is_system_service,
19
20
  sort_processes,
20
21
  )
21
22
  from .memory import get_memory_summary
22
23
  from .models import ProcessInfo
23
- from .process import find_similar_processes, get_cwd, get_process_list, get_tmux_env
24
+ from .process import (
25
+ find_similar_processes,
26
+ get_cwd,
27
+ get_process_list,
28
+ get_tmux_env,
29
+ is_exe_deleted,
30
+ )
24
31
 
25
32
  __all__ = [
26
33
  "CONFIRM_PREVIEW_LIMIT",
@@ -35,11 +42,13 @@ __all__ = [
35
42
  "filter_high_memory",
36
43
  "filter_killable",
37
44
  "filter_orphans",
45
+ "filter_stale",
38
46
  "find_similar_processes",
39
47
  "get_cwd",
40
48
  "get_memory_summary",
41
49
  "get_process_list",
42
50
  "get_tmux_env",
51
+ "is_exe_deleted",
43
52
  "is_system_service",
44
53
  "kill_process",
45
54
  "kill_processes",
@@ -69,6 +69,21 @@ def filter_high_memory(
69
69
  return [p for p in procs if p.rss_mb > threshold_mb]
70
70
 
71
71
 
72
+ def filter_stale(procs: list[ProcessInfo]) -> list[ProcessInfo]:
73
+ """Filter to processes with deleted/updated executables.
74
+
75
+ These are processes running outdated binaries after a package update.
76
+ Common on rolling release distros (Arch, Manjaro) after system updates.
77
+
78
+ Args:
79
+ procs: List of processes to filter.
80
+
81
+ Returns:
82
+ Processes whose executable has been deleted or replaced.
83
+ """
84
+ return [p for p in procs if p.exe_deleted]
85
+
86
+
72
87
  def filter_by_cwd(procs: list[ProcessInfo], cwd_path: str) -> list[ProcessInfo]:
73
88
  """Filter processes by current working directory.
74
89
 
@@ -20,6 +20,7 @@ class ProcessInfo:
20
20
  is_orphan: bool
21
21
  in_tmux: bool
22
22
  status: str
23
+ exe_deleted: bool = False # True if executable was deleted/updated
23
24
 
24
25
  @property
25
26
  def is_orphan_candidate(self) -> bool:
@@ -44,6 +44,26 @@ def get_cwd(pid: int) -> str:
44
44
  return "?"
45
45
 
46
46
 
47
+ def is_exe_deleted(pid: int) -> bool:
48
+ """Check if process executable has been deleted or updated.
49
+
50
+ This happens when a package is updated while the process is running.
51
+ The process continues with the old binary in memory, but the exe symlink
52
+ shows "(deleted)" suffix.
53
+
54
+ Args:
55
+ pid: Process ID.
56
+
57
+ Returns:
58
+ True if the executable file was deleted/updated, False otherwise.
59
+ """
60
+ try:
61
+ exe_link = Path(f"/proc/{pid}/exe").readlink()
62
+ return str(exe_link).endswith("(deleted)")
63
+ except (PermissionError, FileNotFoundError, ProcessLookupError):
64
+ return False
65
+
66
+
47
67
  def get_process_list(
48
68
  sort_by: str = "memory",
49
69
  filter_user: str | None = None,
@@ -93,19 +113,22 @@ def get_process_list(
93
113
  except (psutil.NoSuchProcess, psutil.AccessDenied):
94
114
  parent_name = "?"
95
115
 
96
- # Check if orphaned (parent is init/systemd)
97
- is_orphan = ppid == 1 or parent_name in {"systemd", "init"}
116
+ # Check if orphaned (reparented to PID 1 system init)
117
+ # Note:
118
+ # ppid != 1 with parent "systemd" means user session service, NOT orphan
119
+ is_orphan = ppid == 1
98
120
 
99
121
  cmdline = " ".join(info["cmdline"] or [])[:200]
100
122
  if not cmdline:
101
123
  cmdline = info["name"]
102
124
 
125
+ pid = info["pid"]
103
126
  processes.append(
104
127
  ProcessInfo(
105
- pid=info["pid"],
128
+ pid=pid,
106
129
  name=info["name"],
107
130
  cmdline=cmdline,
108
- cwd=get_cwd(info["pid"]),
131
+ cwd=get_cwd(pid),
109
132
  ppid=ppid,
110
133
  parent_name=parent_name,
111
134
  rss_mb=rss_mb,
@@ -113,8 +136,9 @@ def get_process_list(
113
136
  username=info["username"],
114
137
  create_time=info["create_time"] or 0,
115
138
  is_orphan=is_orphan,
116
- in_tmux=get_tmux_env(info["pid"]) if is_orphan else False,
139
+ in_tmux=get_tmux_env(pid) if is_orphan else False,
117
140
  status=info["status"] or "?",
141
+ exe_deleted=is_exe_deleted(pid),
118
142
  )
119
143
  )
120
144
  except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
@@ -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", "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,6 +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"),
58
+ Binding("O", "show_killable", "Killable"),
56
59
  Binding("a", "show_all", "All"),
57
60
  Binding("g", "show_groups", "Groups"),
58
61
  Binding("w", "filter_cwd", "Filter CWD"),
@@ -93,6 +96,7 @@ class ProcessCleanerApp(App):
93
96
  yield OptionList(
94
97
  Option("All Processes", id="view-all"),
95
98
  Option("Orphaned", id="view-orphans"),
99
+ Option("Killable", id="view-killable"),
96
100
  Option("Process Groups", id="view-groups"),
97
101
  Option("High Memory (>500MB)", id="view-high-mem"),
98
102
  id="view-selector",
@@ -177,35 +181,58 @@ class ProcessCleanerApp(App):
177
181
  key_func = sort_keys.get(self.sort_key, sort_keys["memory"])
178
182
  return sorted(procs, key=key_func, reverse=self.sort_reverse)
179
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
+
180
219
  def update_table(self) -> None:
181
220
  """Update the process table based on current view and sort."""
182
221
  table = self.query_one("#process-table", DataTable)
222
+ cursor_pid = self._get_pid_at_cursor()
183
223
  table.clear()
184
224
 
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
225
+ procs = self._filter_by_view()
198
226
  if self.cwd_filter:
199
227
  procs = filter_by_cwd(procs, self.cwd_filter)
200
-
201
- # Apply sorting
202
228
  procs = self._sort_processes(procs)
203
229
 
204
230
  for proc in procs:
205
231
  selected = "[X]" if proc.pid in self.selected_pids else "[ ]"
206
232
  orphan_marker = " [orphan]" if proc.is_orphan else ""
207
233
  tmux_marker = " [tmux]" if proc.in_tmux else ""
208
- status = f"{proc.status}{orphan_marker}{tmux_marker}"
234
+ stale_marker = " [stale]" if proc.exe_deleted else ""
235
+ status = f"{proc.status}{orphan_marker}{tmux_marker}{stale_marker}"
209
236
 
210
237
  cwd = proc.cwd or "?"
211
238
  if len(cwd) > CWD_MAX_WIDTH:
@@ -224,6 +251,7 @@ class ProcessCleanerApp(App):
224
251
  key=str(proc.pid),
225
252
  )
226
253
 
254
+ self._restore_cursor(table, cursor_pid)
227
255
  self.update_status()
228
256
 
229
257
  def update_status(self) -> None:
@@ -240,12 +268,55 @@ class ProcessCleanerApp(App):
240
268
  view_map: dict[str, ViewType] = {
241
269
  "view-all": "all",
242
270
  "view-orphans": "orphans",
271
+ "view-killable": "killable",
243
272
  "view-groups": "groups",
244
273
  "view-high-mem": "high-mem",
245
274
  }
246
275
  if event.option.id and event.option.id in view_map:
247
276
  self.current_view = view_map[event.option.id]
248
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
+
249
320
  def action_refresh(self) -> None:
250
321
  """Refresh process data."""
251
322
  self.refresh_data()
@@ -256,14 +327,14 @@ class ProcessCleanerApp(App):
256
327
 
257
328
  Returns:
258
329
  The PID at the current cursor position, or ``None`` if there is no
259
- current row selected.
330
+ current row selected or the table is empty.
260
331
  """
261
332
  table = self.query_one("#process-table", DataTable)
262
- if table.cursor_row is None:
333
+ if table.cursor_row is None or table.row_count == 0:
263
334
  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])
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])
267
338
 
268
339
  def _get_process_at_cursor(self) -> ProcessInfo | None:
269
340
  """Get the ProcessInfo at the current cursor position.
@@ -280,13 +351,23 @@ class ProcessCleanerApp(App):
280
351
 
281
352
  def action_toggle_select(self) -> None:
282
353
  """Toggle selection of current row."""
354
+ table = self.query_one("#process-table", DataTable)
355
+ if table.cursor_row is None:
356
+ return
357
+
283
358
  pid = self._get_pid_at_cursor()
284
359
  if pid is not None:
360
+ # Toggle selection
285
361
  if pid in self.selected_pids:
286
362
  self.selected_pids.remove(pid)
363
+ new_value = "[ ]"
287
364
  else:
288
365
  self.selected_pids.add(pid)
289
- 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()
290
371
 
291
372
  def action_select_all_visible(self) -> None:
292
373
  """Select all visible processes."""
@@ -306,6 +387,10 @@ class ProcessCleanerApp(App):
306
387
  """Switch to orphans view."""
307
388
  self.current_view = "orphans"
308
389
 
390
+ def action_show_killable(self) -> None:
391
+ """Switch to killable orphans view (orphans not in tmux)."""
392
+ self.current_view = "killable"
393
+
309
394
  def action_show_all(self) -> None:
310
395
  """Switch to all processes view."""
311
396
  self.current_view = "all"