silvery 0.3.0 → 0.4.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.
Files changed (120) hide show
  1. package/README.md +41 -145
  2. package/dist/chalk.js +3 -0
  3. package/dist/chalk.js.map +11 -0
  4. package/dist/index.js +340 -0
  5. package/dist/index.js.map +282 -0
  6. package/dist/ink.js +129 -0
  7. package/dist/ink.js.map +140 -0
  8. package/dist/runtime.js +394 -0
  9. package/dist/runtime.js.map +286 -0
  10. package/dist/theme.js +343 -0
  11. package/dist/theme.js.map +286 -0
  12. package/dist/ui/animation.js +3 -0
  13. package/dist/ui/animation.js.map +15 -0
  14. package/dist/ui/ansi.js +3 -0
  15. package/dist/ui/ansi.js.map +10 -0
  16. package/dist/ui/cli.js +8 -0
  17. package/dist/ui/cli.js.map +14 -0
  18. package/dist/ui/display.js +4 -0
  19. package/dist/ui/display.js.map +10 -0
  20. package/dist/ui/image.js +4 -0
  21. package/dist/ui/image.js.map +15 -0
  22. package/dist/ui/input.js +3 -0
  23. package/dist/ui/input.js.map +11 -0
  24. package/dist/ui/progress.js +8 -0
  25. package/dist/ui/progress.js.map +20 -0
  26. package/dist/ui/react.js +3 -0
  27. package/dist/ui/react.js.map +15 -0
  28. package/dist/ui/utils.js +3 -0
  29. package/dist/ui/utils.js.map +10 -0
  30. package/dist/ui/wrappers.js +14 -0
  31. package/dist/ui/wrappers.js.map +19 -0
  32. package/dist/ui.js +17 -0
  33. package/dist/ui.js.map +20 -0
  34. package/package.json +67 -15
  35. package/src/index.ts +67 -1
  36. package/src/runtime.ts +4 -0
  37. package/src/theme.ts +4 -0
  38. package/src/ui/animation.ts +2 -0
  39. package/src/ui/ansi.ts +2 -0
  40. package/src/ui/cli.ts +2 -0
  41. package/src/ui/display.ts +2 -0
  42. package/src/ui/image.ts +2 -0
  43. package/src/ui/input.ts +2 -0
  44. package/src/ui/progress.ts +2 -0
  45. package/src/ui/react.ts +2 -0
  46. package/src/ui/utils.ts +2 -0
  47. package/src/ui/wrappers.ts +2 -0
  48. package/src/ui.ts +4 -0
  49. package/examples/CLAUDE.md +0 -75
  50. package/examples/_banner.tsx +0 -60
  51. package/examples/cli.ts +0 -228
  52. package/examples/index.md +0 -101
  53. package/examples/inline/inline-nontty.tsx +0 -98
  54. package/examples/inline/inline-progress.tsx +0 -79
  55. package/examples/inline/inline-simple.tsx +0 -63
  56. package/examples/inline/scrollback.tsx +0 -185
  57. package/examples/interactive/_input-debug.tsx +0 -110
  58. package/examples/interactive/_stdin-test.ts +0 -71
  59. package/examples/interactive/_textarea-bare.tsx +0 -45
  60. package/examples/interactive/aichat/components.tsx +0 -468
  61. package/examples/interactive/aichat/index.tsx +0 -207
  62. package/examples/interactive/aichat/script.ts +0 -460
  63. package/examples/interactive/aichat/state.ts +0 -326
  64. package/examples/interactive/aichat/types.ts +0 -19
  65. package/examples/interactive/app-todo.tsx +0 -198
  66. package/examples/interactive/async-data.tsx +0 -208
  67. package/examples/interactive/cli-wizard.tsx +0 -332
  68. package/examples/interactive/clipboard.tsx +0 -183
  69. package/examples/interactive/components.tsx +0 -463
  70. package/examples/interactive/data-explorer.tsx +0 -506
  71. package/examples/interactive/dev-tools.tsx +0 -379
  72. package/examples/interactive/explorer.tsx +0 -747
  73. package/examples/interactive/gallery.tsx +0 -652
  74. package/examples/interactive/inline-bench.tsx +0 -136
  75. package/examples/interactive/kanban.tsx +0 -267
  76. package/examples/interactive/layout-ref.tsx +0 -185
  77. package/examples/interactive/outline.tsx +0 -171
  78. package/examples/interactive/paste-demo.tsx +0 -198
  79. package/examples/interactive/scroll.tsx +0 -77
  80. package/examples/interactive/search-filter.tsx +0 -240
  81. package/examples/interactive/task-list.tsx +0 -279
  82. package/examples/interactive/terminal.tsx +0 -798
  83. package/examples/interactive/textarea.tsx +0 -103
  84. package/examples/interactive/theme.tsx +0 -336
  85. package/examples/interactive/transform.tsx +0 -256
  86. package/examples/interactive/virtual-10k.tsx +0 -413
  87. package/examples/kitty/canvas.tsx +0 -519
  88. package/examples/kitty/generate-samples.ts +0 -236
  89. package/examples/kitty/image-component.tsx +0 -273
  90. package/examples/kitty/images.tsx +0 -604
  91. package/examples/kitty/input.tsx +0 -371
  92. package/examples/kitty/keys.tsx +0 -378
  93. package/examples/kitty/paint.tsx +0 -1017
  94. package/examples/layout/dashboard.tsx +0 -551
  95. package/examples/layout/live-resize.tsx +0 -290
  96. package/examples/layout/overflow.tsx +0 -51
  97. package/examples/playground/README.md +0 -69
  98. package/examples/playground/build.ts +0 -61
  99. package/examples/playground/index.html +0 -420
  100. package/examples/playground/playground-app.tsx +0 -416
  101. package/examples/runtime/elm-counter.tsx +0 -206
  102. package/examples/runtime/hello-runtime.tsx +0 -73
  103. package/examples/runtime/pipe-composition.tsx +0 -184
  104. package/examples/runtime/run-counter.tsx +0 -78
  105. package/examples/runtime/runtime-counter.tsx +0 -197
  106. package/examples/screenshots/generate.tsx +0 -563
  107. package/examples/scrollback-perf.tsx +0 -230
  108. package/examples/viewer.tsx +0 -654
  109. package/examples/web/build.ts +0 -365
  110. package/examples/web/canvas-app.tsx +0 -80
  111. package/examples/web/canvas.html +0 -89
  112. package/examples/web/dom-app.tsx +0 -81
  113. package/examples/web/dom.html +0 -113
  114. package/examples/web/showcase-app.tsx +0 -107
  115. package/examples/web/showcase.html +0 -34
  116. package/examples/web/showcases/index.tsx +0 -56
  117. package/examples/web/viewer-app.tsx +0 -555
  118. package/examples/web/viewer.html +0 -30
  119. package/examples/web/xterm-app.tsx +0 -105
  120. package/examples/web/xterm.html +0 -118
@@ -1,747 +0,0 @@
1
- /**
2
- * Explorer — Log Viewer & Process Explorer
3
- *
4
- * A tabbed data exploration demo combining:
5
- * - Streaming log viewer with ~2000 lines, severity-level coloring, and level toggles
6
- * - Sortable process table with ~50 processes, live CPU/MEM jitter, and responsive columns
7
- * - Shared TextInput search bar with useDeferredValue for non-blocking filtering
8
- * - VirtualList with interactive scrolling for both tabs
9
- *
10
- * Usage: bun vendor/silvery/examples/interactive/explorer.tsx
11
- *
12
- * Controls:
13
- * Tab/h/l - Switch tabs (Logs / Processes)
14
- * j/k or Up/Dn - Navigate rows
15
- * d/u - Half-page down/up
16
- * g/G - Jump to first/last
17
- * / - Focus search bar
18
- * 1-4 - Toggle log levels (Logs tab)
19
- * s - Cycle sort column (Processes tab)
20
- * Esc - Exit search / quit
21
- * q - Quit (when not searching)
22
- */
23
-
24
- import React, { useState, useCallback, useMemo, useDeferredValue, useEffect, useRef } from "react"
25
- import {
26
- render,
27
- Box,
28
- Text,
29
- VirtualList,
30
- TextInput,
31
- Tabs,
32
- TabList,
33
- Tab,
34
- Divider,
35
- useContentRect,
36
- useInput,
37
- useApp,
38
- createTerm,
39
- Kbd,
40
- Muted,
41
- type Key,
42
- } from "../../src/index.js"
43
- import { ExampleBanner, type ExampleMeta } from "../_banner.js"
44
-
45
- export const meta: ExampleMeta = {
46
- name: "Explorer",
47
- description: "Log viewer and process explorer with VirtualList search",
48
- demo: true,
49
- features: ["VirtualList", "TextInput", "useContentRect()", "useDeferredValue", "2000+ rows"],
50
- }
51
-
52
- // ============================================================================
53
- // Shared Types & Utilities
54
- // ============================================================================
55
-
56
- function seededRandom(seed: number): () => number {
57
- let s = seed
58
- return () => {
59
- s = (s * 1664525 + 1013904223) & 0x7fffffff
60
- return s / 0x7fffffff
61
- }
62
- }
63
-
64
- // ============================================================================
65
- // Log Data
66
- // ============================================================================
67
-
68
- type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"
69
-
70
- interface LogEntry {
71
- id: number
72
- timestamp: string
73
- service: string
74
- level: LogLevel
75
- message: string
76
- }
77
-
78
- const SERVICES = ["api", "auth", "db", "cache", "worker", "gateway", "scheduler", "metrics", "queue", "ws"]
79
-
80
- const LOG_TEMPLATES: Record<LogLevel, string[]> = {
81
- DEBUG: [
82
- "Cache miss for key user:session:{id}",
83
- "Query plan: sequential scan on events ({n} rows)",
84
- "WebSocket frame received: {n} bytes",
85
- "GC pause: {n}ms (minor collection)",
86
- "Connection pool stats: {n} active, {n} idle",
87
- "Route matched: GET /api/v2/resources/{id}",
88
- "DNS resolution took {n}ms for upstream.svc",
89
- "Retry backoff: sleeping {n}ms before attempt",
90
- ],
91
- INFO: [
92
- "Request completed: 200 OK ({n}ms)",
93
- "User {id} authenticated via OAuth",
94
- "Background job processed: email_dispatch #{id}",
95
- "Server listening on port {n}",
96
- "Database migration applied: v{n}",
97
- "Health check passed (latency: {n}ms)",
98
- "Deployed version 2.{n}.0 to production",
99
- "Cache warmed: {n} entries loaded in {n}ms",
100
- ],
101
- WARN: [
102
- "Slow query detected: {n}ms (threshold: 200ms)",
103
- "Rate limit approaching: {n}/1000 requests",
104
- "Memory usage: {n}% of allocated heap",
105
- "Retry attempt {n}/3 for external API call",
106
- "Certificate expires in {n} days",
107
- "Connection pool near capacity: {n}/100",
108
- "Request body exceeds {n}KB soft limit",
109
- "Stale cache entry served for key products:{id}",
110
- ],
111
- ERROR: [
112
- "Unhandled exception in request handler: TypeError",
113
- "Database connection refused: ECONNREFUSED",
114
- "Authentication failed for user {id}: invalid token",
115
- "Timeout after {n}ms waiting for upstream service",
116
- "Disk usage critical: {n}% on /var/data",
117
- "Failed to process message from queue: malformed payload",
118
- "OOM kill triggered for worker process PID {id}",
119
- "TLS handshake failed: certificate chain incomplete",
120
- ],
121
- }
122
-
123
- const LEVEL_COLORS: Record<LogLevel, string> = {
124
- DEBUG: "$muted",
125
- INFO: "$success",
126
- WARN: "$warning",
127
- ERROR: "$error",
128
- }
129
-
130
- const LEVEL_BADGES: Record<LogLevel, string> = {
131
- DEBUG: "DBG",
132
- INFO: "INF",
133
- WARN: "WRN",
134
- ERROR: "ERR",
135
- }
136
-
137
- function generateLogs(count: number): LogEntry[] {
138
- const rng = seededRandom(42)
139
- const levels: LogLevel[] = ["DEBUG", "INFO", "INFO", "INFO", "INFO", "WARN", "WARN", "ERROR"]
140
- const entries: LogEntry[] = []
141
-
142
- // Start time: spread over 30 minutes
143
- const baseHour = 14
144
- const baseMinute = 30
145
-
146
- for (let i = 0; i < count; i++) {
147
- const level = levels[Math.floor(rng() * levels.length)]!
148
- const templates = LOG_TEMPLATES[level]
149
- const template = templates[Math.floor(rng() * templates.length)]!
150
- const message = template
151
- .replace(/\{id\}/g, () => String(Math.floor(rng() * 99999)))
152
- .replace(/\{n\}/g, () => String(Math.floor(rng() * 999)))
153
-
154
- const totalSeconds = (i / count) * 1800 // 30 min spread
155
- const h = baseHour + Math.floor((baseMinute * 60 + totalSeconds) / 3600)
156
- const m = Math.floor(((baseMinute * 60 + totalSeconds) % 3600) / 60)
157
- const s = Math.floor(totalSeconds % 60)
158
- const ms = Math.floor(rng() * 1000)
159
-
160
- entries.push({
161
- id: i,
162
- timestamp: `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(ms).padStart(3, "0")}`,
163
- service: SERVICES[Math.floor(rng() * SERVICES.length)]!,
164
- level,
165
- message,
166
- })
167
- }
168
-
169
- return entries
170
- }
171
-
172
- const ALL_LOGS = generateLogs(2000)
173
-
174
- // ============================================================================
175
- // Process Data
176
- // ============================================================================
177
-
178
- type SortColumn = "pid" | "name" | "cpu" | "mem" | "status"
179
-
180
- interface ProcessInfo {
181
- pid: number
182
- name: string
183
- cpu: number
184
- mem: number
185
- status: "running" | "sleeping" | "stopped" | "zombie"
186
- }
187
-
188
- const PROCESS_NAMES = [
189
- "node",
190
- "bun",
191
- "postgres",
192
- "redis-server",
193
- "nginx",
194
- "docker",
195
- "sshd",
196
- "containerd",
197
- "kubelet",
198
- "etcd",
199
- "coredns",
200
- "prometheus",
201
- "grafana",
202
- "elasticsearch",
203
- "rabbitmq",
204
- "kafka",
205
- "consul",
206
- "vault",
207
- "haproxy",
208
- "traefik",
209
- "envoy",
210
- "mysql",
211
- "mongo",
212
- "clickhouse",
213
- "influxdb",
214
- "jenkins",
215
- "cadvisor",
216
- "telegraf",
217
- "deno",
218
- "esbuild",
219
- "python3",
220
- "ruby",
221
- "java",
222
- "go",
223
- "rustc",
224
- "webpack",
225
- "vite",
226
- "swc",
227
- "chrome",
228
- "code",
229
- "tmux",
230
- "zsh",
231
- "cron",
232
- "systemd",
233
- "rsyslogd",
234
- "logstash",
235
- "kibana",
236
- "alertmanager",
237
- "buildkitd",
238
- "registry",
239
- ]
240
-
241
- const PROCESS_STATUSES: ProcessInfo["status"][] = ["running", "sleeping", "stopped", "zombie"]
242
-
243
- function generateProcesses(count: number): ProcessInfo[] {
244
- const rng = seededRandom(123)
245
- const procs: ProcessInfo[] = []
246
-
247
- for (let i = 0; i < count; i++) {
248
- const nameBase = PROCESS_NAMES[Math.floor(rng() * PROCESS_NAMES.length)]!
249
- const hasInstance = rng() > 0.7
250
- const status = rng() < 0.65 ? "running" : PROCESS_STATUSES[Math.floor(rng() * PROCESS_STATUSES.length)]!
251
-
252
- procs.push({
253
- pid: 1000 + Math.floor(rng() * 60000),
254
- name: hasInstance ? `${nameBase}:${Math.floor(rng() * 16)}` : nameBase,
255
- cpu: status === "running" ? Math.round(rng() * 1000) / 10 : 0,
256
- mem: Math.round(rng() * 800) / 10,
257
- status,
258
- })
259
- }
260
-
261
- return procs
262
- }
263
-
264
- const INITIAL_PROCESSES = generateProcesses(50)
265
-
266
- const STATUS_COLORS: Record<ProcessInfo["status"], string> = {
267
- running: "$success",
268
- sleeping: "$muted",
269
- stopped: "$warning",
270
- zombie: "$error",
271
- }
272
-
273
- const STATUS_ICONS: Record<ProcessInfo["status"], string> = {
274
- running: "\u25b6",
275
- sleeping: "\u25cc",
276
- stopped: "\u25a0",
277
- zombie: "\u2620",
278
- }
279
-
280
- const SORT_COLUMNS: SortColumn[] = ["cpu", "mem", "pid", "name", "status"]
281
-
282
- // ============================================================================
283
- // Log Components
284
- // ============================================================================
285
-
286
- function LogRow({ entry, isSelected }: { entry: LogEntry; isSelected: boolean }): JSX.Element {
287
- return (
288
- <Box paddingX={1} backgroundColor={isSelected ? "$mutedbg" : undefined}>
289
- <Muted>{entry.timestamp} </Muted>
290
- <Text color={LEVEL_COLORS[entry.level]} bold>
291
- {LEVEL_BADGES[entry.level]}
292
- </Text>
293
- <Muted> [{entry.service.padEnd(9)}] </Muted>
294
- <Text>{entry.message}</Text>
295
- </Box>
296
- )
297
- }
298
-
299
- function LogListArea({ entries, cursor }: { entries: LogEntry[]; cursor: number }): JSX.Element {
300
- const { height } = useContentRect()
301
-
302
- return (
303
- <VirtualList
304
- items={entries}
305
- height={height}
306
- itemHeight={1}
307
- scrollTo={cursor}
308
- overscan={5}
309
- renderItem={(entry, index) => <LogRow key={entry.id} entry={entry} isSelected={index === cursor} />}
310
- />
311
- )
312
- }
313
-
314
- function LevelToggles({
315
- levels,
316
- onToggle,
317
- }: {
318
- levels: Record<LogLevel, boolean>
319
- onToggle: (level: LogLevel) => void
320
- }): JSX.Element {
321
- const allLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"]
322
- return (
323
- <Box gap={1}>
324
- {allLevels.map((level, i) => {
325
- const active = levels[level]
326
- return (
327
- <Box key={level} gap={0}>
328
- <Text color="$muted" dim>
329
- {i + 1}:
330
- </Text>
331
- <Text color={active ? LEVEL_COLORS[level] : "$muted"} bold={active} dim={!active} strikethrough={!active}>
332
- {LEVEL_BADGES[level]}
333
- </Text>
334
- </Box>
335
- )
336
- })}
337
- </Box>
338
- )
339
- }
340
-
341
- // ============================================================================
342
- // Process Components
343
- // ============================================================================
344
-
345
- function useColumns(totalWidth: number) {
346
- return useMemo(() => {
347
- const pidW = 7
348
- const cpuW = 8
349
- const memW = 8
350
- const statusW = 11
351
- const fixed = pidW + cpuW + memW + statusW + 4 // gaps
352
- const nameW = Math.max(12, totalWidth - fixed)
353
- return { pidW, nameW, cpuW, memW, statusW }
354
- }, [totalWidth])
355
- }
356
-
357
- function ProcessHeader({ width }: { width: number }): JSX.Element {
358
- const cols = useColumns(width)
359
- return (
360
- <Box paddingX={1}>
361
- <Text bold color="$muted">
362
- {"PID".padEnd(cols.pidW)}
363
- {"NAME".padEnd(cols.nameW)}
364
- {"CPU%".padStart(cols.cpuW)}
365
- {"MEM%".padStart(cols.memW)}
366
- {" "}
367
- {"STATUS".padEnd(cols.statusW)}
368
- </Text>
369
- </Box>
370
- )
371
- }
372
-
373
- function ProcessRow({
374
- proc,
375
- isSelected,
376
- width,
377
- }: {
378
- proc: ProcessInfo
379
- isSelected: boolean
380
- width: number
381
- }): JSX.Element {
382
- const cols = useColumns(width)
383
- const cpuColor = proc.cpu > 80 ? "$error" : proc.cpu > 40 ? "$warning" : "$success"
384
- const displayName = proc.name.length > cols.nameW - 1 ? proc.name.slice(0, cols.nameW - 2) + "\u2026" : proc.name
385
-
386
- return (
387
- <Box paddingX={1} backgroundColor={isSelected ? "$mutedbg" : undefined}>
388
- <Text color="$muted">{String(proc.pid).padEnd(cols.pidW)}</Text>
389
- <Text bold={isSelected}>{displayName.padEnd(cols.nameW)}</Text>
390
- <Text color={cpuColor}>{proc.cpu.toFixed(1).padStart(cols.cpuW - 1)}%</Text>
391
- <Text color={proc.mem > 40 ? "$warning" : "$muted"}>{proc.mem.toFixed(1).padStart(cols.memW - 1)}%</Text>
392
- <Text>{" "}</Text>
393
- <Text color={STATUS_COLORS[proc.status]}>
394
- {STATUS_ICONS[proc.status]} {proc.status.padEnd(cols.statusW - 2)}
395
- </Text>
396
- </Box>
397
- )
398
- }
399
-
400
- function ProcessListArea({
401
- processes,
402
- cursor,
403
- width,
404
- }: {
405
- processes: ProcessInfo[]
406
- cursor: number
407
- width: number
408
- }): JSX.Element {
409
- const { height } = useContentRect()
410
-
411
- return (
412
- <VirtualList
413
- items={processes}
414
- height={height}
415
- itemHeight={1}
416
- scrollTo={cursor}
417
- overscan={5}
418
- renderItem={(proc, index) => (
419
- <ProcessRow key={proc.pid} proc={proc} isSelected={index === cursor} width={width} />
420
- )}
421
- />
422
- )
423
- }
424
-
425
- // ============================================================================
426
- // Main App
427
- // ============================================================================
428
-
429
- export function Explorer(): JSX.Element {
430
- const { exit } = useApp()
431
- const { width } = useContentRect()
432
-
433
- // Tab state
434
- const [activeTab, setActiveTab] = useState("logs")
435
-
436
- // Search state (shared)
437
- const [searchMode, setSearchMode] = useState(false)
438
- const [query, setQuery] = useState("")
439
- const deferredQuery = useDeferredValue(query)
440
-
441
- // Log state
442
- const [logCursor, setLogCursor] = useState(0)
443
- const [logLevels, setLogLevels] = useState<Record<LogLevel, boolean>>({
444
- DEBUG: true,
445
- INFO: true,
446
- WARN: true,
447
- ERROR: true,
448
- })
449
-
450
- // Process state
451
- const [procCursor, setProcCursor] = useState(0)
452
- const [sortCol, setSortCol] = useState<SortColumn>("cpu")
453
- const [processes, setProcesses] = useState(INITIAL_PROCESSES)
454
-
455
- // Live jitter on CPU/MEM values
456
- const jitterRef = useRef<ReturnType<typeof setInterval> | null>(null)
457
- useEffect(() => {
458
- const rng = seededRandom(Date.now())
459
- jitterRef.current = setInterval(() => {
460
- setProcesses((prev) =>
461
- prev.map((p) => {
462
- if (p.status !== "running") return p
463
- const cpuDelta = (rng() - 0.5) * 6
464
- const memDelta = (rng() - 0.5) * 2
465
- return {
466
- ...p,
467
- cpu: Math.max(0, Math.min(100, Math.round((p.cpu + cpuDelta) * 10) / 10)),
468
- mem: Math.max(0, Math.min(100, Math.round((p.mem + memDelta) * 10) / 10)),
469
- }
470
- }),
471
- )
472
- }, 2000)
473
- return () => {
474
- if (jitterRef.current) clearInterval(jitterRef.current)
475
- }
476
- }, [])
477
-
478
- // Filtered logs
479
- const filteredLogs = useMemo(() => {
480
- let logs = ALL_LOGS.filter((e) => logLevels[e.level])
481
- if (deferredQuery) {
482
- const q = deferredQuery.toLowerCase()
483
- logs = logs.filter(
484
- (e) =>
485
- e.message.toLowerCase().includes(q) ||
486
- e.service.toLowerCase().includes(q) ||
487
- e.level.toLowerCase().includes(q),
488
- )
489
- }
490
- return logs
491
- }, [deferredQuery, logLevels])
492
-
493
- // Filtered + sorted processes
494
- const filteredProcesses = useMemo(() => {
495
- let procs = processes
496
- if (deferredQuery) {
497
- const q = deferredQuery.toLowerCase()
498
- procs = procs.filter((p) => p.name.toLowerCase().includes(q) || p.status.includes(q) || String(p.pid).includes(q))
499
- }
500
- return [...procs].sort((a, b) => {
501
- switch (sortCol) {
502
- case "cpu":
503
- return b.cpu - a.cpu
504
- case "mem":
505
- return b.mem - a.mem
506
- case "pid":
507
- return a.pid - b.pid
508
- case "name":
509
- return a.name.localeCompare(b.name)
510
- case "status":
511
- return a.status.localeCompare(b.status)
512
- }
513
- })
514
- }, [processes, deferredQuery, sortCol])
515
-
516
- // Current list length for navigation
517
- const currentItems = activeTab === "logs" ? filteredLogs : filteredProcesses
518
- const cursor = activeTab === "logs" ? logCursor : procCursor
519
- const setCursor = activeTab === "logs" ? setLogCursor : setProcCursor
520
- const halfPage = Math.max(1, Math.floor(20 / 2))
521
-
522
- // Clamp cursors when filter changes
523
- const effectiveLogCursor = Math.min(logCursor, Math.max(0, filteredLogs.length - 1))
524
- const effectiveProcCursor = Math.min(procCursor, Math.max(0, filteredProcesses.length - 1))
525
-
526
- const handleSearchSubmit = useCallback(() => {
527
- setSearchMode(false)
528
- }, [])
529
-
530
- const toggleLevel = useCallback((level: LogLevel) => {
531
- setLogLevels((prev) => ({ ...prev, [level]: !prev[level] }))
532
- setLogCursor(0)
533
- }, [])
534
-
535
- useInput(
536
- useCallback(
537
- (input: string, key: Key) => {
538
- // Search mode: only handle Esc
539
- if (searchMode) {
540
- if (key.escape) {
541
- setSearchMode(false)
542
- return
543
- }
544
- return
545
- }
546
-
547
- // Quit
548
- if (input === "q" || key.escape) {
549
- exit()
550
- return
551
- }
552
-
553
- // Search
554
- if (input === "/") {
555
- setSearchMode(true)
556
- return
557
- }
558
-
559
- // Tab switching (Tab key handled by Tabs component via h/l)
560
-
561
- // Log level toggles (logs tab only)
562
- if (activeTab === "logs") {
563
- const levelMap: Record<string, LogLevel> = {
564
- "1": "DEBUG",
565
- "2": "INFO",
566
- "3": "WARN",
567
- "4": "ERROR",
568
- }
569
- if (levelMap[input]) {
570
- toggleLevel(levelMap[input])
571
- return
572
- }
573
- }
574
-
575
- // Sort cycling (processes tab only)
576
- if (activeTab === "processes" && input === "s") {
577
- setSortCol((prev) => {
578
- const idx = SORT_COLUMNS.indexOf(prev)
579
- return SORT_COLUMNS[(idx + 1) % SORT_COLUMNS.length]!
580
- })
581
- return
582
- }
583
-
584
- // Navigation
585
- if (input === "j" || key.downArrow) {
586
- setCursor((c: number) => Math.min(currentItems.length - 1, c + 1))
587
- }
588
- if (input === "k" || key.upArrow) {
589
- setCursor((c: number) => Math.max(0, c - 1))
590
- }
591
- if (input === "d" || key.pageDown) {
592
- setCursor((c: number) => Math.min(currentItems.length - 1, c + halfPage))
593
- }
594
- if (input === "u" || key.pageUp) {
595
- setCursor((c: number) => Math.max(0, c - halfPage))
596
- }
597
- if (input === "g" || key.home) {
598
- setCursor(0)
599
- }
600
- if (input === "G" || key.end) {
601
- setCursor(currentItems.length - 1)
602
- }
603
-
604
- // Clear filter
605
- if (key.backspace && query) {
606
- setQuery("")
607
- setCursor(0)
608
- }
609
- },
610
- [searchMode, exit, activeTab, currentItems.length, halfPage, query, toggleLevel, setCursor],
611
- ),
612
- )
613
-
614
- return (
615
- <Box flexDirection="column" flexGrow={1}>
616
- {/* Search bar */}
617
- <Box paddingX={1}>
618
- {searchMode ? (
619
- <Box flexGrow={1}>
620
- <Text color="$primary" bold>
621
- /{" "}
622
- </Text>
623
- <TextInput
624
- value={query}
625
- onChange={(v) => {
626
- setQuery(v)
627
- setLogCursor(0)
628
- setProcCursor(0)
629
- }}
630
- onSubmit={handleSearchSubmit}
631
- prompt=""
632
- isActive={searchMode}
633
- />
634
- </Box>
635
- ) : query ? (
636
- <Muted>
637
- filter: <Text bold>{query}</Text> (<Kbd>backspace</Kbd> clear, <Kbd>/</Kbd> edit)
638
- </Muted>
639
- ) : (
640
- <Muted>
641
- <Kbd>/</Kbd> search
642
- </Muted>
643
- )}
644
- </Box>
645
-
646
- {/* Tab bar */}
647
- <Tabs value={activeTab} onChange={setActiveTab} isActive={!searchMode}>
648
- <Box paddingX={1}>
649
- <TabList>
650
- <Tab value="logs">Logs ({filteredLogs.length.toLocaleString()})</Tab>
651
- <Tab value="processes">Processes ({filteredProcesses.length})</Tab>
652
- </TabList>
653
- </Box>
654
- </Tabs>
655
-
656
- {/* Tab content — outside Tabs so flexGrow works */}
657
- {activeTab === "logs" && (
658
- <>
659
- <Box paddingX={1} justifyContent="space-between">
660
- <LevelToggles levels={logLevels} onToggle={toggleLevel} />
661
- <Muted>
662
- {effectiveLogCursor + 1}/{filteredLogs.length.toLocaleString()}
663
- </Muted>
664
- </Box>
665
- <Box flexGrow={1} flexDirection="column">
666
- {filteredLogs.length > 0 ? (
667
- <LogListArea entries={filteredLogs} cursor={effectiveLogCursor} />
668
- ) : (
669
- <Box paddingX={1} justifyContent="center">
670
- <Muted>No logs match the current filter</Muted>
671
- </Box>
672
- )}
673
- </Box>
674
- </>
675
- )}
676
-
677
- {activeTab === "processes" && (
678
- <>
679
- <Box paddingX={1} justifyContent="space-between">
680
- <Box gap={1}>
681
- <Muted>sort:</Muted>
682
- <Text bold color="$primary">
683
- {sortCol.toUpperCase()}
684
- </Text>
685
- <Muted>
686
- (<Kbd>s</Kbd> cycle)
687
- </Muted>
688
- </Box>
689
- <Muted>
690
- {effectiveProcCursor + 1}/{filteredProcesses.length}
691
- </Muted>
692
- </Box>
693
- <ProcessHeader width={width} />
694
- <Box paddingX={1}>
695
- <Divider />
696
- </Box>
697
- <Box flexGrow={1} flexDirection="column">
698
- {filteredProcesses.length > 0 ? (
699
- <ProcessListArea processes={filteredProcesses} cursor={effectiveProcCursor} width={width} />
700
- ) : (
701
- <Box paddingX={1} justifyContent="center">
702
- <Muted>No processes match the current filter</Muted>
703
- </Box>
704
- )}
705
- </Box>
706
- </>
707
- )}
708
-
709
- {/* Help bar */}
710
- <Box paddingX={1} justifyContent="space-between">
711
- <Muted>
712
- <Kbd>h/l</Kbd> tab <Kbd>j/k</Kbd> navigate <Kbd>d/u</Kbd> page <Kbd>/</Kbd> search{" "}
713
- {activeTab === "logs" && (
714
- <>
715
- <Kbd>1-4</Kbd> levels{" "}
716
- </>
717
- )}
718
- {activeTab === "processes" && (
719
- <>
720
- <Kbd>s</Kbd> sort{" "}
721
- </>
722
- )}
723
- <Kbd>q</Kbd> quit
724
- </Muted>
725
- </Box>
726
- </Box>
727
- )
728
- }
729
-
730
- // ============================================================================
731
- // Main
732
- // ============================================================================
733
-
734
- export async function main() {
735
- using term = createTerm()
736
- const { waitUntilExit } = await render(
737
- <ExampleBanner meta={meta} controls="h/l tab j/k navigate d/u page / search 1-4 levels s sort q quit">
738
- <Explorer />
739
- </ExampleBanner>,
740
- term,
741
- )
742
- await waitUntilExit()
743
- }
744
-
745
- if (import.meta.main) {
746
- main().catch(console.error)
747
- }