@davstack/tui 0.2.0

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 (48) hide show
  1. package/README.md +66 -0
  2. package/bin/davstack.mjs +45 -0
  3. package/package.json +33 -0
  4. package/src/App.test.tsx +92 -0
  5. package/src/App.tsx +103 -0
  6. package/src/cli.ts +62 -0
  7. package/src/commands/check.test.ts +160 -0
  8. package/src/commands/check.ts +112 -0
  9. package/src/components/BottomBar.tsx +28 -0
  10. package/src/components/DaemonSupervisor.tsx +50 -0
  11. package/src/components/DescriptorSync.tsx +19 -0
  12. package/src/components/GlobalHotkeys.tsx +30 -0
  13. package/src/components/MainView.tsx +75 -0
  14. package/src/components/QuitConfirm.tsx +20 -0
  15. package/src/components/QuitController.tsx +69 -0
  16. package/src/components/StatusBar.test.tsx +47 -0
  17. package/src/components/StatusBar.tsx +67 -0
  18. package/src/hooks/useConfigDiscovery.ts +56 -0
  19. package/src/hooks/useDaemonProcess.test.ts +415 -0
  20. package/src/hooks/useDaemonProcess.ts +275 -0
  21. package/src/hooks/useHotkeys.test.tsx +508 -0
  22. package/src/hooks/useHotkeys.ts +164 -0
  23. package/src/hooks/useNoColor.test.ts +50 -0
  24. package/src/hooks/useNoColor.ts +35 -0
  25. package/src/hooks/useRingBuffer.test.tsx +86 -0
  26. package/src/hooks/useRingBuffer.ts +46 -0
  27. package/src/lib/config-discovery.test.ts +77 -0
  28. package/src/lib/config-discovery.ts +57 -0
  29. package/src/lib/daemon-registry.ts +143 -0
  30. package/src/lib/global-teardown.test.ts +75 -0
  31. package/src/lib/global-teardown.ts +78 -0
  32. package/src/lib/kill-tree.test.ts +69 -0
  33. package/src/lib/kill-tree.ts +31 -0
  34. package/src/lib/package-info.ts +35 -0
  35. package/src/lib/port-owner.test.ts +105 -0
  36. package/src/lib/port-owner.ts +90 -0
  37. package/src/lib/port-probe.test.ts +41 -0
  38. package/src/lib/port-probe.ts +29 -0
  39. package/src/lib/repo-root.test.ts +36 -0
  40. package/src/lib/repo-root.ts +30 -0
  41. package/src/lib/ring-buffer.test.ts +63 -0
  42. package/src/lib/ring-buffer.ts +47 -0
  43. package/src/state/daemons-context.tsx +149 -0
  44. package/src/state/quit-context.tsx +27 -0
  45. package/src/state/view-context.tsx +32 -0
  46. package/src/views/ServerList.test.tsx +167 -0
  47. package/src/views/ServerList.tsx +109 -0
  48. package/src/views/ServerLogView.tsx +79 -0
@@ -0,0 +1,508 @@
1
+ // Unit tests for the hotkey dispatcher. We mount the contexts (View +
2
+ // Daemons) and a small capture component that grabs the hook's return
3
+ // value so tests can call handlers directly. This is the seam where
4
+ // keystrokes arrive from <GlobalHotkeys>, so testing here covers the
5
+ // production routing without depending on Ink's raw-mode plumbing.
6
+
7
+ import React from "react"
8
+ import { afterEach, expect, test, vi } from "vitest"
9
+ import { render } from "ink-testing-library"
10
+
11
+ import { ViewProvider, useView, type View } from "../state/view-context.tsx"
12
+ import { DaemonsProvider, useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
13
+ import { QuitProvider, useQuit } from "../state/quit-context.tsx"
14
+ import { useHotkeys, type HotkeyHandlers } from "./useHotkeys.ts"
15
+ import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
16
+
17
+ function makeDescriptor(key: "logs" | "vitest" | "playwright"): DaemonDescriptor {
18
+ return {
19
+ key,
20
+ label: key,
21
+ port: 1000,
22
+ readyRegex: /listening/i,
23
+ spawn: () => {
24
+ throw new Error("not spawned in this test")
25
+ },
26
+ }
27
+ }
28
+
29
+ interface CapturedApis {
30
+ hotkeys: HotkeyHandlers
31
+ view: View
32
+ setFocusedIdx: (n: number) => void
33
+ registerRow: (row: DaemonRow) => void
34
+ registerControls: ReturnType<typeof useDaemons>["registerControls"]
35
+ quitConfirming: boolean
36
+ }
37
+
38
+ function Capture({ onUpdate, onQuit }: {
39
+ onUpdate: (api: CapturedApis) => void
40
+ onQuit: () => void
41
+ }): null {
42
+ const hotkeys = useHotkeys(onQuit)
43
+ const v = useView()
44
+ const d = useDaemons()
45
+ const q = useQuit()
46
+ // Publish during render so tests can synchronously read the latest
47
+ // context values + handlers after render(...). Safe here — onUpdate
48
+ // is a setter into a closure, not a React state update.
49
+ onUpdate({
50
+ hotkeys,
51
+ view: v.view,
52
+ setFocusedIdx: v.setFocusedIdx,
53
+ registerRow: d.registerRow,
54
+ registerControls: d.registerControls,
55
+ quitConfirming: q.confirming,
56
+ })
57
+ return null
58
+ }
59
+
60
+ async function tick(): Promise<void> {
61
+ // Let React flush queued setState/effects between assertions.
62
+ await new Promise((resolve) => setTimeout(resolve, 0))
63
+ }
64
+
65
+ function renderWithProviders(descriptors: DaemonDescriptor[], onQuit: () => void) {
66
+ let captured: CapturedApis | null = null
67
+ const r = render(
68
+ <ViewProvider>
69
+ <DaemonsProvider descriptors={descriptors}>
70
+ <QuitProvider>
71
+ <Capture onQuit={onQuit} onUpdate={(api) => (captured = api)} />
72
+ </QuitProvider>
73
+ </DaemonsProvider>
74
+ </ViewProvider>,
75
+ )
76
+ if (!captured) throw new Error("Capture never published")
77
+ return { r, get: (): CapturedApis => captured! }
78
+ }
79
+
80
+ let unmount: (() => void) | null = null
81
+ afterEach(() => {
82
+ unmount?.()
83
+ unmount = null
84
+ })
85
+
86
+ test("pressing `1` from list view jumps into daemon 1's log view", async () => {
87
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
88
+ const { r, get } = renderWithProviders(descriptors, () => {})
89
+ unmount = () => r.unmount()
90
+
91
+ // Seed rows so toggleByKey + numberKey see populated rowsRef.
92
+ get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
93
+ get().registerRow({ descriptor: descriptors[1], status: "idle", lines: [], exitCode: null })
94
+ get().registerRow({ descriptor: descriptors[2], status: "idle", lines: [], exitCode: null })
95
+ await tick()
96
+
97
+ get().hotkeys.handle("1", {})
98
+ await tick()
99
+ expect(get().view).toEqual({ kind: "log", key: "logs" })
100
+ })
101
+
102
+ test("pressing `2` from inside log view jumps to daemon 2", async () => {
103
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
104
+ const { r, get } = renderWithProviders(descriptors, () => {})
105
+ unmount = () => r.unmount()
106
+
107
+ for (const d of descriptors) {
108
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
109
+ }
110
+ await tick()
111
+
112
+ get().hotkeys.handle("1", {})
113
+ await tick()
114
+ expect(get().view).toEqual({ kind: "log", key: "logs" })
115
+
116
+ get().hotkeys.handle("2", {})
117
+ await tick()
118
+ expect(get().view).toEqual({ kind: "log", key: "vitest" })
119
+ })
120
+
121
+ test("escape from log view returns to list view", async () => {
122
+ const descriptors = [makeDescriptor("logs")]
123
+ const { r, get } = renderWithProviders(descriptors, () => {})
124
+ unmount = () => r.unmount()
125
+
126
+ get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
127
+ await tick()
128
+ get().hotkeys.handle("1", {})
129
+ await tick()
130
+ expect(get().view.kind).toBe("log")
131
+
132
+ get().hotkeys.handle("", { escape: true })
133
+ await tick()
134
+ expect(get().view.kind).toBe("list")
135
+ })
136
+
137
+ test("escape from list view is a no-op", async () => {
138
+ const descriptors = [makeDescriptor("logs")]
139
+ const { r, get } = renderWithProviders(descriptors, () => {})
140
+ unmount = () => r.unmount()
141
+
142
+ get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
143
+ await tick()
144
+ expect(get().view.kind).toBe("list")
145
+ get().hotkeys.handle("", { escape: true })
146
+ await tick()
147
+ expect(get().view.kind).toBe("list")
148
+ })
149
+
150
+ test("out-of-range number keys are no-ops", async () => {
151
+ const descriptors = [makeDescriptor("logs")]
152
+ const { r, get } = renderWithProviders(descriptors, () => {})
153
+ unmount = () => r.unmount()
154
+
155
+ get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
156
+ await tick()
157
+ get().hotkeys.handle("5", {})
158
+ await tick()
159
+ expect(get().view.kind).toBe("list")
160
+ })
161
+
162
+ test("`q` invokes the quit handler", () => {
163
+ const descriptors = [makeDescriptor("logs")]
164
+ const onQuit = vi.fn()
165
+ const { r, get } = renderWithProviders(descriptors, onQuit)
166
+ unmount = () => r.unmount()
167
+
168
+ get().hotkeys.handle("q", {})
169
+ expect(onQuit).toHaveBeenCalledTimes(1)
170
+ })
171
+
172
+ test("ctrl-c also invokes the quit handler", () => {
173
+ const descriptors = [makeDescriptor("logs")]
174
+ const onQuit = vi.fn()
175
+ const { r, get } = renderWithProviders(descriptors, onQuit)
176
+ unmount = () => r.unmount()
177
+
178
+ get().hotkeys.handle("c", { ctrl: true })
179
+ expect(onQuit).toHaveBeenCalledTimes(1)
180
+ })
181
+
182
+ test("onToggleFocused calls stop on a running focused daemon", async () => {
183
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
184
+ const { r, get } = renderWithProviders(descriptors, () => {})
185
+ unmount = () => r.unmount()
186
+
187
+ const start = vi.fn()
188
+ const stop = vi.fn()
189
+ get().registerControls("logs", { start, stop })
190
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
191
+ await tick()
192
+ // focusedIdx defaults to 0 -> logs.
193
+
194
+ get().hotkeys.onToggleFocused()
195
+ expect(stop).toHaveBeenCalledTimes(1)
196
+ expect(start).not.toHaveBeenCalled()
197
+ })
198
+
199
+ test("onToggleFocused calls start on an idle focused daemon", async () => {
200
+ const descriptors = [makeDescriptor("logs")]
201
+ const { r, get } = renderWithProviders(descriptors, () => {})
202
+ unmount = () => r.unmount()
203
+
204
+ const start = vi.fn()
205
+ const stop = vi.fn()
206
+ get().registerControls("logs", { start, stop })
207
+ get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
208
+ await tick()
209
+
210
+ get().hotkeys.onToggleFocused()
211
+ expect(start).toHaveBeenCalledTimes(1)
212
+ expect(stop).not.toHaveBeenCalled()
213
+ })
214
+
215
+ test("`q` with a running daemon enters the confirm-quit state instead of quitting", async () => {
216
+ const descriptors = [makeDescriptor("logs")]
217
+ const onQuit = vi.fn()
218
+ const { r, get } = renderWithProviders(descriptors, onQuit)
219
+ unmount = () => r.unmount()
220
+
221
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
222
+ await tick()
223
+
224
+ get().hotkeys.handle("q", {})
225
+ await tick()
226
+
227
+ expect(onQuit).not.toHaveBeenCalled()
228
+ expect(get().quitConfirming).toBe(true)
229
+ })
230
+
231
+ test("pressing `y` while confirming triggers cascade shutdown", async () => {
232
+ const descriptors = [makeDescriptor("logs")]
233
+ const onQuit = vi.fn()
234
+ const { r, get } = renderWithProviders(descriptors, onQuit)
235
+ unmount = () => r.unmount()
236
+
237
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
238
+ await tick()
239
+
240
+ get().hotkeys.handle("q", {})
241
+ await tick()
242
+ expect(get().quitConfirming).toBe(true)
243
+
244
+ get().hotkeys.handle("y", {})
245
+ await tick()
246
+ expect(onQuit).toHaveBeenCalledTimes(1)
247
+ expect(get().quitConfirming).toBe(false)
248
+ })
249
+
250
+ test("pressing `n` while confirming cancels back to the prior view", async () => {
251
+ const descriptors = [makeDescriptor("logs")]
252
+ const onQuit = vi.fn()
253
+ const { r, get } = renderWithProviders(descriptors, onQuit)
254
+ unmount = () => r.unmount()
255
+
256
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
257
+ await tick()
258
+
259
+ get().hotkeys.handle("q", {})
260
+ await tick()
261
+ expect(get().quitConfirming).toBe(true)
262
+
263
+ get().hotkeys.handle("n", {})
264
+ await tick()
265
+ expect(get().quitConfirming).toBe(false)
266
+ expect(onQuit).not.toHaveBeenCalled()
267
+ })
268
+
269
+ test("pressing `esc` while confirming cancels", async () => {
270
+ const descriptors = [makeDescriptor("logs")]
271
+ const onQuit = vi.fn()
272
+ const { r, get } = renderWithProviders(descriptors, onQuit)
273
+ unmount = () => r.unmount()
274
+
275
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
276
+ await tick()
277
+ get().hotkeys.handle("q", {})
278
+ await tick()
279
+
280
+ get().hotkeys.handle("", { escape: true })
281
+ await tick()
282
+ expect(get().quitConfirming).toBe(false)
283
+ expect(onQuit).not.toHaveBeenCalled()
284
+ })
285
+
286
+ test("while confirming, number keys are swallowed", async () => {
287
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
288
+ const onQuit = vi.fn()
289
+ const { r, get } = renderWithProviders(descriptors, onQuit)
290
+ unmount = () => r.unmount()
291
+
292
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
293
+ get().registerRow({ descriptor: descriptors[1], status: "idle", lines: [], exitCode: null })
294
+ await tick()
295
+ get().hotkeys.handle("q", {})
296
+ await tick()
297
+ expect(get().quitConfirming).toBe(true)
298
+
299
+ // Number key while confirming should NOT navigate.
300
+ get().hotkeys.handle("2", {})
301
+ await tick()
302
+ expect(get().view.kind).toBe("list")
303
+ expect(get().quitConfirming).toBe(true)
304
+ })
305
+
306
+ test("`q` with no running daemons quits immediately (no confirm)", async () => {
307
+ const descriptors = [makeDescriptor("logs")]
308
+ const onQuit = vi.fn()
309
+ const { r, get } = renderWithProviders(descriptors, onQuit)
310
+ unmount = () => r.unmount()
311
+
312
+ // All-idle rows.
313
+ get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
314
+ await tick()
315
+
316
+ get().hotkeys.handle("q", {})
317
+ await tick()
318
+ expect(onQuit).toHaveBeenCalledTimes(1)
319
+ expect(get().quitConfirming).toBe(false)
320
+ })
321
+
322
+ test("`c` in log view clears the focused daemon's ring buffer", async () => {
323
+ const descriptors = [makeDescriptor("logs")]
324
+ const { r, get } = renderWithProviders(descriptors, () => {})
325
+ unmount = () => r.unmount()
326
+
327
+ const clear = vi.fn()
328
+ get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), clear })
329
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
330
+ await tick()
331
+
332
+ // Drill into log view first.
333
+ get().hotkeys.handle("1", {})
334
+ await tick()
335
+ expect(get().view.kind).toBe("log")
336
+
337
+ get().hotkeys.handle("c", {})
338
+ expect(clear).toHaveBeenCalledTimes(1)
339
+ })
340
+
341
+ test("`c` in list view is a no-op (does not clear)", async () => {
342
+ const descriptors = [makeDescriptor("logs")]
343
+ const { r, get } = renderWithProviders(descriptors, () => {})
344
+ unmount = () => r.unmount()
345
+
346
+ const clear = vi.fn()
347
+ get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), clear })
348
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
349
+ await tick()
350
+
351
+ get().hotkeys.handle("c", {})
352
+ expect(clear).not.toHaveBeenCalled()
353
+ })
354
+
355
+ test("`k` on a blocked focused row calls takeover", async () => {
356
+ const descriptors = [makeDescriptor("logs")]
357
+ const { r, get } = renderWithProviders(descriptors, () => {})
358
+ unmount = () => r.unmount()
359
+
360
+ const takeover = vi.fn()
361
+ get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), takeover })
362
+ get().registerRow({ descriptor: descriptors[0], status: "blocked", lines: [], exitCode: null })
363
+ await tick()
364
+
365
+ get().hotkeys.handle("k", {})
366
+ expect(takeover).toHaveBeenCalledTimes(1)
367
+ })
368
+
369
+ test("`k` on a running focused row is a no-op", async () => {
370
+ const descriptors = [makeDescriptor("logs")]
371
+ const { r, get } = renderWithProviders(descriptors, () => {})
372
+ unmount = () => r.unmount()
373
+
374
+ const takeover = vi.fn()
375
+ get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), takeover })
376
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
377
+ await tick()
378
+
379
+ get().hotkeys.handle("k", {})
380
+ expect(takeover).not.toHaveBeenCalled()
381
+ })
382
+
383
+ test("left arrow from list-view cycles focus backward and wraps from 0 to last", async () => {
384
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
385
+ const { r, get } = renderWithProviders(descriptors, () => {})
386
+ unmount = () => r.unmount()
387
+
388
+ for (const d of descriptors) {
389
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
390
+ }
391
+ await tick()
392
+ // focusedIdx starts at 0; left wraps to last (index 2).
393
+ get().hotkeys.handle("", { leftArrow: true })
394
+ await tick()
395
+ // Still in list view; assert via onToggleFocused targeting the focused row.
396
+ expect(get().view.kind).toBe("list")
397
+ const stop = vi.fn()
398
+ get().registerControls("playwright", { start: vi.fn(), stop })
399
+ get().registerRow({ descriptor: descriptors[2], status: "running", lines: [], exitCode: null })
400
+ await tick()
401
+ get().hotkeys.onToggleFocused()
402
+ expect(stop).toHaveBeenCalledTimes(1)
403
+ })
404
+
405
+ test("right arrow from list-view cycles focus forward and wraps from last to 0", async () => {
406
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
407
+ const { r, get } = renderWithProviders(descriptors, () => {})
408
+ unmount = () => r.unmount()
409
+
410
+ for (const d of descriptors) {
411
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
412
+ }
413
+ await tick()
414
+ // Advance focus to the last (index 2): two right presses.
415
+ get().hotkeys.handle("", { rightArrow: true })
416
+ await tick()
417
+ get().hotkeys.handle("", { rightArrow: true })
418
+ await tick()
419
+ // One more right wraps back to index 0.
420
+ get().hotkeys.handle("", { rightArrow: true })
421
+ await tick()
422
+ expect(get().view.kind).toBe("list")
423
+ const stop = vi.fn()
424
+ get().registerControls("logs", { start: vi.fn(), stop })
425
+ get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
426
+ await tick()
427
+ get().hotkeys.onToggleFocused()
428
+ expect(stop).toHaveBeenCalledTimes(1)
429
+ })
430
+
431
+ test("left arrow from log-view cycles the focused log target backward", async () => {
432
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
433
+ const { r, get } = renderWithProviders(descriptors, () => {})
434
+ unmount = () => r.unmount()
435
+
436
+ for (const d of descriptors) {
437
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
438
+ }
439
+ await tick()
440
+ // Drill into log view on daemon 2 (index 1).
441
+ get().hotkeys.handle("2", {})
442
+ await tick()
443
+ expect(get().view).toEqual({ kind: "log", key: "vitest" })
444
+
445
+ get().hotkeys.handle("", { leftArrow: true })
446
+ await tick()
447
+ expect(get().view).toEqual({ kind: "log", key: "logs" })
448
+ })
449
+
450
+ test("right arrow from log-view cycles the focused log target forward", async () => {
451
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
452
+ const { r, get } = renderWithProviders(descriptors, () => {})
453
+ unmount = () => r.unmount()
454
+
455
+ for (const d of descriptors) {
456
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
457
+ }
458
+ await tick()
459
+ get().hotkeys.handle("1", {})
460
+ await tick()
461
+ expect(get().view).toEqual({ kind: "log", key: "logs" })
462
+
463
+ get().hotkeys.handle("", { rightArrow: true })
464
+ await tick()
465
+ expect(get().view).toEqual({ kind: "log", key: "vitest" })
466
+ })
467
+
468
+ test("tab from list-view cycles focus forward like right arrow", async () => {
469
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
470
+ const { r, get } = renderWithProviders(descriptors, () => {})
471
+ unmount = () => r.unmount()
472
+
473
+ for (const d of descriptors) {
474
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
475
+ }
476
+ await tick()
477
+ // From focusedIdx 0, one tab -> idx 1 (vitest).
478
+ get().hotkeys.handle("", { tab: true })
479
+ await tick()
480
+ expect(get().view.kind).toBe("list")
481
+ const stop = vi.fn()
482
+ get().registerControls("vitest", { start: vi.fn(), stop })
483
+ get().registerRow({ descriptor: descriptors[1], status: "running", lines: [], exitCode: null })
484
+ await tick()
485
+ get().hotkeys.onToggleFocused()
486
+ expect(stop).toHaveBeenCalledTimes(1)
487
+ })
488
+
489
+ test("shift+tab from list-view cycles focus backward like left arrow", async () => {
490
+ const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
491
+ const { r, get } = renderWithProviders(descriptors, () => {})
492
+ unmount = () => r.unmount()
493
+
494
+ for (const d of descriptors) {
495
+ get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
496
+ }
497
+ await tick()
498
+ // From focusedIdx 0, shift+tab wraps backward -> idx 2 (playwright).
499
+ get().hotkeys.handle("", { tab: true, shift: true })
500
+ await tick()
501
+ expect(get().view.kind).toBe("list")
502
+ const stop = vi.fn()
503
+ get().registerControls("playwright", { start: vi.fn(), stop })
504
+ get().registerRow({ descriptor: descriptors[2], status: "running", lines: [], exitCode: null })
505
+ await tick()
506
+ get().hotkeys.onToggleFocused()
507
+ expect(stop).toHaveBeenCalledTimes(1)
508
+ })
@@ -0,0 +1,164 @@
1
+ // Pure hotkey dispatcher. Takes a raw key/input shape and routes it
2
+ // through the view + daemons + quit contexts. Exposed as a hook so tests
3
+ // can invoke handlers directly without piping bytes through Ink's
4
+ // raw-mode machinery.
5
+
6
+ import { useCallback } from "react"
7
+
8
+ import { useView } from "../state/view-context.tsx"
9
+ import { useDaemons } from "../state/daemons-context.tsx"
10
+ import { useQuit } from "../state/quit-context.tsx"
11
+
12
+ export interface KeyEvent {
13
+ ctrl?: boolean
14
+ escape?: boolean
15
+ leftArrow?: boolean
16
+ rightArrow?: boolean
17
+ tab?: boolean
18
+ shift?: boolean
19
+ }
20
+
21
+ export interface HotkeyHandlers {
22
+ onQuit: () => void
23
+ onNumberKey: (idx: number) => void
24
+ onEscape: () => void
25
+ // List-view-only: handle `s` toggle on the focused row.
26
+ onToggleFocused: () => void
27
+ // List-view-only: kill external port owner and re-spawn when blocked.
28
+ onTakeoverFocused: () => void
29
+ // Log-view-only: clear the currently-viewed daemon's ring buffer.
30
+ onClearLog: () => void
31
+ // Cycle the focused daemon by +1 / -1 with wrap-around. In log view this
32
+ // also swaps the displayed daemon; in list view it just moves focus.
33
+ onCycleFocus: (offset: 1 | -1) => void
34
+ // Master dispatcher used by ink's useInput.
35
+ handle: (input: string, key: KeyEvent) => void
36
+ }
37
+
38
+ // `quit` is the cascade-shutdown trigger from QuitController. We invoke
39
+ // it directly when no daemons are live; otherwise we route through the
40
+ // confirm overlay (requestConfirm).
41
+ export function useHotkeys(quit: () => void): HotkeyHandlers {
42
+ const { showLog, showList, setFocusedIdx, view, focusedIdx } = useView()
43
+ const { rowsRef, toggleByKey, clearByKey, takeoverByKey, anyLive } = useDaemons()
44
+ const { confirming, requestConfirm, cancelConfirm } = useQuit()
45
+
46
+ const onQuit = useCallback(() => {
47
+ if (anyLive()) {
48
+ requestConfirm()
49
+ } else {
50
+ quit()
51
+ }
52
+ }, [quit, anyLive, requestConfirm])
53
+
54
+ const onNumberKey = useCallback(
55
+ (idx: number) => {
56
+ const target = rowsRef.current[idx]
57
+ if (!target) return
58
+ setFocusedIdx(idx)
59
+ showLog(target.descriptor.key)
60
+ },
61
+ [rowsRef, setFocusedIdx, showLog],
62
+ )
63
+
64
+ const onEscape = useCallback(() => {
65
+ if (view.kind === "log") showList()
66
+ }, [view, showList])
67
+
68
+ const onToggleFocused = useCallback(() => {
69
+ const target = rowsRef.current[focusedIdx]
70
+ if (!target) return
71
+ toggleByKey(target.descriptor.key)
72
+ }, [rowsRef, focusedIdx, toggleByKey])
73
+
74
+ const onTakeoverFocused = useCallback(() => {
75
+ const target = rowsRef.current[focusedIdx]
76
+ if (!target) return
77
+ takeoverByKey(target.descriptor.key)
78
+ }, [rowsRef, focusedIdx, takeoverByKey])
79
+
80
+ const onClearLog = useCallback(() => {
81
+ if (view.kind !== "log") return
82
+ clearByKey(view.key)
83
+ }, [view, clearByKey])
84
+
85
+ const onCycleFocus = useCallback(
86
+ (offset: 1 | -1) => {
87
+ const n = rowsRef.current.length
88
+ if (n === 0) return
89
+ const nextIdx = (focusedIdx + offset + n) % n
90
+ setFocusedIdx(nextIdx)
91
+ if (view.kind === "log") {
92
+ const target = rowsRef.current[nextIdx]
93
+ if (target) showLog(target.descriptor.key)
94
+ }
95
+ },
96
+ [rowsRef, focusedIdx, setFocusedIdx, view, showLog],
97
+ )
98
+
99
+ const handle = useCallback(
100
+ (input: string, key: KeyEvent) => {
101
+ // Confirm overlay swallows everything except y / n / esc.
102
+ if (confirming) {
103
+ if (input === "y") {
104
+ cancelConfirm()
105
+ quit()
106
+ return
107
+ }
108
+ if (input === "n" || key.escape) {
109
+ cancelConfirm()
110
+ return
111
+ }
112
+ return
113
+ }
114
+ if (input === "q") {
115
+ onQuit()
116
+ return
117
+ }
118
+ if (key.ctrl && input === "c") {
119
+ onQuit()
120
+ return
121
+ }
122
+ if (input === "c" && view.kind === "log") {
123
+ onClearLog()
124
+ return
125
+ }
126
+ if (input === "k" && view.kind === "list") {
127
+ onTakeoverFocused()
128
+ return
129
+ }
130
+ if (/^[1-9]$/.test(input)) {
131
+ onNumberKey(Number(input) - 1)
132
+ return
133
+ }
134
+ if (key.leftArrow) {
135
+ onCycleFocus(-1)
136
+ return
137
+ }
138
+ if (key.rightArrow) {
139
+ onCycleFocus(1)
140
+ return
141
+ }
142
+ if (key.tab) {
143
+ onCycleFocus(key.shift ? -1 : 1)
144
+ return
145
+ }
146
+ if (key.escape) {
147
+ onEscape()
148
+ return
149
+ }
150
+ },
151
+ [confirming, cancelConfirm, quit, onQuit, onNumberKey, onEscape, onClearLog, onTakeoverFocused, onCycleFocus, view],
152
+ )
153
+
154
+ return {
155
+ onQuit,
156
+ onNumberKey,
157
+ onEscape,
158
+ onToggleFocused,
159
+ onTakeoverFocused,
160
+ onClearLog,
161
+ onCycleFocus,
162
+ handle,
163
+ }
164
+ }