@@ -36,6 +36,10 @@ type Model struct {
3636 page int
3737 pageSize int
3838
39+ // Terminal dimensions
40+ width int
41+ height int
42+
3943 // Admin status
4044 isAdmin bool
4145
@@ -51,6 +55,9 @@ type Model struct {
5155 selectedEntry models.PortEntry
5256}
5357
58+ // clearMsgTick is sent after a delay to clear status messages
59+ type clearMsgTick struct {}
60+
5461// InitialModel creates the initial TUI model
5562func InitialModel () * Model {
5663 scanner := ports .NewScanner ()
@@ -74,6 +81,8 @@ func InitialModel() *Model {
7481 successMsg : "" ,
7582 page : 0 ,
7683 pageSize : 20 ,
84+ width : 80 ,
85+ height : 24 ,
7786 isAdmin : isAdmin ,
7887 scanner : scanner ,
7988 kill : kill ,
@@ -86,6 +95,28 @@ func (m *Model) Init() tea.Cmd {
8695 return m .refresh ()
8796}
8897
98+ // scheduleClearMsg returns a command that clears messages after a delay
99+ func scheduleClearMsg () tea.Cmd {
100+ return tea .Tick (3 * time .Second , func (_ time.Time ) tea.Msg {
101+ return clearMsgTick {}
102+ })
103+ }
104+
105+ // calcPageSize computes how many rows fit based on terminal height
106+ func (m * Model ) calcPageSize () {
107+ // Reserve lines: header(2) + admin warning(2) + pagination(1) + spacing(1) +
108+ // table header(1) + footer(2) + some buffer(2) = ~11 overhead lines
109+ overhead := 11
110+ if ! m .isAdmin {
111+ overhead += 2 // admin warning takes extra lines
112+ }
113+ ps := m .height - overhead
114+ if ps < 5 {
115+ ps = 5
116+ }
117+ m .pageSize = ps
118+ }
119+
89120// Update handles messages
90121func (m * Model ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
91122 switch msg := msg .(type ) {
@@ -107,19 +138,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107138 m .isLoading = false
108139 m .refreshing = false
109140 m .mu .Unlock ()
141+ return m , scheduleClearMsg ()
142+
143+ case clearMsgTick :
144+ m .errMsg = ""
145+ m .successMsg = ""
110146 return m , nil
111147
112148 case tea.WindowSizeMsg :
113- // Handle window resize if needed
114- }
115-
116- // Clear messages after delay
117- if m .errMsg != "" || m .successMsg != "" {
118- go func () {
119- time .Sleep (3 * time .Second )
120- // Note: We can't directly modify model from goroutine
121- // This would need to be handled differently in production
122- }()
149+ m .width = msg .Width
150+ m .height = msg .Height
151+ m .calcPageSize ()
152+ return m , nil
123153 }
124154
125155 return m , nil
@@ -156,7 +186,7 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (*Model, tea.Cmd) {
156186 case "ctrl+c" , "q" :
157187 return m , tea .Quit
158188
159- case "k" :
189+ case "x" , "K" : // Kill with x or Shift+K (not k, which is vim-up)
160190 m2 , cmd := m .handleKill ()
161191 return m2 , cmd
162192
@@ -172,13 +202,13 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (*Model, tea.Cmd) {
172202 m .showHelp = true
173203 return m , nil
174204
175- case "up" , "w " : // vi style - up arrow or w
205+ case "up" , "k " : // vim style: k = up
176206 if m .selected > 0 {
177207 m .selected --
178208 }
179209 return m , nil
180210
181- case "down" , "j" : // vi style
211+ case "down" , "j" : // vim style: j = down
182212 if m .selected < len (m .getCurrentPageEntries ())- 1 {
183213 m .selected ++
184214 }
@@ -283,14 +313,7 @@ func (m *Model) handleKill() (*Model, tea.Cmd) {
283313 pageEntries := m .getCurrentPageEntries ()
284314 if len (pageEntries ) == 0 || m .selected >= len (pageEntries ) {
285315 m .errMsg = "No process selected"
286- return m , nil
287- }
288-
289- entry := pageEntries [m .selected ]
290-
291- if entry .IsSystem {
292- m .showKillConfirm = true
293- return m , nil
316+ return m , scheduleClearMsg ()
294317 }
295318
296319 m .showKillConfirm = true
@@ -309,10 +332,10 @@ func (m *Model) handleKillConfirm(msg tea.KeyMsg) (*Model, tea.Cmd) {
309332 result := m .kill .Kill (entry .PID )
310333 if result .Success {
311334 m .successMsg = result .Message
312- return m , m .refresh ()
313- } else {
314- m .errMsg = result .Message
335+ return m , tea .Batch (m .refresh (), scheduleClearMsg ())
315336 }
337+ m .errMsg = result .Message
338+ return m , scheduleClearMsg ()
316339 }
317340
318341 case "n" , "N" , "esc" :
@@ -401,12 +424,12 @@ func (m *Model) View() string {
401424 // Pagination info
402425 if len (m .filtered ) > 0 {
403426 sb .WriteString (paginationStyle .Render (fmt .Sprintf ("Page %d/%d • %d entries" , m .page + 1 , m .totalPages (), len (m .filtered ))))
404- } else {
405- sb .WriteString ("\n " )
406427 }
428+ sb .WriteString ("\n " )
407429
408430 // Loading state
409431 if m .isLoading {
432+ sb .WriteString ("\n " )
410433 sb .WriteString (loadingStyle .Render ("Loading..." ))
411434 return sb .String ()
412435 }
@@ -429,29 +452,29 @@ func (m *Model) View() string {
429452 // Search mode
430453 if m .searchMode {
431454 sb .WriteString (searchStyle .Render (fmt .Sprintf ("Search: %s" , m .searchQuery )))
432- sb .WriteString (" (Esc to cancel)\n \n " )
455+ sb .WriteString (" (Esc to cancel)\n " )
433456 }
434457
435458 // Error message
436459 if m .errMsg != "" {
437460 sb .WriteString (errorStyle .Render (m .errMsg ))
438- sb .WriteString ("\n \n " )
439- m .errMsg = "" // Clear after displaying
461+ sb .WriteString ("\n " )
440462 }
441463
442464 // Success message
443465 if m .successMsg != "" {
444466 sb .WriteString (successStyle .Render (m .successMsg ))
445- sb .WriteString ("\n \n " )
446- m .successMsg = "" // Clear after displaying
467+ sb .WriteString ("\n " )
447468 }
448469
470+ sb .WriteString ("\n " )
471+
449472 // Table header
450473 sb .WriteString (tableHeaderStyle .Render (
451474 fmt .Sprintf ("%s %s %s %s %s" ,
452475 padRight ("PROTO" , 5 ),
453476 padRight ("PORT" , 6 ),
454- padRight ("PID" , 6 ),
477+ padRight ("PID" , 8 ),
455478 padRight ("PROCESS" , 20 ),
456479 "STATE" ),
457480 ))
@@ -477,7 +500,7 @@ func (m *Model) View() string {
477500 // Footer
478501 sb .WriteString ("\n " )
479502 sb .WriteString (footerStyle .Render (
480- "[↑/↓] Navigate [PgUp/PgDn] Page [k ] Kill [r] Refresh [/] Filter [h] Help [q] Quit" ,
503+ "[↑/↓/j/k ] Navigate [PgUp/PgDn] Page [x ] Kill [r] Refresh [/] Filter [h] Help [q] Quit" ,
481504 ))
482505
483506 return sb .String ()
@@ -497,7 +520,7 @@ func (m *Model) renderRow(entry models.PortEntry) string {
497520 return fmt .Sprintf ("%s %s %s %s %s" ,
498521 protocolStyle .Render (padRight (entry .Protocol , 5 )),
499522 padRight (fmt .Sprintf ("%d" , entry .Port ), 6 ),
500- padRight (fmt .Sprintf ("%d" , entry .PID ), 6 ),
523+ padRight (fmt .Sprintf ("%d" , entry .PID ), 8 ),
501524 padRight (truncate (entry .ProcessName , 18 ), 20 ),
502525 stateStyle .Render (entry .State ),
503526 )
@@ -524,11 +547,11 @@ func (m *Model) renderHelp() string {
524547 {"↑/↓ or j/k" , "Navigate list" },
525548 {"PgUp/PgDn" , "Change page" },
526549 {"Enter" , "View process details" },
527- {"k " , "Kill selected process" },
550+ {"x / Shift+K " , "Kill selected process" },
528551 {"r" , "Refresh port list" },
529552 {"/" , "Search/filter mode" },
530553 {"h" , "Show this help" },
531- {"q" , "Quit" },
554+ {"q / Ctrl+C " , "Quit" },
532555 {"Esc" , "Clear filter / Close dialog" },
533556 }
534557
@@ -618,9 +641,10 @@ func formatUptime(d time.Duration) string {
618641 return "unknown"
619642 }
620643
621- days := int (d .Hours () / 24 )
622- hours := int (d .Hours ()) % 60
623- minutes := int (d .Minutes ()) % 60
644+ totalMinutes := int (d .Minutes ())
645+ days := totalMinutes / (60 * 24 )
646+ hours := (totalMinutes / 60 ) % 24
647+ minutes := totalMinutes % 60
624648
625649 if days > 0 {
626650 return fmt .Sprintf ("%dd %dh %dm" , days , hours , minutes )
0 commit comments