1- from dataclasses import dataclass
1+ import os
22import signal
3- import subprocess
43import sys
4+ from dataclasses import dataclass
55from typing import List , Optional
66
7+ import psutil
78import typer
8- from readchar import readkey , key
9+ from readchar import key , readkey
10+ from rich import box
911from rich .color import Color
1012from rich .console import Console , Group
1113from rich .live import Live
12- from rich .style import Style
1314from rich .panel import Panel
15+ from rich .style import Style
1416from rich .text import Text
15- from rich import box
1617
1718
1819@dataclass
@@ -21,6 +22,7 @@ class Process:
2122 port : int | str
2223 user : str
2324 command : str
25+ name : str
2426
2527
2628SELECTED_COLOR = Style (color = "#EE6FF8" , bold = True )
@@ -44,27 +46,118 @@ def parse_port(port_str: str) -> int | str:
4446 return port_str
4547
4648
47- def get_processes () -> list [Process ]:
48- raw_processes = subprocess .run (
49- ["lsof" , "-i" , "-P" , "-n" , "-sTCP:LISTEN" ], capture_output = True , text = True
49+ def extract_app_name (command : str ) -> str :
50+ if not command or command == "N/A" :
51+ return command
52+
53+ # Handle macOS .app bundles - extract the main app name from the full command string
54+ if ".app/" in command :
55+ first_app_end = command .find (".app/" ) + 4
56+ path_before_app = command [:first_app_end ]
57+ app_start = path_before_app .rfind ("/" )
58+ if app_start != - 1 :
59+ app_name = path_before_app [app_start + 1 :]
60+ return app_name .replace (".app" , "" )
61+
62+ # Handle Windows .exe paths - look for .exe to find the executable
63+ if ".exe" in command :
64+ exe_end = command .find (".exe" ) + 4
65+ path_before_exe = command [:exe_end ]
66+ # Find the last backslash or forward slash
67+ exe_start = max (path_before_exe .rfind ("\\ " ), path_before_exe .rfind ("/" ))
68+ if exe_start != - 1 :
69+ exe_name = path_before_exe [exe_start + 1 :]
70+ return exe_name .replace (".exe" , "" )
71+
72+ # For other executables, get the first word/path component
73+ parts = command .split ()
74+ if not parts :
75+ return command
76+
77+ executable = parts [0 ]
78+ basename = os .path .basename (executable )
79+
80+ # Remove common helper suffixes for cleaner names
81+ basename = (
82+ basename .replace (" (Plugin)" , "" )
83+ .replace (" (Renderer)" , "" )
84+ .replace (" (GPU)" , "" )
5085 )
51- processes : list [Process ] = []
5286
53- for line in raw_processes .stdout .splitlines ()[1 :]:
54- parts = line .split ()
55- if len (parts ) >= 9 :
56- pid = int (parts [1 ])
57- user = parts [2 ]
58- command = parts [0 ]
59- port = parse_port (parts [8 ].split (":" )[- 1 ])
60- process = Process (pid = pid , port = port , user = user , command = command )
61- processes .append (process )
87+ return basename
88+
6289
90+ def get_processes () -> list [Process ]:
91+ """Get a list of processes with their associated ports."""
92+ processes : list [Process ] = []
93+ try :
94+ connections = psutil .net_connections (kind = "inet" )
95+ for conn in connections :
96+ if (
97+ conn .laddr
98+ and conn .status == psutil .CONN_LISTEN
99+ and conn .pid is not None
100+ ):
101+ pid = conn .pid
102+ port = parse_port (str (conn .laddr .port ))
103+ try :
104+ proc = psutil .Process (pid )
105+ user = proc .username ()
106+ command = (
107+ " " .join (proc .cmdline ()) if proc .cmdline () else proc .name ()
108+ )
109+ except (psutil .NoSuchProcess , psutil .AccessDenied ):
110+ user = "N/A"
111+ command = "N/A"
112+
113+ name = extract_app_name (command )
114+ processes .append (
115+ Process (pid = pid , port = port , user = user , command = command , name = name )
116+ )
117+ except psutil .AccessDenied :
118+ # On macOS, net_connections() requires elevated privileges
119+ # Fall back to checking each process individually
120+ for proc in psutil .process_iter (["pid" ]):
121+ try :
122+ pid = proc .info ["pid" ]
123+ proc_connections = proc .net_connections (kind = "inet" )
124+ for conn in proc_connections :
125+ if conn .laddr and conn .status == psutil .CONN_LISTEN :
126+ port = parse_port (str (conn .laddr .port ))
127+ try :
128+ user = proc .username ()
129+ # get last part of command line or name if empty
130+ command = (
131+ " " .join (proc .cmdline ())
132+ if proc .cmdline ()
133+ else proc .name ()
134+ )
135+ except (psutil .NoSuchProcess , psutil .AccessDenied ):
136+ user = "N/A"
137+ command = "N/A"
138+
139+ name = extract_app_name (command )
140+ processes .append (
141+ Process (
142+ pid = pid ,
143+ port = port ,
144+ user = user ,
145+ command = command ,
146+ name = name ,
147+ )
148+ )
149+ except (psutil .NoSuchProcess , psutil .AccessDenied , psutil .ZombieProcess ):
150+ # Skip processes we can't access
151+ continue
152+
153+ # Sort processes by port number (numeric ports first, then strings)
154+ processes .sort (key = lambda p : (isinstance (p .port , str ), p .port ))
63155 return processes
64156
65157
66158def kill_process (pid : int ):
67- subprocess .run (["kill" , "-9" , str (pid )])
159+ proc = psutil .Process (pid )
160+ proc .kill ()
68161
69162
70163def _show_pagination_indicator (total : int , selected : int , panels : list [Panel | str ]):
@@ -83,7 +176,9 @@ def _show_pagination_indicator(total: int, selected: int, panels: list[Panel | s
83176 panels .append (f" [dim]{ indicator } [/dim]" )
84177
85178
86- def _render_processes_table (processes : List [Process ], selected : int ):
179+ def _render_processes_table (
180+ processes : List [Process ], selected : int , show_details : bool = False
181+ ):
87182 max_display = 4
88183
89184 if len (processes ) <= max_display :
@@ -107,10 +202,15 @@ def _render_processes_table(processes: List[Process], selected: int):
107202 indicator = "▐ " if i == display_selected else " "
108203
109204 port_line = f"{ indicator } [bold]Port: { process .port } (PID: { process .pid } )[/bold]"
110- user_line = (
111- f"{ indicator } [dim]User: { process .user } , Command: { process .command } [/dim]"
112- )
113- content = f"{ port_line } \n { user_line } "
205+ app_line = f"{ indicator } [dim]App: { process .name } , User: { process .user } [/dim]"
206+
207+ if show_details :
208+ # Show clean name AND full command details
209+ details_line = f"{ indicator } [dim]Details: { process .command } [/dim]"
210+ content = f"{ port_line } \n { app_line } \n { details_line } "
211+ else :
212+ # Show just clean app name and user
213+ content = f"{ port_line } \n { app_line } "
114214
115215 # All items get a panel with no border
116216 panel = Panel (
@@ -247,17 +347,14 @@ def main(
247347 refresh_rate : int = typer .Option (
248348 10 , "--refresh-rate" , "-r" , help = "Display refresh rate per second"
249349 ),
350+ details : bool = typer .Option (
351+ False , "--details" , "-d" , help = "Show full command details instead of app name"
352+ ),
250353):
251354 console = Console ()
252355 text = _render_title ()
253356 processes : list [Process ] = get_processes ()
254357
255- if sys .platform .startswith ("win" ):
256- console .print (
257- "[red]Error:[/red] This program is only supported on Unix-like systems."
258- )
259- sys .exit (1 )
260-
261358 if port is not None :
262359 processes = [p for p in processes if p .port == port ]
263360
@@ -288,7 +385,7 @@ def main(
288385 )
289386
290387 with Live (
291- _render_processes_table (processes , selected ),
388+ _render_processes_table (processes , selected , details ),
292389 console = console ,
293390 refresh_per_second = refresh_rate ,
294391 ) as live :
@@ -298,7 +395,9 @@ def main(
298395 is_filtering = False
299396 filter_text = ""
300397 processes = get_processes ()
301- live .update (_render_processes_table (processes , selected ))
398+ live .update (
399+ _render_processes_table (processes , selected , details )
400+ )
302401 continue
303402 elif ch == key .UP or ch == "k" :
304403 selected = max (0 , selected - 1 )
@@ -331,7 +430,8 @@ def main(
331430 border_style = "magenta" ,
332431 )
333432 display = Group (
334- filter_panel , _render_processes_table (processes , selected )
433+ filter_panel ,
434+ _render_processes_table (processes , selected , details ),
335435 )
336436 live .update (display )
337437 else :
@@ -348,7 +448,8 @@ def main(
348448 border_style = "magenta" ,
349449 )
350450 display = Group (
351- filter_panel , _render_processes_table (processes , selected )
451+ filter_panel ,
452+ _render_processes_table (processes , selected , details ),
352453 )
353454 live .update (display )
354455 continue # Skip the update at the end of the loop
@@ -359,7 +460,7 @@ def main(
359460 # Exit live context to show confirmation view
360461 process_to_kill = processes [selected ]
361462 break
362- live .update (_render_processes_table (processes , selected ))
463+ live .update (_render_processes_table (processes , selected , details ))
363464
364465 if process_to_kill is not None :
365466 _clear_screen ()
0 commit comments