Terminal system monitor in Gleam — htop × btop
1

Configure Feed

Select the types of activity you want to include in your feed.

monitor / src / app.gleam
11 kB 417 lines
1//// Main application loop: sample /proc, render, handle keys and mouse. 2 3import etch/command 4import etch/erlang/input 5import etch/erlang/tty 6import etch/event.{ 7 type Event, type KeyCode, type MouseEvent, Backspace, Char, Down, DownArrow, 8 Drag, End, Enter, Esc, Home, Key, Left, Mouse, Moved, PageDown, PageUp, Right, 9 ScrollDown, ScrollLeft, ScrollRight, ScrollUp, Up, UpArrow, 10} 11import etch/stdout 12import etch/terminal 13import gleam/dict 14import gleam/erlang/process 15import gleam/int 16import gleam/list 17import gleam/option.{None, Some} 18import gleam/string 19import procfs.{type Snapshot} 20import ui.{ 21 type SortKey, type ViewState, ConfirmKill, Filter, Help, Normal, SortCommand, 22 SortCpu, SortMem, SortPid, SortTime, 23} 24 25const refresh_ms = 1000 26 27@external(erlang, "erlang", "halt") 28fn halt(code: Int) -> Nil 29 30@external(erlang, "os", "cmd") 31fn os_cmd(command: String) -> String 32 33pub fn run() -> Nil { 34 let #(cols, rows) = window_size() 35 let assert Ok(_) = tty.enter_raw() 36 stdout.execute([ 37 command.EnterAlternateScreen, 38 command.EnableMouseCapture, 39 command.HideCursor, 40 command.Clear(terminal.All), 41 command.DisableLineWrap, 42 command.SetTitle("monitor"), 43 ]) 44 45 // Seed samples so first painted frame has real CPU % 46 let prev_cores = dict.new() 47 let prev_ticks = dict.new() 48 let snap0 = procfs.sample_with_core_history(0, 0, prev_cores, prev_ticks) 49 process.sleep(200) 50 let snap = 51 procfs.sample_with_core_history( 52 snap0.prev_cpu_total, 53 snap0.prev_cpu_idle, 54 procfs.core_history_from(snap0), 55 snap0.prev_proc_ticks, 56 ) 57 58 let view = 59 ui.initial_view(cols, rows) 60 |> ui.push_history(snap) 61 62 loop(snap, view) 63} 64 65fn loop(snap: Snapshot, view: ViewState) -> Nil { 66 let #(cols, rows) = window_size() 67 let view = case cols != view.cols || rows != view.rows { 68 True -> ui.ViewState(..view, cols: cols, rows: rows) 69 False -> view 70 } 71 72 stdout.execute(ui.render(snap, view)) 73 74 let #(snap2, view2, quit) = handle_input_window(snap, view, refresh_ms) 75 case quit { 76 True -> cleanup() 77 False -> { 78 let view2 = ui.push_history(view2, snap2) 79 loop(snap2, view2) 80 } 81 } 82} 83 84/// Poll for keys up to `budget_ms`, then refresh system stats. 85fn handle_input_window( 86 snap: Snapshot, 87 view: ViewState, 88 budget_ms: Int, 89) -> #(Snapshot, ViewState, Bool) { 90 let step = 50 91 handle_input_window_go(snap, view, budget_ms, step) 92} 93 94fn handle_input_window_go( 95 snap: Snapshot, 96 view: ViewState, 97 remaining: Int, 98 step: Int, 99) -> #(Snapshot, ViewState, Bool) { 100 case remaining <= 0 { 101 True -> { 102 let snap2 = refresh(snap) 103 #(snap2, view, False) 104 } 105 False -> { 106 case input.poll(0) { 107 Some(Ok(ev)) -> 108 // Mouse motion / drag / release spam SGR reports continuously. 109 // Full clear+redraw on hover was the glitch; ignore those kinds. 110 case is_mouse_noise(ev) { 111 True -> handle_input_window_go(snap, view, remaining - step, step) 112 False -> 113 case apply_event(snap, view, ev) { 114 #(snap2, view2, True) -> #(snap2, view2, True) 115 #(snap2, view2, False) -> { 116 // Immediate redraw on real input for snappy feel 117 stdout.execute(ui.render(snap2, view2)) 118 handle_input_window_go(snap2, view2, remaining - step, step) 119 } 120 } 121 } 122 Some(Error(_)) -> { 123 process.sleep(step) 124 handle_input_window_go(snap, view, remaining - step, step) 125 } 126 None -> { 127 process.sleep(step) 128 handle_input_window_go(snap, view, remaining - step, step) 129 } 130 } 131 } 132 } 133} 134 135fn refresh(snap: Snapshot) -> Snapshot { 136 procfs.sample_with_core_history( 137 snap.prev_cpu_total, 138 snap.prev_cpu_idle, 139 procfs.core_history_from(snap), 140 snap.prev_proc_ticks, 141 ) 142} 143 144fn apply_event( 145 snap: Snapshot, 146 view: ViewState, 147 ev: Event, 148) -> #(Snapshot, ViewState, Bool) { 149 case ev { 150 Key(ke) -> apply_key(snap, view, ke.code, ke.modifiers.control) 151 Mouse(me) -> apply_mouse(snap, view, me) 152 _ -> #(snap, view, False) 153 } 154} 155 156/// Hover / drag / button-up should not mutate state or force a paint. 157fn is_mouse_noise(ev: Event) -> Bool { 158 case ev { 159 Mouse(me) -> 160 case me.kind { 161 Moved | Drag(_) | Up(_) | ScrollLeft | ScrollRight -> True 162 _ -> False 163 } 164 _ -> False 165 } 166} 167 168fn apply_mouse( 169 snap: Snapshot, 170 view: ViewState, 171 me: MouseEvent, 172) -> #(Snapshot, ViewState, Bool) { 173 case view.mode { 174 Help -> 175 // Only a click dismisses help (not hover — filtered as noise above) 176 case me.kind { 177 Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False) 178 _ -> #(snap, view, False) 179 } 180 181 ConfirmKill -> 182 // Click cancels kill (safer than accidental confirm) 183 case me.kind { 184 Down(_) -> #(snap, ui.ViewState(..view, mode: Normal), False) 185 _ -> #(snap, view, False) 186 } 187 188 Filter -> 189 // Keep filter typing keyboard-only; wheel still scrolls selection 190 case me.kind { 191 ScrollUp -> #(snap, move_sel(view, snap, -3), False) 192 ScrollDown -> #(snap, move_sel(view, snap, 3), False) 193 _ -> #(snap, view, False) 194 } 195 196 Normal -> 197 case me.kind { 198 ScrollUp -> #(snap, move_sel(view, snap, -3), False) 199 ScrollDown -> #(snap, move_sel(view, snap, 3), False) 200 201 Down(Left) -> apply_left_click(snap, view, me.column, me.row) 202 203 Down(Right) -> 204 case ui.process_index_at_row(snap, view, me.row) { 205 Ok(idx) -> { 206 let view = ui.ViewState(..view, selected: idx) 207 case ui.selected_process(view, snap) { 208 Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False) 209 Error(_) -> #(snap, view, False) 210 } 211 } 212 Error(_) -> #(snap, view, False) 213 } 214 215 _ -> #(snap, view, False) 216 } 217 } 218} 219 220fn apply_left_click( 221 snap: Snapshot, 222 view: ViewState, 223 col: Int, 224 row: Int, 225) -> #(Snapshot, ViewState, Bool) { 226 let lay = ui.layout(snap, view) 227 case row == lay.table_header_row { 228 True -> 229 case ui.sort_key_at_column(col) { 230 Ok(key) -> #(snap, set_sort(view, key), False) 231 Error(_) -> #(snap, view, False) 232 } 233 False -> 234 case ui.process_index_at_row(snap, view, row) { 235 Ok(idx) -> #(snap, ui.ViewState(..view, selected: idx), False) 236 Error(_) -> #(snap, view, False) 237 } 238 } 239} 240 241fn apply_key( 242 snap: Snapshot, 243 view: ViewState, 244 code: KeyCode, 245 ctrl: Bool, 246) -> #(Snapshot, ViewState, Bool) { 247 case view.mode { 248 Help -> #(snap, ui.ViewState(..view, mode: Normal), False) 249 250 ConfirmKill -> 251 case code { 252 Char("y") | Char("Y") -> { 253 case ui.selected_process(view, snap) { 254 Ok(p) -> { 255 let _ = 256 os_cmd("kill -15 " <> int.to_string(p.pid) <> " 2>/dev/null") 257 Nil 258 } 259 Error(_) -> Nil 260 } 261 #(refresh(snap), ui.ViewState(..view, mode: Normal), False) 262 } 263 Char("n") | Char("N") | Esc -> #( 264 snap, 265 ui.ViewState(..view, mode: Normal), 266 False, 267 ) 268 _ -> #(snap, view, False) 269 } 270 271 Filter -> 272 case code { 273 Esc -> #( 274 snap, 275 ui.ViewState(..view, mode: Normal, filter: "", selected: 0, scroll: 0), 276 False, 277 ) 278 Enter -> #( 279 snap, 280 ui.ViewState(..view, mode: Normal, selected: 0, scroll: 0), 281 False, 282 ) 283 Backspace -> { 284 let f = string.drop_end(view.filter, 1) 285 #( 286 snap, 287 ui.ViewState(..view, filter: f, selected: 0, scroll: 0), 288 False, 289 ) 290 } 291 Char(c) -> 292 case string.byte_size(c) == 1 && !ctrl && is_printable(c) { 293 True -> #( 294 snap, 295 ui.ViewState( 296 ..view, 297 filter: view.filter <> c, 298 selected: 0, 299 scroll: 0, 300 ), 301 False, 302 ) 303 False -> #(snap, view, False) 304 } 305 _ -> #(snap, view, False) 306 } 307 308 Normal -> 309 case code { 310 Char("q") | Char("Q") | Esc -> #(snap, view, True) 311 Char("h") | Char("?") -> #( 312 snap, 313 ui.ViewState(..view, mode: Help), 314 False, 315 ) 316 Char("/") -> #(snap, ui.ViewState(..view, mode: Filter), False) 317 Char("k") | Char("K") -> 318 case ui.selected_process(view, snap) { 319 Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False) 320 Error(_) -> #(snap, view, False) 321 } 322 Char("j") | DownArrow -> #(snap, move_sel(view, snap, 1), False) 323 Char("J") -> #(snap, move_sel(view, snap, 10), False) 324 UpArrow | Char("u") -> #(snap, move_sel(view, snap, -1), False) 325 PageDown -> #(snap, move_sel(view, snap, page(view)), False) 326 PageUp -> #(snap, move_sel(view, snap, -page(view)), False) 327 Home | Char("g") -> #( 328 snap, 329 ui.ViewState(..view, selected: 0, scroll: 0), 330 False, 331 ) 332 End | Char("G") -> { 333 let n = list.length(ui.visible_processes(view, snap)) 334 let sel = int.max(0, n - 1) 335 #(snap, ui.ViewState(..view, selected: sel), False) 336 } 337 Char("c") -> #(snap, set_sort(view, SortCpu), False) 338 Char("m") -> #(snap, set_sort(view, SortMem), False) 339 Char("p") -> #(snap, set_sort(view, SortPid), False) 340 Char("t") -> #(snap, set_sort(view, SortTime), False) 341 Char("n") -> #(snap, set_sort(view, SortCommand), False) 342 Char("T") -> #( 343 snap, 344 ui.ViewState( 345 ..view, 346 tree: !view.tree, 347 selected: 0, 348 scroll: 0, 349 ), 350 False, 351 ) 352 Char("r") -> #( 353 snap, 354 ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0), 355 False, 356 ) 357 Char(" ") -> #(refresh(snap), view, False) 358 _ -> #(snap, view, False) 359 } 360 } 361} 362 363fn is_printable(c: String) -> Bool { 364 case string.to_utf_codepoints(c) { 365 [cp] -> { 366 let n = string.utf_codepoint_to_int(cp) 367 n >= 32 && n <= 126 368 } 369 _ -> False 370 } 371} 372 373fn set_sort(view: ViewState, key: SortKey) -> ViewState { 374 case view.sort == key { 375 True -> ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0) 376 False -> 377 ui.ViewState( 378 ..view, 379 sort: key, 380 reverse: case key { 381 SortCpu | SortMem | SortTime -> True 382 SortPid | SortCommand -> False 383 }, 384 selected: 0, 385 scroll: 0, 386 ) 387 } 388} 389 390fn move_sel(view: ViewState, snap: Snapshot, delta: Int) -> ViewState { 391 let n = list.length(ui.visible_processes(view, snap)) 392 let sel = int.clamp(view.selected + delta, 0, int.max(0, n - 1)) 393 ui.ViewState(..view, selected: sel) 394} 395 396fn page(view: ViewState) -> Int { 397 int.max(5, view.rows / 2) 398} 399 400fn window_size() -> #(Int, Int) { 401 case tty.window_size() { 402 Ok(#(c, r)) -> #(c, r) 403 Error(_) -> #(100, 30) 404 } 405} 406 407fn cleanup() -> Nil { 408 stdout.execute([ 409 command.DisableMouseCapture, 410 command.EnableLineWrap, 411 command.ShowCursor, 412 command.Clear(terminal.All), 413 command.LeaveAlternateScreen, 414 ]) 415 let _ = tty.exit_raw() 416 halt(0) 417}