Terminal system monitor in Gleam — htop × btop
1//// Terminal rendering: btop-style meters + htop-style process table.
2
3import etch/command.{type Command}
4import etch/style
5import etch/terminal
6import format
7import gleam/int
8import gleam/list
9import gleam/order.{type Order, Eq, Gt, Lt}
10import gleam/string
11import procfs.{type Process, type Snapshot}
12
13pub type SortKey {
14 SortCpu
15 SortMem
16 SortPid
17 SortTime
18 SortCommand
19}
20
21pub type Mode {
22 Normal
23 Filter
24 Help
25 ConfirmKill
26}
27
28pub type ViewState {
29 ViewState(
30 selected: Int,
31 scroll: Int,
32 sort: SortKey,
33 reverse: Bool,
34 filter: String,
35 mode: Mode,
36 cpu_history: List(Float),
37 mem_history: List(Float),
38 cols: Int,
39 rows: Int,
40 )
41}
42
43pub fn initial_view(cols: Int, rows: Int) -> ViewState {
44 ViewState(
45 selected: 0,
46 scroll: 0,
47 sort: SortCpu,
48 reverse: True,
49 filter: "",
50 mode: Normal,
51 cpu_history: [],
52 mem_history: [],
53 cols: cols,
54 rows: rows,
55 )
56}
57
58pub fn push_history(view: ViewState, snap: Snapshot) -> ViewState {
59 let max = 60
60 let cpu = list.append(view.cpu_history, [snap.cpu_total_percent])
61 let mem = list.append(view.mem_history, [snap.mem.used_percent])
62 let cpu = case list.length(cpu) > max {
63 True -> list.drop(cpu, list.length(cpu) - max)
64 False -> cpu
65 }
66 let mem = case list.length(mem) > max {
67 True -> list.drop(mem, list.length(mem) - max)
68 False -> mem
69 }
70 ViewState(..view, cpu_history: cpu, mem_history: mem)
71}
72
73pub fn visible_processes(view: ViewState, snap: Snapshot) -> List(Process) {
74 let filtered = case view.filter {
75 "" -> snap.processes
76 f -> {
77 let f = string.lowercase(f)
78 list.filter(snap.processes, fn(p) {
79 string.contains(string.lowercase(p.command), f)
80 || string.contains(string.lowercase(p.user), f)
81 || string.contains(int.to_string(p.pid), f)
82 })
83 }
84 }
85 let sorted = sort_processes(filtered, view.sort)
86 case view.reverse {
87 True -> list.reverse(sorted)
88 False -> sorted
89 }
90}
91
92fn sort_processes(procs: List(Process), key: SortKey) -> List(Process) {
93 list.sort(procs, fn(a, b) {
94 case key {
95 SortCpu -> float_ord(a.cpu_percent, b.cpu_percent)
96 SortMem -> float_ord(a.mem_percent, b.mem_percent)
97 SortPid -> int.compare(a.pid, b.pid)
98 SortTime -> int.compare(a.time_ticks, b.time_ticks)
99 SortCommand -> string.compare(a.command, b.command)
100 }
101 })
102}
103
104fn float_ord(a: Float, b: Float) -> Order {
105 case a <. b {
106 True -> Lt
107 False ->
108 case a >. b {
109 True -> Gt
110 False -> Eq
111 }
112 }
113}
114
115/// Build a full frame as a list of etch commands.
116pub fn render(snap: Snapshot, view: ViewState) -> List(Command) {
117 let cols = int.max(view.cols, 60)
118 let rows = int.max(view.rows, 16)
119 let procs = visible_processes(view, snap)
120 let proc_count = list.length(procs)
121
122 // Layout: header(1) + cpu block + mem(2) + blank + table header + rows + footer
123 let core_count = list.length(snap.cores)
124 // CPU section: 1 line total + ceil(cores/cols_per_row) lines, max ~4 lines
125 let cores_per_row = case cols >= 120 {
126 True -> 4
127 False ->
128 case cols >= 90 {
129 True -> 3
130 False -> 2
131 }
132 }
133 let core_rows = case core_count {
134 0 -> 0
135 n -> {
136 let r = { n + cores_per_row - 1 } / cores_per_row
137 int.min(r, 4)
138 }
139 }
140 let header_lines = 1 + 1 + core_rows + 2 + 1
141 // header, cpu total, core rows, mem+swap, blank before table
142 let footer_lines = 1
143 let table_header = 1
144 let available = int.max(rows - header_lines - footer_lines - table_header, 3)
145 let scroll = clamp_scroll(view.scroll, view.selected, proc_count, available)
146 let selected = int.clamp(view.selected, 0, int.max(proc_count - 1, 0))
147
148 let view =
149 ViewState(
150 ..view,
151 scroll: scroll,
152 selected: selected,
153 cols: cols,
154 rows: rows,
155 )
156
157 let lines =
158 list.flatten([
159 [draw_header(snap, view, cols)],
160 [draw_cpu_total(snap, view, cols)],
161 draw_cores(snap, cores_per_row, core_rows, cols),
162 [draw_mem(snap, cols), draw_swap(snap, cols)],
163 [draw_table_header(view, cols)],
164 draw_process_rows(procs, view, available, cols),
165 pad_to(available - visible_row_count(procs, view, available), cols),
166 ])
167
168 let body =
169 list.index_map(lines, fn(line, i) {
170 [command.MoveTo(0, i), command.Print(line)]
171 })
172 |> list.flatten
173
174 let footer_y = rows - 1
175 let footer = draw_footer(snap, view, proc_count, cols)
176
177 let overlay = case view.mode {
178 Help -> help_overlay(cols, rows)
179 ConfirmKill -> kill_overlay(procs, view, cols, rows)
180 Filter -> []
181 Normal -> []
182 }
183
184 list.flatten([
185 [
186 command.HideCursor,
187 command.MoveTo(0, 0),
188 command.Clear(terminal.All),
189 ],
190 body,
191 [
192 command.MoveTo(0, footer_y),
193 command.Print(footer),
194 ],
195 overlay,
196 ])
197}
198
199fn clamp_scroll(scroll: Int, selected: Int, count: Int, height: Int) -> Int {
200 let scroll = case selected < scroll {
201 True -> selected
202 False -> scroll
203 }
204 let scroll = case selected >= scroll + height {
205 True -> selected - height + 1
206 False -> scroll
207 }
208 int.clamp(scroll, 0, int.max(0, count - height))
209}
210
211fn visible_row_count(
212 procs: List(Process),
213 view: ViewState,
214 height: Int,
215) -> Int {
216 let n = list.length(procs)
217 int.min(height, int.max(0, n - view.scroll))
218}
219
220fn pad_to(n: Int, cols: Int) -> List(String) {
221 case n <= 0 {
222 True -> []
223 False -> list.repeat(string.repeat(" ", cols), n)
224 }
225}
226
227fn draw_header(snap: Snapshot, view: ViewState, cols: Int) -> String {
228 let title = style_bold(style_cyan(" monitor "))
229 let host = style_dim(snap.hostname)
230 let up = "up " <> format.format_uptime(snap.uptime_secs)
231 let load = "load " <> format.format_load(snap.load1, snap.load5, snap.load15)
232 let tasks =
233 int.to_string(snap.process_count)
234 <> " procs · "
235 <> int.to_string(snap.thread_count)
236 <> " thr"
237 let sort =
238 "sort:"
239 <> sort_label(view.sort)
240 <> case view.reverse {
241 True -> "↓"
242 False -> "↑"
243 }
244 let mid = host <> " " <> up <> " " <> load <> " " <> tasks
245 let right = sort
246 let left = title
247 // approx without ansi length — simple pad using raw segments
248 let raw_left = " monitor "
249 let raw_mid = snap.hostname <> " " <> up <> " " <> load <> " " <> tasks
250 let raw_right = "sort:" <> sort_label(view.sort) <> "↓"
251 let gap =
252 int.max(
253 1,
254 cols
255 - string.length(raw_left)
256 - string.length(raw_mid)
257 - string.length(raw_right)
258 - 2,
259 )
260 left
261 <> style_dim(string.repeat("─", gap / 2))
262 <> " "
263 <> style_dim(mid)
264 <> " "
265 <> style_dim(string.repeat("─", gap - gap / 2))
266 <> " "
267 <> style_yellow(right)
268 <> reset()
269 |> fit_line(cols)
270}
271
272fn draw_cpu_total(snap: Snapshot, view: ViewState, cols: Int) -> String {
273 let label = style_bold(" CPU ")
274 let bar_w = int.clamp(cols / 3, 20, 40)
275 let bar = colored_meter(snap.cpu_total_percent, bar_w)
276 let pct =
277 style_for_band(
278 snap.cpu_total_percent,
279 format.percent(snap.cpu_total_percent),
280 )
281 let spark_w = int.clamp(cols - bar_w - 18, 8, 40)
282 let spark = style_cyan(format.sparkline(view.cpu_history, spark_w))
283 label
284 <> bar
285 <> " "
286 <> pct
287 <> " "
288 <> spark
289 <> reset()
290 |> fit_line(cols)
291}
292
293fn draw_cores(
294 snap: Snapshot,
295 per_row: Int,
296 max_rows: Int,
297 cols: Int,
298) -> List(String) {
299 let cores = list.take(snap.cores, max_rows * per_row)
300 let cell_w = int.max(12, { cols - 1 } / per_row)
301 list.sized_chunk(cores, per_row)
302 |> list.map(fn(row) {
303 row
304 |> list.map(fn(c) {
305 let name = string.replace(c.name, "cpu", "")
306 let label = style_dim(format.pad_left(name, 2) <> " ")
307 let bar_w = int.max(4, cell_w - 9)
308 let bar = colored_meter(c.percent, bar_w)
309 let pct =
310 style_for_band(
311 c.percent,
312 format.fit_right(format.percent_short(c.percent), 5),
313 )
314 format.fit(label <> bar <> pct, cell_w)
315 })
316 |> string.concat
317 |> fit_line(cols)
318 })
319}
320
321fn draw_mem(snap: Snapshot, cols: Int) -> String {
322 let m = snap.mem
323 let label = style_bold(" MEM ")
324 let bar_w = int.clamp(cols / 3, 20, 40)
325 let bar = colored_meter(m.used_percent, bar_w)
326 let pct = style_for_band(m.used_percent, format.percent(m.used_percent))
327 let detail =
328 style_dim(
329 format.human_kb(m.used_kb)
330 <> "/"
331 <> format.human_kb(m.total_kb)
332 <> " free "
333 <> format.human_kb(m.free_kb)
334 <> " cache "
335 <> format.human_kb(m.cached_kb),
336 )
337 label
338 <> bar
339 <> " "
340 <> pct
341 <> " "
342 <> detail
343 <> reset()
344 |> fit_line(cols)
345}
346
347fn draw_swap(snap: Snapshot, cols: Int) -> String {
348 let m = snap.mem
349 let label = style_bold(" SWP ")
350 let bar_w = int.clamp(cols / 3, 20, 40)
351 let bar = colored_meter(m.swap_percent, bar_w)
352 let pct = style_for_band(m.swap_percent, format.percent(m.swap_percent))
353 let detail =
354 style_dim(
355 format.human_kb(m.swap_used_kb) <> "/" <> format.human_kb(m.swap_total_kb),
356 )
357 label
358 <> bar
359 <> " "
360 <> pct
361 <> " "
362 <> detail
363 <> reset()
364 |> fit_line(cols)
365}
366
367fn draw_table_header(view: ViewState, cols: Int) -> String {
368 let mark = fn(key: SortKey, label: String) {
369 case view.sort == key {
370 True -> style_bold(style_yellow(label))
371 False -> style_bold(label)
372 }
373 }
374 let line =
375 mark(SortPid, format.fit_right("PID", 7))
376 <> " "
377 <> format.fit("USER", 9)
378 <> " "
379 <> format.fit_right("PRI", 3)
380 <> " "
381 <> format.fit_right("NI", 3)
382 <> " "
383 <> format.fit_right("VIRT", 7)
384 <> " "
385 <> format.fit_right("RES", 7)
386 <> " "
387 <> "S"
388 <> " "
389 <> mark(SortCpu, format.fit_right("CPU%", 6))
390 <> " "
391 <> mark(SortMem, format.fit_right("MEM%", 6))
392 <> " "
393 <> mark(SortTime, format.fit_right("TIME+", 9))
394 <> " "
395 <> mark(SortCommand, "Command")
396 style_on_bg(line, style.Rgb(30, 30, 46))
397 <> reset()
398 |> fit_line(cols)
399}
400
401fn draw_process_rows(
402 procs: List(Process),
403 view: ViewState,
404 height: Int,
405 cols: Int,
406) -> List(String) {
407 procs
408 |> list.drop(view.scroll)
409 |> list.take(height)
410 |> list.index_map(fn(p, i) {
411 let idx = view.scroll + i
412 let selected = idx == view.selected
413 draw_process(p, selected, cols)
414 })
415}
416
417fn draw_process(p: Process, selected: Bool, cols: Int) -> String {
418 let cmd_w = int.max(10, cols - 70)
419 let base =
420 format.fit_right(int.to_string(p.pid), 7)
421 <> " "
422 <> format.fit(p.user, 9)
423 <> " "
424 <> format.fit_right(int.to_string(p.priority), 3)
425 <> " "
426 <> format.fit_right(int.to_string(p.nice), 3)
427 <> " "
428 <> format.fit_right(format.human_kb(p.virt_kb), 7)
429 <> " "
430 <> format.fit_right(format.human_kb(p.res_kb), 7)
431 <> " "
432 <> state_color(p.state)
433 <> " "
434 <> style_for_band(
435 p.cpu_percent,
436 format.fit_right(format.percent(p.cpu_percent), 6),
437 )
438 <> " "
439 <> style_for_band(
440 p.mem_percent,
441 format.fit_right(format.percent(p.mem_percent), 6),
442 )
443 <> " "
444 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9)
445 <> " "
446 <> format.fit(p.command, cmd_w)
447
448 case selected {
449 True ->
450 style_on_bg(strip_for_select(p, cmd_w), style.Rgb(69, 71, 90))
451 <> reset()
452 |> fit_line(cols)
453 False -> base <> reset() |> fit_line(cols)
454 }
455}
456
457fn strip_for_select(p: Process, cmd_w: Int) -> String {
458 format.fit_right(int.to_string(p.pid), 7)
459 <> " "
460 <> format.fit(p.user, 9)
461 <> " "
462 <> format.fit_right(int.to_string(p.priority), 3)
463 <> " "
464 <> format.fit_right(int.to_string(p.nice), 3)
465 <> " "
466 <> format.fit_right(format.human_kb(p.virt_kb), 7)
467 <> " "
468 <> format.fit_right(format.human_kb(p.res_kb), 7)
469 <> " "
470 <> p.state
471 <> " "
472 <> format.fit_right(format.percent(p.cpu_percent), 6)
473 <> " "
474 <> format.fit_right(format.percent(p.mem_percent), 6)
475 <> " "
476 <> format.fit_right(procfs.ticks_to_time(p.time_ticks), 9)
477 <> " "
478 <> format.fit(p.command, cmd_w)
479}
480
481fn draw_footer(
482 snap: Snapshot,
483 view: ViewState,
484 visible: Int,
485 cols: Int,
486) -> String {
487 let text = case view.mode {
488 Filter -> " Filter: " <> view.filter <> "█ (Enter apply · Esc cancel)"
489 ConfirmKill -> " Confirm kill? [y/N]"
490 Help -> " Help · press any key to close"
491 Normal ->
492 " [q]uit [↑↓/uj] move [PgUp/Dn] [/]filter [c/m/p/t/n]sort [r]ev [k]ill [h]elp · "
493 <> int.to_string(visible)
494 <> "/"
495 <> int.to_string(snap.process_count)
496 <> " shown"
497 }
498 style_on_bg(format.fit(text, cols), style.Rgb(30, 30, 46))
499 <> reset()
500 |> fit_line(cols)
501}
502
503fn help_overlay(cols: Int, rows: Int) -> List(Command) {
504 let w = int.min(56, cols - 4)
505 let h = 14
506 let x = int.max(0, { cols - w } / 2)
507 let y = int.max(1, { rows - h } / 2)
508 let lines = [
509 "┌" <> string.repeat("─", w - 2) <> "┐",
510 "│" <> format.fit(" monitor — htop × btop in Gleam", w - 2) <> "│",
511 "│" <> format.fit("", w - 2) <> "│",
512 "│" <> format.fit(" Navigation", w - 2) <> "│",
513 "│" <> format.fit(" ↑/u ↓/j move selection", w - 2) <> "│",
514 "│" <> format.fit(" PgUp/PgDn page · g/G top/end", w - 2) <> "│",
515 "│" <> format.fit(" Sorting & filter", w - 2) <> "│",
516 "│" <> format.fit(" c/m/p/t/n CPU MEM PID TIME name", w - 2) <> "│",
517 "│" <> format.fit(" r reverse · / filter", w - 2) <> "│",
518 "│" <> format.fit(" Actions", w - 2) <> "│",
519 "│" <> format.fit(" k kill selected (SIGTERM)", w - 2) <> "│",
520 "│" <> format.fit(" q / Esc quit", w - 2) <> "│",
521 "│" <> format.fit(" Meters from btop · table from htop", w - 2) <> "│",
522 "└" <> string.repeat("─", w - 2) <> "┘",
523 ]
524 list.index_map(lines, fn(line, i) {
525 [
526 command.MoveTo(x, y + i),
527 command.SetStyle(
528 style.Style(
529 fg: style.BrightWhite,
530 bg: style.Rgb(24, 24, 37),
531 attributes: [style.Bold],
532 ),
533 ),
534 command.Print(format.fit(line, w)),
535 command.ResetStyle,
536 ]
537 })
538 |> list.flatten
539}
540
541fn kill_overlay(
542 procs: List(Process),
543 view: ViewState,
544 cols: Int,
545 rows: Int,
546) -> List(Command) {
547 let msg = case list_at(procs, view.selected) {
548 Ok(p) ->
549 " Kill PID "
550 <> int.to_string(p.pid)
551 <> " ("
552 <> format.truncate(p.command, 30)
553 <> ")? [y/N] "
554 Error(_) -> " No process selected "
555 }
556 let w = int.min(string.length(msg) + 4, cols - 2)
557 let x = int.max(0, { cols - w } / 2)
558 let y = rows / 2
559 [
560 command.MoveTo(x, y),
561 command.SetStyle(
562 style.Style(fg: style.Black, bg: style.BrightYellow, attributes: [
563 style.Bold,
564 ]),
565 ),
566 command.Print(format.fit(msg, w)),
567 command.ResetStyle,
568 ]
569}
570
571fn list_at(items: List(a), index: Int) -> Result(a, Nil) {
572 case list.drop(items, index) {
573 [x, ..] -> Ok(x)
574 [] -> Error(Nil)
575 }
576}
577
578fn colored_meter(percent: Float, width: Int) -> String {
579 let bar = format.meter_fine(percent, width)
580 style_for_band(percent, bar)
581}
582
583fn style_for_band(percent: Float, s: String) -> String {
584 case format.band(percent) {
585 format.Low -> style_green(s)
586 format.Mid -> style_yellow(s)
587 format.High -> style_magenta(s)
588 format.Critical -> style_red(s)
589 }
590}
591
592fn state_color(s: String) -> String {
593 case s {
594 "R" -> style_green("R")
595 "S" -> style_dim("S")
596 "D" -> style_red("D")
597 "Z" -> style_red("Z")
598 "T" | "t" -> style_yellow("T")
599 _ -> s
600 }
601}
602
603fn sort_label(k: SortKey) -> String {
604 case k {
605 SortCpu -> "CPU"
606 SortMem -> "MEM"
607 SortPid -> "PID"
608 SortTime -> "TIME"
609 SortCommand -> "CMD"
610 }
611}
612
613fn fit_line(s: String, _cols: Int) -> String {
614 // ANSI length ≠ display length; leave padding to the terminal clear.
615 s <> reset()
616}
617
618// ─── ANSI helpers via etch/style ───────────────────────────────────────────
619
620fn reset() -> String {
621 style.reset_style("")
622}
623
624fn style_bold(s: String) -> String {
625 style.attributes(s, [style.Bold])
626}
627
628fn style_dim(s: String) -> String {
629 style.attributes(s, [style.Dim])
630}
631
632fn style_cyan(s: String) -> String {
633 style.with(s, style.Cyan)
634}
635
636fn style_green(s: String) -> String {
637 style.with(s, style.Green)
638}
639
640fn style_yellow(s: String) -> String {
641 style.with(s, style.Yellow)
642}
643
644fn style_red(s: String) -> String {
645 style.with(s, style.Red)
646}
647
648fn style_magenta(s: String) -> String {
649 style.with(s, style.Magenta)
650}
651
652fn style_on_bg(s: String, bg: style.Color) -> String {
653 style.on(s, bg)
654}
655
656pub fn selected_process(
657 view: ViewState,
658 snap: Snapshot,
659) -> Result(Process, Nil) {
660 let procs = visible_processes(view, snap)
661 list_at(procs, view.selected)
662}