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,108 @@ 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+ # For other executables, get the first word/path component
63+ parts = command .split ()
64+ if not parts :
65+ return command
66+
67+ executable = parts [0 ]
68+ basename = os .path .basename (executable )
69+
70+ # Remove common helper suffixes for cleaner names
71+ basename = (
72+ basename .replace (" (Plugin)" , "" )
73+ .replace (" (Renderer)" , "" )
74+ .replace (" (GPU)" , "" )
5075 )
51- processes : list [Process ] = []
5276
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 )
77+ return basename
78+
6279
80+ def get_processes () -> list [Process ]:
81+ """Get a list of processes with their associated ports."""
82+ processes : list [Process ] = []
83+ try :
84+ connections = psutil .net_connections (kind = "inet" )
85+ for conn in connections :
86+ if (
87+ conn .laddr
88+ and conn .status == psutil .CONN_LISTEN
89+ and conn .pid is not None
90+ ):
91+ pid = conn .pid
92+ port = parse_port (str (conn .laddr .port ))
93+ try :
94+ proc = psutil .Process (pid )
95+ user = proc .username ()
96+ command = (
97+ " " .join (proc .cmdline ()) if proc .cmdline () else proc .name ()
98+ )
99+ except (psutil .NoSuchProcess , psutil .AccessDenied ):
100+ user = "N/A"
101+ command = "N/A"
102+
103+ name = extract_app_name (command )
104+ processes .append (
105+ Process (pid = pid , port = port , user = user , command = command , name = name )
106+ )
107+ except psutil .AccessDenied :
108+ # On macOS, net_connections() requires elevated privileges
109+ # Fall back to checking each process individually
110+ for proc in psutil .process_iter (["pid" ]):
111+ try :
112+ pid = proc .info ["pid" ]
113+ proc_connections = proc .net_connections (kind = "inet" )
114+ for conn in proc_connections :
115+ if conn .laddr and conn .status == psutil .CONN_LISTEN :
116+ port = parse_port (str (conn .laddr .port ))
117+ try :
118+ user = proc .username ()
119+ # get last part of command line or name if empty
120+ command = (
121+ " " .join (proc .cmdline ())
122+ if proc .cmdline ()
123+ else proc .name ()
124+ )
125+ except (psutil .NoSuchProcess , psutil .AccessDenied ):
126+ user = "N/A"
127+ command = "N/A"
128+
129+ name = extract_app_name (command )
130+ processes .append (
131+ Process (
132+ pid = pid ,
133+ port = port ,
134+ user = user ,
135+ command = command ,
136+ name = name ,
137+ )
138+ )
139+ except (psutil .NoSuchProcess , psutil .AccessDenied , psutil .ZombieProcess ):
140+ # Skip processes we can't access
141+ continue
142+
143+ # Sort processes by port number (numeric ports first, then strings)
144+ processes .sort (key = lambda p : (isinstance (p .port , str ), p .port ))
63145 return processes
64146
65147
66148def kill_process (pid : int ):
67- subprocess .run (["kill" , "-9" , str (pid )])
149+ proc = psutil .Process (pid )
150+ proc .kill ()
68151
69152
70153def _show_pagination_indicator (total : int , selected : int , panels : list [Panel | str ]):
@@ -83,7 +166,9 @@ def _show_pagination_indicator(total: int, selected: int, panels: list[Panel | s
83166 panels .append (f" [dim]{ indicator } [/dim]" )
84167
85168
86- def _render_processes_table (processes : List [Process ], selected : int ):
169+ def _render_processes_table (
170+ processes : List [Process ], selected : int , show_details : bool = False
171+ ):
87172 max_display = 4
88173
89174 if len (processes ) <= max_display :
@@ -107,10 +192,15 @@ def _render_processes_table(processes: List[Process], selected: int):
107192 indicator = "▐ " if i == display_selected else " "
108193
109194 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 } "
195+ app_line = f"{ indicator } [dim]App: { process .name } , User: { process .user } [/dim]"
196+
197+ if show_details :
198+ # Show clean name AND full command details
199+ details_line = f"{ indicator } [dim]Details: { process .command } [/dim]"
200+ content = f"{ port_line } \n { app_line } \n { details_line } "
201+ else :
202+ # Show just clean app name and user
203+ content = f"{ port_line } \n { app_line } "
114204
115205 # All items get a panel with no border
116206 panel = Panel (
@@ -247,17 +337,14 @@ def main(
247337 refresh_rate : int = typer .Option (
248338 10 , "--refresh-rate" , "-r" , help = "Display refresh rate per second"
249339 ),
340+ details : bool = typer .Option (
341+ False , "--details" , "-d" , help = "Show full command details instead of app name"
342+ ),
250343):
251344 console = Console ()
252345 text = _render_title ()
253346 processes : list [Process ] = get_processes ()
254347
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-
261348 if port is not None :
262349 processes = [p for p in processes if p .port == port ]
263350
@@ -288,7 +375,7 @@ def main(
288375 )
289376
290377 with Live (
291- _render_processes_table (processes , selected ),
378+ _render_processes_table (processes , selected , details ),
292379 console = console ,
293380 refresh_per_second = refresh_rate ,
294381 ) as live :
@@ -298,7 +385,9 @@ def main(
298385 is_filtering = False
299386 filter_text = ""
300387 processes = get_processes ()
301- live .update (_render_processes_table (processes , selected ))
388+ live .update (
389+ _render_processes_table (processes , selected , details )
390+ )
302391 continue
303392 elif ch == key .UP or ch == "k" :
304393 selected = max (0 , selected - 1 )
@@ -331,7 +420,8 @@ def main(
331420 border_style = "magenta" ,
332421 )
333422 display = Group (
334- filter_panel , _render_processes_table (processes , selected )
423+ filter_panel ,
424+ _render_processes_table (processes , selected , details ),
335425 )
336426 live .update (display )
337427 else :
@@ -348,7 +438,8 @@ def main(
348438 border_style = "magenta" ,
349439 )
350440 display = Group (
351- filter_panel , _render_processes_table (processes , selected )
441+ filter_panel ,
442+ _render_processes_table (processes , selected , details ),
352443 )
353444 live .update (display )
354445 continue # Skip the update at the end of the loop
@@ -359,7 +450,7 @@ def main(
359450 # Exit live context to show confirmation view
360451 process_to_kill = processes [selected ]
361452 break
362- live .update (_render_processes_table (processes , selected ))
453+ live .update (_render_processes_table (processes , selected , details ))
363454
364455 if process_to_kill is not None :
365456 _clear_screen ()
0 commit comments