Terminal system monitor in Gleam — htop × btop
1//// Main application loop: sample /proc, render, handle keys.
2
3import etch/command
4import etch/erlang/input
5import etch/erlang/tty
6import etch/event.{
7 type Event, type KeyCode, Backspace, Char, DownArrow, End, Enter, Esc, Home,
8 Key, PageDown, PageUp, UpArrow,
9}
10import etch/stdout
11import etch/terminal
12import gleam/dict
13import gleam/erlang/process
14import gleam/int
15import gleam/list
16import gleam/option.{None, Some}
17import gleam/string
18import procfs.{type Snapshot}
19import ui.{
20 type SortKey, type ViewState, ConfirmKill, Filter, Help, Normal, SortCommand,
21 SortCpu, SortMem, SortPid, SortTime,
22}
23
24const refresh_ms = 1000
25
26@external(erlang, "erlang", "halt")
27fn halt(code: Int) -> Nil
28
29@external(erlang, "os", "cmd")
30fn os_cmd(command: String) -> String
31
32pub fn run() -> Nil {
33 let #(cols, rows) = window_size()
34 let assert Ok(_) = tty.enter_raw()
35 stdout.execute([
36 command.EnterAlternateScreen,
37 command.HideCursor,
38 command.Clear(terminal.All),
39 command.DisableLineWrap,
40 command.SetTitle("monitor"),
41 ])
42
43 // Seed samples so first painted frame has real CPU %
44 let prev_cores = dict.new()
45 let prev_ticks = dict.new()
46 let snap0 = procfs.sample_with_core_history(0, 0, prev_cores, prev_ticks)
47 process.sleep(200)
48 let snap =
49 procfs.sample_with_core_history(
50 snap0.prev_cpu_total,
51 snap0.prev_cpu_idle,
52 procfs.core_history_from(snap0),
53 snap0.prev_proc_ticks,
54 )
55
56 let view =
57 ui.initial_view(cols, rows)
58 |> ui.push_history(snap)
59
60 loop(snap, view)
61}
62
63fn loop(snap: Snapshot, view: ViewState) -> Nil {
64 let #(cols, rows) = window_size()
65 let view = case cols != view.cols || rows != view.rows {
66 True -> ui.ViewState(..view, cols: cols, rows: rows)
67 False -> view
68 }
69
70 stdout.execute(ui.render(snap, view))
71
72 let #(snap2, view2, quit) = handle_input_window(snap, view, refresh_ms)
73 case quit {
74 True -> cleanup()
75 False -> {
76 let view2 = ui.push_history(view2, snap2)
77 loop(snap2, view2)
78 }
79 }
80}
81
82/// Poll for keys up to `budget_ms`, then refresh system stats.
83fn handle_input_window(
84 snap: Snapshot,
85 view: ViewState,
86 budget_ms: Int,
87) -> #(Snapshot, ViewState, Bool) {
88 let step = 50
89 handle_input_window_go(snap, view, budget_ms, step)
90}
91
92fn handle_input_window_go(
93 snap: Snapshot,
94 view: ViewState,
95 remaining: Int,
96 step: Int,
97) -> #(Snapshot, ViewState, Bool) {
98 case remaining <= 0 {
99 True -> {
100 let snap2 = refresh(snap)
101 #(snap2, view, False)
102 }
103 False -> {
104 case input.poll(0) {
105 Some(Ok(ev)) ->
106 case apply_event(snap, view, ev) {
107 #(snap2, view2, True) -> #(snap2, view2, True)
108 #(snap2, view2, False) -> {
109 // Immediate redraw on keypress for snappy feel
110 stdout.execute(ui.render(snap2, view2))
111 handle_input_window_go(snap2, view2, remaining - step, step)
112 }
113 }
114 Some(Error(_)) -> {
115 process.sleep(step)
116 handle_input_window_go(snap, view, remaining - step, step)
117 }
118 None -> {
119 process.sleep(step)
120 handle_input_window_go(snap, view, remaining - step, step)
121 }
122 }
123 }
124 }
125}
126
127fn refresh(snap: Snapshot) -> Snapshot {
128 procfs.sample_with_core_history(
129 snap.prev_cpu_total,
130 snap.prev_cpu_idle,
131 procfs.core_history_from(snap),
132 snap.prev_proc_ticks,
133 )
134}
135
136fn apply_event(
137 snap: Snapshot,
138 view: ViewState,
139 ev: Event,
140) -> #(Snapshot, ViewState, Bool) {
141 case ev {
142 Key(ke) -> apply_key(snap, view, ke.code, ke.modifiers.control)
143 _ -> #(snap, view, False)
144 }
145}
146
147fn apply_key(
148 snap: Snapshot,
149 view: ViewState,
150 code: KeyCode,
151 ctrl: Bool,
152) -> #(Snapshot, ViewState, Bool) {
153 case view.mode {
154 Help -> #(snap, ui.ViewState(..view, mode: Normal), False)
155
156 ConfirmKill ->
157 case code {
158 Char("y") | Char("Y") -> {
159 case ui.selected_process(view, snap) {
160 Ok(p) -> {
161 let _ =
162 os_cmd("kill -15 " <> int.to_string(p.pid) <> " 2>/dev/null")
163 Nil
164 }
165 Error(_) -> Nil
166 }
167 #(refresh(snap), ui.ViewState(..view, mode: Normal), False)
168 }
169 Char("n") | Char("N") | Esc -> #(
170 snap,
171 ui.ViewState(..view, mode: Normal),
172 False,
173 )
174 _ -> #(snap, view, False)
175 }
176
177 Filter ->
178 case code {
179 Esc -> #(
180 snap,
181 ui.ViewState(..view, mode: Normal, filter: "", selected: 0, scroll: 0),
182 False,
183 )
184 Enter -> #(
185 snap,
186 ui.ViewState(..view, mode: Normal, selected: 0, scroll: 0),
187 False,
188 )
189 Backspace -> {
190 let f = string.drop_end(view.filter, 1)
191 #(
192 snap,
193 ui.ViewState(..view, filter: f, selected: 0, scroll: 0),
194 False,
195 )
196 }
197 Char(c) ->
198 case string.byte_size(c) == 1 && !ctrl && is_printable(c) {
199 True -> #(
200 snap,
201 ui.ViewState(
202 ..view,
203 filter: view.filter <> c,
204 selected: 0,
205 scroll: 0,
206 ),
207 False,
208 )
209 False -> #(snap, view, False)
210 }
211 _ -> #(snap, view, False)
212 }
213
214 Normal ->
215 case code {
216 Char("q") | Char("Q") | Esc -> #(snap, view, True)
217 Char("h") | Char("?") -> #(
218 snap,
219 ui.ViewState(..view, mode: Help),
220 False,
221 )
222 Char("/") -> #(snap, ui.ViewState(..view, mode: Filter), False)
223 Char("k") | Char("K") ->
224 case ui.selected_process(view, snap) {
225 Ok(_) -> #(snap, ui.ViewState(..view, mode: ConfirmKill), False)
226 Error(_) -> #(snap, view, False)
227 }
228 Char("j") | DownArrow -> #(snap, move_sel(view, snap, 1), False)
229 Char("J") -> #(snap, move_sel(view, snap, 10), False)
230 UpArrow | Char("u") -> #(snap, move_sel(view, snap, -1), False)
231 PageDown -> #(snap, move_sel(view, snap, page(view)), False)
232 PageUp -> #(snap, move_sel(view, snap, -page(view)), False)
233 Home | Char("g") -> #(
234 snap,
235 ui.ViewState(..view, selected: 0, scroll: 0),
236 False,
237 )
238 End | Char("G") -> {
239 let n = list.length(ui.visible_processes(view, snap))
240 let sel = int.max(0, n - 1)
241 #(snap, ui.ViewState(..view, selected: sel), False)
242 }
243 Char("c") -> #(snap, set_sort(view, SortCpu), False)
244 Char("m") -> #(snap, set_sort(view, SortMem), False)
245 Char("p") -> #(snap, set_sort(view, SortPid), False)
246 Char("t") -> #(snap, set_sort(view, SortTime), False)
247 Char("n") -> #(snap, set_sort(view, SortCommand), False)
248 Char("r") -> #(
249 snap,
250 ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0),
251 False,
252 )
253 Char(" ") -> #(refresh(snap), view, False)
254 _ -> #(snap, view, False)
255 }
256 }
257}
258
259fn is_printable(c: String) -> Bool {
260 case string.to_utf_codepoints(c) {
261 [cp] -> {
262 let n = string.utf_codepoint_to_int(cp)
263 n >= 32 && n <= 126
264 }
265 _ -> False
266 }
267}
268
269fn set_sort(view: ViewState, key: SortKey) -> ViewState {
270 case view.sort == key {
271 True -> ui.ViewState(..view, reverse: !view.reverse, selected: 0, scroll: 0)
272 False ->
273 ui.ViewState(
274 ..view,
275 sort: key,
276 reverse: case key {
277 SortCpu | SortMem | SortTime -> True
278 SortPid | SortCommand -> False
279 },
280 selected: 0,
281 scroll: 0,
282 )
283 }
284}
285
286fn move_sel(view: ViewState, snap: Snapshot, delta: Int) -> ViewState {
287 let n = list.length(ui.visible_processes(view, snap))
288 let sel = int.clamp(view.selected + delta, 0, int.max(0, n - 1))
289 ui.ViewState(..view, selected: sel)
290}
291
292fn page(view: ViewState) -> Int {
293 int.max(5, view.rows / 2)
294}
295
296fn window_size() -> #(Int, Int) {
297 case tty.window_size() {
298 Ok(#(c, r)) -> #(c, r)
299 Error(_) -> #(100, 30)
300 }
301}
302
303fn cleanup() -> Nil {
304 stdout.execute([
305 command.EnableLineWrap,
306 command.ShowCursor,
307 command.Clear(terminal.All),
308 command.LeaveAlternateScreen,
309 ])
310 let _ = tty.exit_raw()
311 halt(0)
312}