procm-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ createCliRenderer,
4
+ BoxRenderable,
5
+ TextRenderable,
6
+ TextAttributes,
7
+ type KeyEvent,
8
+ } from "@opentui/core"
9
+ import {
10
+ type Process,
11
+ parseProcessLine,
12
+ c,
13
+ pad,
14
+ rpad,
15
+ cpuColor,
16
+ memColor,
17
+ statLabel,
18
+ statColor,
19
+ sortFields,
20
+ type SortField,
21
+ sortProcs,
22
+ } from "./lib"
23
+
24
+ // ── Process data ───────────────────────────────────────────────────────────────
25
+
26
+ async function getProcesses(): Promise<Process[]> {
27
+ const proc = Bun.spawn(
28
+ ["ps", "-eo", "pid,ppid,pcpu,pmem,stat,tty,lstart,command"],
29
+ { stdout: "pipe", stderr: "ignore" },
30
+ )
31
+ const text = await new Response(proc.stdout).text()
32
+ const lines = text.split("\n")
33
+
34
+ const processes: Process[] = []
35
+ const selfPid = process.pid
36
+
37
+ for (let i = 1; i < lines.length; i++) {
38
+ const p = parseProcessLine(lines[i])
39
+ if (p && p.pid !== selfPid) processes.push(p)
40
+ }
41
+
42
+ return processes
43
+ }
44
+
45
+ // ── Columns ────────────────────────────────────────────────────────────────────
46
+
47
+ const cols = [
48
+ { label: "PID", w: 7, right: true, val: (p: Process) => String(p.pid), color: (_: Process) => c.cyan },
49
+ { label: "PPID", w: 7, right: true, val: (p: Process) => String(p.ppid), color: (_: Process) => c.dim },
50
+ { label: "CPU%", w: 7, right: true, val: (p: Process) => p.cpu.toFixed(1), color: (p: Process) => cpuColor(p.cpu) },
51
+ { label: "MEM%", w: 7, right: true, val: (p: Process) => p.mem.toFixed(1), color: (p: Process) => memColor(p.mem) },
52
+ { label: "STATUS", w: 10, right: false, val: (p: Process) => statLabel(p.stat), color: (p: Process) => statColor(p.stat) },
53
+ { label: "COMMAND", w: 0, right: false, val: (p: Process) => p.command, color: (_: Process) => c.text },
54
+ ]
55
+
56
+ // ── Sort state ─────────────────────────────────────────────────────────────────
57
+
58
+ let sortBy: SortField = "cpu"
59
+ let sortAsc = false
60
+
61
+ // ── Main ───────────────────────────────────────────────────────────────────────
62
+
63
+ interface RowHandle {
64
+ box: BoxRenderable
65
+ cells: TextRenderable[]
66
+ }
67
+
68
+ async function main() {
69
+ const renderer = await createCliRenderer({ exitOnCtrlC: true })
70
+
71
+ let displayed: Process[] = []
72
+ let selected = 0
73
+ let scrollOffset = 0
74
+ let filter = ""
75
+ let filtering = false
76
+ let confirm: { pid: number; cmd: string; action: string } | null = null
77
+ let refreshing = false
78
+ let destroyed = false
79
+
80
+ // Chrome rows: title(1) + sort(1) + header(1) + filter(1) + footer(1) = 5
81
+ const CHROME = 5
82
+ let viewportH = renderer.height - CHROME
83
+
84
+ // ── Layout ─────────────────────────────────────────────────────────────────
85
+
86
+ const root = renderer.root
87
+ root.backgroundColor = c.bg
88
+ root.flexDirection = "column"
89
+
90
+ // Title bar
91
+ const titleBar = new BoxRenderable(renderer, {
92
+ id: "title-bar",
93
+ flexDirection: "row",
94
+ justifyContent: "space-between",
95
+ alignItems: "center",
96
+ height: 1,
97
+ paddingLeft: 1,
98
+ paddingRight: 1,
99
+ backgroundColor: c.blue,
100
+ })
101
+ const titleText = new TextRenderable(renderer, {
102
+ id: "title",
103
+ content: " PROCM ",
104
+ fg: c.bg,
105
+ attributes: TextAttributes.BOLD,
106
+ })
107
+ const statsText = new TextRenderable(renderer, {
108
+ id: "stats",
109
+ content: "",
110
+ fg: c.bg,
111
+ })
112
+ titleBar.add(titleText)
113
+ titleBar.add(statsText)
114
+
115
+ // Sort bar
116
+ const sortBar = new BoxRenderable(renderer, {
117
+ id: "sort-bar",
118
+ height: 1,
119
+ paddingLeft: 1,
120
+ backgroundColor: c.bgAlt,
121
+ })
122
+ const sortText = new TextRenderable(renderer, {
123
+ id: "sort-text",
124
+ content: "",
125
+ fg: c.muted,
126
+ })
127
+ sortBar.add(sortText)
128
+
129
+ // Column headers
130
+ const headerRow = new BoxRenderable(renderer, {
131
+ id: "hdr",
132
+ flexDirection: "row",
133
+ height: 1,
134
+ paddingLeft: 1,
135
+ paddingRight: 1,
136
+ backgroundColor: c.bgAlt,
137
+ gap: 1,
138
+ })
139
+ for (const col of cols) {
140
+ headerRow.add(
141
+ new TextRenderable(renderer, {
142
+ id: `hdr-${col.label}`,
143
+ content: col.w > 0
144
+ ? (col.right ? rpad(col.label, col.w) : pad(col.label, col.w))
145
+ : col.label,
146
+ fg: c.yellow,
147
+ attributes: TextAttributes.BOLD,
148
+ ...(col.w > 0 ? { width: col.w } : { flexGrow: 1 }),
149
+ }),
150
+ )
151
+ }
152
+
153
+ // Virtual list area (plain Box, not ScrollBox)
154
+ const listBox = new BoxRenderable(renderer, {
155
+ id: "list",
156
+ flexGrow: 1,
157
+ flexDirection: "column",
158
+ backgroundColor: c.bg,
159
+ })
160
+
161
+ // Filter bar
162
+ const filterBar = new BoxRenderable(renderer, {
163
+ id: "filter-bar",
164
+ height: 1,
165
+ paddingLeft: 1,
166
+ backgroundColor: c.bgAlt,
167
+ })
168
+ const filterText = new TextRenderable(renderer, {
169
+ id: "filter-text",
170
+ content: "",
171
+ fg: c.text,
172
+ })
173
+ filterBar.add(filterText)
174
+
175
+ // Confirm bar
176
+ const confirmBar = new BoxRenderable(renderer, {
177
+ id: "confirm-bar",
178
+ height: 1,
179
+ paddingLeft: 1,
180
+ backgroundColor: c.red,
181
+ visible: false,
182
+ })
183
+ const confirmText = new TextRenderable(renderer, {
184
+ id: "confirm-text",
185
+ content: "",
186
+ fg: c.bg,
187
+ attributes: TextAttributes.BOLD,
188
+ })
189
+ confirmBar.add(confirmText)
190
+
191
+ // Footer
192
+ const footerBar = new BoxRenderable(renderer, {
193
+ id: "footer",
194
+ flexDirection: "row",
195
+ height: 1,
196
+ paddingLeft: 1,
197
+ backgroundColor: c.bgAlt,
198
+ gap: 2,
199
+ })
200
+
201
+ for (const [key, label] of [
202
+ ["j/k", "Nav"],
203
+ ["/", "Filter"],
204
+ ["s", "Sort"],
205
+ ["K", "Kill"],
206
+ ["t", "Term"],
207
+ ["r", "Restart"],
208
+ ["q", "Quit"],
209
+ ]) {
210
+ const kb = new BoxRenderable(renderer, {
211
+ id: `kb-${key}`,
212
+ flexDirection: "row",
213
+ })
214
+ kb.add(
215
+ new TextRenderable(renderer, {
216
+ id: `kk-${key}`,
217
+ content: ` ${key} `,
218
+ fg: c.bg,
219
+ bg: c.border,
220
+ attributes: TextAttributes.BOLD,
221
+ }),
222
+ )
223
+ kb.add(
224
+ new TextRenderable(renderer, {
225
+ id: `kl-${key}`,
226
+ content: ` ${label}`,
227
+ fg: c.dim,
228
+ }),
229
+ )
230
+ footerBar.add(kb)
231
+ }
232
+
233
+ root.add(titleBar)
234
+ root.add(sortBar)
235
+ root.add(headerRow)
236
+ root.add(listBox)
237
+ root.add(filterBar)
238
+ root.add(confirmBar)
239
+ root.add(footerBar)
240
+
241
+ // ── Virtual row pool (viewport-sized) ─────────────────────────────────────
242
+
243
+ const rowPool: RowHandle[] = []
244
+
245
+ function createRow(i: number): RowHandle {
246
+ const box = new BoxRenderable(renderer, {
247
+ id: `r-${i}`,
248
+ flexDirection: "row",
249
+ height: 1,
250
+ paddingLeft: 1,
251
+ paddingRight: 1,
252
+ backgroundColor: c.bg,
253
+ gap: 1,
254
+ })
255
+ const cells: TextRenderable[] = []
256
+ for (const col of cols) {
257
+ const cell = new TextRenderable(renderer, {
258
+ id: `c-${i}-${col.label}`,
259
+ content: "",
260
+ fg: c.text,
261
+ ...(col.w > 0 ? { width: col.w } : { flexGrow: 1 }),
262
+ })
263
+ box.add(cell)
264
+ cells.push(cell)
265
+ }
266
+ listBox.add(box)
267
+ return { box, cells }
268
+ }
269
+
270
+ function ensurePoolSize(n: number) {
271
+ while (rowPool.length < n) {
272
+ rowPool.push(createRow(rowPool.length))
273
+ }
274
+ }
275
+
276
+ // Pre-allocate rows for initial viewport
277
+ ensurePoolSize(viewportH)
278
+
279
+ // ── Scroll helpers ────────────────────────────────────────────────────────
280
+
281
+ function clampScroll() {
282
+ const maxOffset = Math.max(0, displayed.length - viewportH)
283
+ if (scrollOffset > maxOffset) scrollOffset = maxOffset
284
+ if (scrollOffset < 0) scrollOffset = 0
285
+ }
286
+
287
+ function ensureVisible() {
288
+ if (selected < scrollOffset) scrollOffset = selected
289
+ else if (selected >= scrollOffset + viewportH)
290
+ scrollOffset = selected - viewportH + 1
291
+ clampScroll()
292
+ }
293
+
294
+ // ── Render helpers ─────────────────────────────────────────────────────────
295
+
296
+ function updateUI() {
297
+ const arrow = sortAsc ? "▲" : "▼"
298
+ sortText.content = `Sort: ${sortBy.toUpperCase()} ${arrow} (s to cycle, S to flip)`
299
+
300
+ if (filtering) {
301
+ filterText.content = `Filter: ${filter}█`
302
+ filterText.fg = c.yellow
303
+ } else if (filter) {
304
+ filterText.content = `Filter: ${filter} (/ to edit, Esc to clear)`
305
+ filterText.fg = c.muted
306
+ } else {
307
+ filterText.content = ""
308
+ }
309
+
310
+ const pos = displayed.length > 0
311
+ ? `${selected + 1}/${displayed.length}`
312
+ : "0"
313
+ statsText.content = `${pos} | ${new Date().toLocaleTimeString()}`
314
+ }
315
+
316
+ function renderRows() {
317
+ ensureVisible()
318
+ const visibleCount = Math.min(viewportH, displayed.length - scrollOffset)
319
+ ensurePoolSize(viewportH)
320
+
321
+ for (let vi = 0; vi < viewportH; vi++) {
322
+ const row = rowPool[vi]
323
+ if (vi >= visibleCount) {
324
+ row.box.visible = false
325
+ continue
326
+ }
327
+
328
+ const di = scrollOffset + vi // data index
329
+ const p = displayed[di]
330
+ const sel = di === selected
331
+ const bg = sel ? c.bgHl : di % 2 === 0 ? c.bg : c.bgAlt
332
+
333
+ row.box.visible = true
334
+ row.box.backgroundColor = bg
335
+
336
+ for (let j = 0; j < cols.length; j++) {
337
+ const col = cols[j]
338
+ const raw = col.val(p)
339
+ const txt = col.w > 0
340
+ ? (col.right ? rpad(raw, col.w) : pad(raw, col.w))
341
+ : raw
342
+
343
+ row.cells[j].content = txt
344
+ row.cells[j].fg = sel ? "#ffffff" : col.color(p)
345
+ row.cells[j].attributes = sel ? TextAttributes.BOLD : 0
346
+ }
347
+ }
348
+ }
349
+
350
+ async function refresh() {
351
+ if (refreshing || destroyed) return
352
+ refreshing = true
353
+ try {
354
+ const procs = await getProcesses()
355
+ const sorted = sortProcs(procs, sortBy, sortAsc)
356
+
357
+ if (filter) {
358
+ const f = filter.toLowerCase()
359
+ displayed = sorted.filter(
360
+ (p) =>
361
+ p.command.toLowerCase().includes(f) ||
362
+ String(p.pid).includes(f) ||
363
+ statLabel(p.stat).toLowerCase().includes(f),
364
+ )
365
+ } else {
366
+ displayed = sorted
367
+ }
368
+
369
+ if (selected >= displayed.length) {
370
+ selected = Math.max(0, displayed.length - 1)
371
+ }
372
+
373
+ updateUI()
374
+ renderRows()
375
+ } finally {
376
+ refreshing = false
377
+ }
378
+ }
379
+
380
+ // ── Keyboard ───────────────────────────────────────────────────────────────
381
+
382
+ renderer.keyInput.on("keypress", async (key: KeyEvent) => {
383
+ // Confirm mode
384
+ if (confirm) {
385
+ if (key.name === "y") {
386
+ try {
387
+ const sig = confirm.action === "kill" ? "-9" : "-15"
388
+ Bun.spawn(["kill", sig, String(confirm.pid)], {
389
+ stdout: "ignore",
390
+ stderr: "ignore",
391
+ })
392
+ if (confirm.action === "restart") {
393
+ await Bun.sleep(500)
394
+ Bun.spawn(["sh", "-c", confirm.cmd], {
395
+ stdout: "ignore",
396
+ stderr: "ignore",
397
+ stdin: "ignore",
398
+ })
399
+ }
400
+ } catch {}
401
+ confirm = null
402
+ confirmBar.visible = false
403
+ await refresh()
404
+ } else {
405
+ confirm = null
406
+ confirmBar.visible = false
407
+ }
408
+ return
409
+ }
410
+
411
+ // Filter mode
412
+ if (filtering) {
413
+ if (key.name === "return") {
414
+ filtering = false
415
+ updateUI()
416
+ await refresh()
417
+ } else if (key.name === "escape") {
418
+ filtering = false
419
+ filter = ""
420
+ updateUI()
421
+ await refresh()
422
+ } else if (key.name === "backspace") {
423
+ filter = filter.slice(0, -1)
424
+ updateUI()
425
+ await refresh()
426
+ } else if (key.sequence?.length === 1 && !key.ctrl && !key.meta) {
427
+ filter += key.sequence
428
+ updateUI()
429
+ await refresh()
430
+ }
431
+ return
432
+ }
433
+
434
+ // Normal mode
435
+ switch (key.name) {
436
+ case "q":
437
+ destroyed = true
438
+ renderer.destroy()
439
+ process.exit(0)
440
+ break
441
+
442
+ case "j":
443
+ case "down":
444
+ if (selected < displayed.length - 1) {
445
+ selected++
446
+ updateUI()
447
+ renderRows()
448
+ }
449
+ break
450
+
451
+ case "k":
452
+ case "up":
453
+ if (selected > 0) {
454
+ selected--
455
+ updateUI()
456
+ renderRows()
457
+ }
458
+ break
459
+
460
+ case "d":
461
+ selected = Math.min(displayed.length - 1, selected + Math.floor(viewportH / 2))
462
+ updateUI()
463
+ renderRows()
464
+ break
465
+
466
+ case "u":
467
+ selected = Math.max(0, selected - Math.floor(viewportH / 2))
468
+ updateUI()
469
+ renderRows()
470
+ break
471
+
472
+ case "g":
473
+ selected = 0
474
+ updateUI()
475
+ renderRows()
476
+ break
477
+
478
+ case "G":
479
+ selected = Math.max(0, displayed.length - 1)
480
+ updateUI()
481
+ renderRows()
482
+ break
483
+
484
+ case "/":
485
+ filtering = true
486
+ updateUI()
487
+ break
488
+
489
+ case "s": {
490
+ const i = sortFields.indexOf(sortBy)
491
+ sortBy = sortFields[(i + 1) % sortFields.length]
492
+ sortAsc = sortBy === "pid" || sortBy === "command"
493
+ await refresh()
494
+ break
495
+ }
496
+
497
+ case "S":
498
+ sortAsc = !sortAsc
499
+ await refresh()
500
+ break
501
+
502
+ case "K": {
503
+ const p = displayed[selected]
504
+ if (p) {
505
+ confirm = { pid: p.pid, cmd: p.command, action: "kill" }
506
+ confirmText.content = `SIGKILL PID ${p.pid} (${p.command.slice(0, 50)})? [y/n]`
507
+ confirmBar.visible = true
508
+ }
509
+ break
510
+ }
511
+
512
+ case "t": {
513
+ const p = displayed[selected]
514
+ if (p) {
515
+ confirm = { pid: p.pid, cmd: p.command, action: "term" }
516
+ confirmText.content = `SIGTERM PID ${p.pid} (${p.command.slice(0, 50)})? [y/n]`
517
+ confirmBar.visible = true
518
+ }
519
+ break
520
+ }
521
+
522
+ case "r": {
523
+ const p = displayed[selected]
524
+ if (p) {
525
+ confirm = { pid: p.pid, cmd: p.command, action: "restart" }
526
+ confirmText.content = `Restart PID ${p.pid} (${p.command.slice(0, 50)})? [y/n]`
527
+ confirmBar.visible = true
528
+ }
529
+ break
530
+ }
531
+ }
532
+ })
533
+
534
+ // ── Start ──────────────────────────────────────────────────────────────────
535
+
536
+ renderer.on("destroy", () => { destroyed = true })
537
+
538
+ await refresh()
539
+ setInterval(async () => {
540
+ if (!filtering && !confirm && !destroyed) await refresh()
541
+ }, 2000)
542
+ }
543
+
544
+ main().catch((err) => {
545
+ console.error(err)
546
+ process.exit(1)
547
+ })