@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,69 @@
1
+ // Verifies killTree shells out to taskkill on Windows and uses
2
+ // process.kill on POSIX. We can't actually fork+kill in unit tests
3
+ // reliably across platforms, so we mock child_process / process.kill.
4
+
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
6
+
7
+ vi.mock("node:child_process", () => {
8
+ return {
9
+ exec: vi.fn(
10
+ (
11
+ _cmd: string,
12
+ cb: (err: Error | null, stdout: string, stderr: string) => void,
13
+ ) => {
14
+ cb(null, "", "")
15
+ },
16
+ ),
17
+ }
18
+ })
19
+
20
+ import { exec } from "node:child_process"
21
+ import { killTree } from "./kill-tree.ts"
22
+
23
+ const originalPlatform = process.platform
24
+
25
+ function setPlatform(p: NodeJS.Platform): void {
26
+ Object.defineProperty(process, "platform", { value: p, configurable: true })
27
+ }
28
+
29
+ describe("killTree", () => {
30
+ beforeEach(() => {
31
+ vi.mocked(exec).mockClear()
32
+ })
33
+
34
+ afterEach(() => {
35
+ setPlatform(originalPlatform)
36
+ })
37
+
38
+ test("Windows path shells out to taskkill /T /F /PID", async () => {
39
+ setPlatform("win32")
40
+ await killTree(1234, "SIGTERM")
41
+ expect(exec).toHaveBeenCalledTimes(1)
42
+ const cmd = vi.mocked(exec).mock.calls[0][0]
43
+ expect(cmd).toBe("taskkill /T /F /PID 1234")
44
+ })
45
+
46
+ test("Windows ignores SIGKILL/SIGTERM distinction (always /F)", async () => {
47
+ setPlatform("win32")
48
+ await killTree(99, "SIGKILL")
49
+ const cmd = vi.mocked(exec).mock.calls[0][0]
50
+ expect(cmd).toBe("taskkill /T /F /PID 99")
51
+ })
52
+
53
+ test("POSIX path calls process.kill with the given signal", async () => {
54
+ setPlatform("linux")
55
+ const spy = vi.spyOn(process, "kill").mockImplementation(() => true)
56
+ await killTree(1234, "SIGTERM")
57
+ expect(spy).toHaveBeenCalledWith(1234, "SIGTERM")
58
+ spy.mockRestore()
59
+ })
60
+
61
+ test("POSIX swallows ESRCH (process already gone)", async () => {
62
+ setPlatform("linux")
63
+ const spy = vi.spyOn(process, "kill").mockImplementation(() => {
64
+ throw new Error("ESRCH")
65
+ })
66
+ await expect(killTree(1234, "SIGKILL")).resolves.toBeUndefined()
67
+ spy.mockRestore()
68
+ })
69
+ })
@@ -0,0 +1,31 @@
1
+ // Cross-platform process-tree kill.
2
+ //
3
+ // Windows: shell out to `taskkill /T /F /PID <pid>`. `/T` kills the whole
4
+ // tree (parent + descendants); `/F` is force — `/T` without `/F` is
5
+ // unreliable. The Windows path ignores the signal argument by design — we
6
+ // always hard-terminate because Node's SIGTERM-on-Windows already maps to
7
+ // TerminateProcess anyway, so "graceful" SIGTERM doesn't exist for us.
8
+ //
9
+ // POSIX: best-effort `process.kill(pid, signal)` to the immediate child.
10
+ // We do NOT walk /proc — none of the davstack daemons fork grandchildren
11
+ // on Linux per the spawn audit, and kernel SIGTERM-to-PGID would require
12
+ // `detached:true` on spawn which we deliberately don't do.
13
+
14
+ import { exec } from "node:child_process"
15
+
16
+ export async function killTree(
17
+ pid: number,
18
+ signal: "SIGTERM" | "SIGKILL",
19
+ ): Promise<void> {
20
+ if (process.platform === "win32") {
21
+ await new Promise<void>((resolve) => {
22
+ exec(`taskkill /T /F /PID ${pid}`, () => resolve())
23
+ })
24
+ return
25
+ }
26
+ try {
27
+ process.kill(pid, signal)
28
+ } catch {
29
+ // Process already gone — nothing to do.
30
+ }
31
+ }
@@ -0,0 +1,35 @@
1
+ // Small helpers for the empty-state block — package version + repo root.
2
+ // Pulled out so MainView doesn't need to deal with JSON-import gymnastics
3
+ // or repo-root throw-paths.
4
+
5
+ import fs from "node:fs"
6
+ import path from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+
9
+ import { findRepoRoot } from "./repo-root.ts"
10
+
11
+ let cachedVersion: string | null = null
12
+
13
+ export function getPackageVersion(): string {
14
+ if (cachedVersion !== null) return cachedVersion
15
+ try {
16
+ // package-info.ts lives at packages/tui/src/lib/. The package.json is
17
+ // two dirs up.
18
+ const here = path.dirname(fileURLToPath(import.meta.url))
19
+ const pkgPath = path.join(here, "..", "..", "package.json")
20
+ const raw = fs.readFileSync(pkgPath, "utf8")
21
+ const parsed = JSON.parse(raw) as { version?: string }
22
+ cachedVersion = parsed.version ?? "0.0.0"
23
+ } catch {
24
+ cachedVersion = "0.0.0"
25
+ }
26
+ return cachedVersion
27
+ }
28
+
29
+ export function getRepoRootSafe(): string {
30
+ try {
31
+ return findRepoRoot(process.cwd())
32
+ } catch {
33
+ return process.cwd()
34
+ }
35
+ }
@@ -0,0 +1,105 @@
1
+ // Mock child_process.exec for netstat / lsof shell-outs. Assert parse for:
2
+ // zero matches → null, single match → that PID, multiple matches → first PID.
3
+
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
5
+
6
+ vi.mock("node:child_process", () => {
7
+ return {
8
+ exec: vi.fn(
9
+ (
10
+ _cmd: string,
11
+ cb: (err: Error | null, stdout: string, stderr: string) => void,
12
+ ) => {
13
+ cb(null, "", "")
14
+ },
15
+ ),
16
+ }
17
+ })
18
+
19
+ import { exec } from "node:child_process"
20
+ import { findPortOwner } from "./port-owner.ts"
21
+
22
+ const originalPlatform = process.platform
23
+
24
+ function setPlatform(p: NodeJS.Platform): void {
25
+ Object.defineProperty(process, "platform", { value: p, configurable: true })
26
+ }
27
+
28
+ function mockExecStdout(stdout: string): void {
29
+ vi.mocked(exec).mockImplementation(
30
+ (_cmd, cb) => {
31
+ ;(cb as (err: null, stdout: string, stderr: string) => void)(null, stdout, "")
32
+ return undefined as never
33
+ },
34
+ )
35
+ }
36
+
37
+ describe("findPortOwner", () => {
38
+ beforeEach(() => {
39
+ vi.mocked(exec).mockClear()
40
+ })
41
+
42
+ afterEach(() => {
43
+ setPlatform(originalPlatform)
44
+ })
45
+
46
+ test("Windows: zero netstat matches → null", async () => {
47
+ setPlatform("win32")
48
+ mockExecStdout(" TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING 999\n")
49
+ expect(await findPortOwner("127.0.0.1", 7077)).toBeNull()
50
+ })
51
+
52
+ test("Windows: single LISTENING match → that PID", async () => {
53
+ setPlatform("win32")
54
+ mockExecStdout(
55
+ " TCP 127.0.0.1:7077 0.0.0.0:0 LISTENING 4242\n",
56
+ )
57
+ expect(await findPortOwner("127.0.0.1", 7077)).toBe(4242)
58
+ })
59
+
60
+ test("Windows: 0.0.0.0 bind matches 127.0.0.1 host", async () => {
61
+ setPlatform("win32")
62
+ mockExecStdout(
63
+ " TCP 0.0.0.0:7077 0.0.0.0:0 LISTENING 5555\n",
64
+ )
65
+ expect(await findPortOwner("127.0.0.1", 7077)).toBe(5555)
66
+ })
67
+
68
+ test("Windows: multiple matches → first PID", async () => {
69
+ setPlatform("win32")
70
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
71
+ mockExecStdout(
72
+ [
73
+ " TCP 127.0.0.1:7077 0.0.0.0:0 LISTENING 1111",
74
+ " TCP 0.0.0.0:7077 0.0.0.0:0 LISTENING 2222",
75
+ ].join("\n"),
76
+ )
77
+ expect(await findPortOwner("127.0.0.1", 7077)).toBe(1111)
78
+ expect(warn).toHaveBeenCalled()
79
+ warn.mockRestore()
80
+ })
81
+
82
+ test("POSIX: single lsof match → that PID", async () => {
83
+ setPlatform("linux")
84
+ mockExecStdout("4242\n")
85
+ const pid = await findPortOwner("127.0.0.1", 7077)
86
+ const cmd = vi.mocked(exec).mock.calls[0][0]
87
+ expect(cmd).toBe("lsof -nP -iTCP:7077 -sTCP:LISTEN -t")
88
+ expect(pid).toBe(4242)
89
+ })
90
+
91
+ test("POSIX: empty lsof output → null", async () => {
92
+ setPlatform("linux")
93
+ mockExecStdout("")
94
+ expect(await findPortOwner("127.0.0.1", 7077)).toBeNull()
95
+ })
96
+
97
+ test("POSIX: multiple PIDs → first", async () => {
98
+ setPlatform("linux")
99
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
100
+ mockExecStdout("1111\n2222\n")
101
+ expect(await findPortOwner("127.0.0.1", 7077)).toBe(1111)
102
+ expect(warn).toHaveBeenCalled()
103
+ warn.mockRestore()
104
+ })
105
+ })
@@ -0,0 +1,90 @@
1
+ // Resolve the PID listening on a TCP port. Pure lookup — no side effects.
2
+ //
3
+ // Windows: `netstat -ano -p tcp` (no admin). POSIX: `lsof` only in v1 — if
4
+ // missing, return null and log rather than trying ss/fuser fallbacks.
5
+
6
+ import { exec } from "node:child_process"
7
+
8
+ function execStdout(cmd: string): Promise<string> {
9
+ return new Promise((resolve, reject) => {
10
+ exec(cmd, (err, stdout) => {
11
+ if (err) reject(err)
12
+ else resolve(stdout)
13
+ })
14
+ })
15
+ }
16
+
17
+ export async function findPortOwner(host: string, port: number): Promise<number | null> {
18
+ if (process.platform === "win32") {
19
+ return findPortOwnerWindows(host, port)
20
+ }
21
+ return findPortOwnerPosix(port)
22
+ }
23
+
24
+ function matchesHost(localHost: string, targetHost: string): boolean {
25
+ if (localHost === "0.0.0.0" || localHost === "[::]" || localHost === "::") return true
26
+ if (targetHost === "127.0.0.1" && localHost === "127.0.0.1") return true
27
+ if (targetHost === "localhost" && localHost === "127.0.0.1") return true
28
+ if (targetHost === "::1" && (localHost === "::1" || localHost === "[::1]")) return true
29
+ return localHost === targetHost || localHost === `[${targetHost}]`
30
+ }
31
+
32
+ function parseNetstatLine(line: string, host: string, port: number): number | null {
33
+ if (!line.includes("LISTENING")) return null
34
+ const parts = line.trim().split(/\s+/)
35
+ if (parts.length < 5 || parts[3] !== "LISTENING") return null
36
+
37
+ const local = parts[1]
38
+ const portMatch = local.match(/:(\d+)$/)
39
+ if (!portMatch || Number(portMatch[1]) !== port) return null
40
+
41
+ const localHost = local.slice(0, local.lastIndexOf(":"))
42
+ if (!matchesHost(localHost, host)) return null
43
+
44
+ const pid = Number(parts[4])
45
+ return Number.isFinite(pid) && pid > 0 ? pid : null
46
+ }
47
+
48
+ function pickFirstPid(pids: number[], port: number): number | null {
49
+ if (pids.length === 0) return null
50
+ if (pids.length > 1) {
51
+ // eslint-disable-next-line no-console
52
+ console.warn(
53
+ `[davstack tui] multiple PIDs listening on :${port}, using first (${pids[0]})`,
54
+ )
55
+ }
56
+ return pids[0]
57
+ }
58
+
59
+ async function findPortOwnerWindows(host: string, port: number): Promise<number | null> {
60
+ try {
61
+ const stdout = await execStdout("netstat -ano -p tcp")
62
+ const pids: number[] = []
63
+ for (const line of stdout.split(/\r?\n/)) {
64
+ const pid = parseNetstatLine(line, host, port)
65
+ if (pid != null) pids.push(pid)
66
+ }
67
+ return pickFirstPid(pids, port)
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ async function findPortOwnerPosix(port: number): Promise<number | null> {
74
+ try {
75
+ const stdout = await execStdout(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`)
76
+ const pids = stdout
77
+ .split(/\r?\n/)
78
+ .map((s) => s.trim())
79
+ .filter(Boolean)
80
+ .map(Number)
81
+ .filter((p) => Number.isFinite(p) && p > 0)
82
+ return pickFirstPid(pids, port)
83
+ } catch (err) {
84
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
85
+ // eslint-disable-next-line no-console
86
+ console.warn("[davstack tui] lsof not found — cannot identify port owner")
87
+ }
88
+ return null
89
+ }
90
+ }
@@ -0,0 +1,41 @@
1
+ // 3 cases: open port (real listener), refused (unbound port), timeout.
2
+
3
+ import net from "node:net"
4
+ import { describe, expect, test } from "vitest"
5
+
6
+ import { probePort } from "./port-probe.ts"
7
+
8
+ function listenOn(port: number): Promise<net.Server> {
9
+ return new Promise((resolve, reject) => {
10
+ const srv = net.createServer()
11
+ srv.on("error", reject)
12
+ srv.listen(port, "127.0.0.1", () => resolve(srv))
13
+ })
14
+ }
15
+
16
+ function close(srv: net.Server): Promise<void> {
17
+ return new Promise((resolve) => srv.close(() => resolve()))
18
+ }
19
+
20
+ describe("probePort", () => {
21
+ test("returns true when a listener is bound", async () => {
22
+ const srv = await listenOn(0)
23
+ const addr = srv.address() as net.AddressInfo
24
+ const open = await probePort("127.0.0.1", addr.port, 500)
25
+ expect(open).toBe(true)
26
+ await close(srv)
27
+ })
28
+
29
+ test("returns false on ECONNREFUSED (no listener)", async () => {
30
+ // Pick a high port unlikely to be bound. 1 is reserved, never bound.
31
+ const open = await probePort("127.0.0.1", 1, 500)
32
+ expect(open).toBe(false)
33
+ })
34
+
35
+ test("returns false on timeout (route to TEST-NET-1)", async () => {
36
+ // 192.0.2.0/24 is RFC-5737 TEST-NET-1, guaranteed not routable.
37
+ // With a 50ms timeout we should never connect.
38
+ const open = await probePort("192.0.2.1", 9, 50)
39
+ expect(open).toBe(false)
40
+ })
41
+ })
@@ -0,0 +1,29 @@
1
+ // Single-shot TCP probe — does anything answer on <host>:<port>?
2
+ // Returns true if a connection succeeds (then immediately destroys the
3
+ // socket), false on ECONNREFUSED or timeout. Used as a preflight before
4
+ // spawning a daemon so we can surface "blocked by external process"
5
+ // instead of letting the daemon crash with EADDRINUSE.
6
+
7
+ import net from "node:net"
8
+
9
+ export async function probePort(
10
+ host: string,
11
+ port: number,
12
+ timeoutMs: number = 250,
13
+ ): Promise<boolean> {
14
+ return new Promise<boolean>((resolve) => {
15
+ const sock = new net.Socket()
16
+ let settled = false
17
+ const finish = (open: boolean): void => {
18
+ if (settled) return
19
+ settled = true
20
+ sock.destroy()
21
+ resolve(open)
22
+ }
23
+ sock.setTimeout(timeoutMs)
24
+ sock.once("connect", () => finish(true))
25
+ sock.once("timeout", () => finish(false))
26
+ sock.once("error", () => finish(false))
27
+ sock.connect(port, host)
28
+ })
29
+ }
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { test, expect, describe } from "vitest"
5
+
6
+ import { findRepoRoot } from "./repo-root.ts"
7
+
8
+ function mkTmp(prefix: string): string {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
10
+ }
11
+
12
+ describe("findRepoRoot", () => {
13
+ test("locates root via pnpm-workspace.yaml from a nested dir", () => {
14
+ const root = mkTmp("davstack-rr-")
15
+ fs.writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n")
16
+ const nested = path.join(root, "packages", "foo", "src", "deep")
17
+ fs.mkdirSync(nested, { recursive: true })
18
+ // realpath: tmpdir on macOS/Windows can be a symlink alias.
19
+ expect(fs.realpathSync(findRepoRoot(nested))).toBe(fs.realpathSync(root))
20
+ })
21
+
22
+ test("locates root via private package.json when no workspace yaml", () => {
23
+ const root = mkTmp("davstack-rr-")
24
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "x", private: true }))
25
+ const nested = path.join(root, "a", "b")
26
+ fs.mkdirSync(nested, { recursive: true })
27
+ expect(fs.realpathSync(findRepoRoot(nested))).toBe(fs.realpathSync(root))
28
+ })
29
+
30
+ test("throws when not inside a davstack-shaped repo", () => {
31
+ const root = mkTmp("davstack-rr-nope-")
32
+ // No workspace yaml, no private package.json.
33
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "x" }))
34
+ expect(() => findRepoRoot(root)).toThrow(/not inside a davstack-shaped repo/)
35
+ })
36
+ })
@@ -0,0 +1,30 @@
1
+ // Walk up from a starting directory to find the davstack repo root.
2
+ // A davstack-shaped repo has either a `pnpm-workspace.yaml` or a top-level
3
+ // `package.json` with `"private": true` at its root.
4
+
5
+ import fs from "node:fs"
6
+ import path from "node:path"
7
+
8
+ export function findRepoRoot(startDir: string): string {
9
+ let cur = path.resolve(startDir)
10
+ // Stop at filesystem root.
11
+ while (true) {
12
+ if (fs.existsSync(path.join(cur, "pnpm-workspace.yaml"))) return cur
13
+ const pkgPath = path.join(cur, "package.json")
14
+ if (fs.existsSync(pkgPath)) {
15
+ try {
16
+ const raw = fs.readFileSync(pkgPath, "utf8")
17
+ const parsed = JSON.parse(raw) as { private?: boolean }
18
+ if (parsed.private === true) return cur
19
+ } catch {
20
+ // Ignore malformed package.json and keep walking.
21
+ }
22
+ }
23
+ const parent = path.dirname(cur)
24
+ if (parent === cur) break
25
+ cur = parent
26
+ }
27
+ throw new Error(
28
+ `findRepoRoot: not inside a davstack-shaped repo (no pnpm-workspace.yaml or private package.json found walking up from ${startDir})`,
29
+ )
30
+ }
@@ -0,0 +1,63 @@
1
+ import { test, expect, describe } from "vitest"
2
+
3
+ import { RingBuffer } from "./ring-buffer.ts"
4
+
5
+ describe("RingBuffer", () => {
6
+ test("empty buffer has size 0 and empty array", () => {
7
+ const rb = new RingBuffer<number>(5)
8
+ expect(rb.size).toBe(0)
9
+ expect(rb.toArray()).toEqual([])
10
+ })
11
+
12
+ test("partial fill preserves insertion order", () => {
13
+ const rb = new RingBuffer<number>(5)
14
+ rb.push(1)
15
+ rb.push(2)
16
+ rb.push(3)
17
+ expect(rb.size).toBe(3)
18
+ expect(rb.toArray()).toEqual([1, 2, 3])
19
+ })
20
+
21
+ test("exactly-full fill preserves all entries in order", () => {
22
+ const rb = new RingBuffer<number>(3)
23
+ rb.push(1)
24
+ rb.push(2)
25
+ rb.push(3)
26
+ expect(rb.size).toBe(3)
27
+ expect(rb.toArray()).toEqual([1, 2, 3])
28
+ })
29
+
30
+ test("wraps once: oldest entry overwritten", () => {
31
+ const rb = new RingBuffer<number>(3)
32
+ rb.push(1)
33
+ rb.push(2)
34
+ rb.push(3)
35
+ rb.push(4)
36
+ expect(rb.size).toBe(3)
37
+ expect(rb.toArray()).toEqual([2, 3, 4])
38
+ })
39
+
40
+ test("wraps many times: only last `capacity` entries remain", () => {
41
+ const rb = new RingBuffer<number>(3)
42
+ for (let i = 1; i <= 10; i++) rb.push(i)
43
+ expect(rb.size).toBe(3)
44
+ expect(rb.toArray()).toEqual([8, 9, 10])
45
+ })
46
+
47
+ test("clear resets size and contents but capacity remains", () => {
48
+ const rb = new RingBuffer<number>(3)
49
+ rb.push(1)
50
+ rb.push(2)
51
+ rb.clear()
52
+ expect(rb.size).toBe(0)
53
+ expect(rb.toArray()).toEqual([])
54
+ rb.push(42)
55
+ expect(rb.toArray()).toEqual([42])
56
+ })
57
+
58
+ test("rejects non-positive capacity", () => {
59
+ expect(() => new RingBuffer<number>(0)).toThrow()
60
+ expect(() => new RingBuffer<number>(-1)).toThrow()
61
+ expect(() => new RingBuffer<number>(1.5)).toThrow()
62
+ })
63
+ })
@@ -0,0 +1,47 @@
1
+ // Fixed-capacity circular buffer. Oldest entries overwritten when full.
2
+ // Used to cap memory for live daemon stdout/stderr streams in the TUI.
3
+
4
+ export class RingBuffer<T> {
5
+ private buf: (T | undefined)[]
6
+ private writeIdx = 0
7
+ private count = 0
8
+ private readonly capacity: number
9
+
10
+ constructor(capacity: number) {
11
+ if (!Number.isInteger(capacity) || capacity <= 0) {
12
+ throw new Error(`RingBuffer capacity must be a positive integer, got ${capacity}`)
13
+ }
14
+ this.capacity = capacity
15
+ this.buf = new Array(capacity)
16
+ }
17
+
18
+ push(item: T): void {
19
+ this.buf[this.writeIdx] = item
20
+ this.writeIdx = (this.writeIdx + 1) % this.capacity
21
+ if (this.count < this.capacity) this.count++
22
+ }
23
+
24
+ toArray(): T[] {
25
+ const out: T[] = []
26
+ if (this.count < this.capacity) {
27
+ for (let i = 0; i < this.count; i++) out.push(this.buf[i] as T)
28
+ return out
29
+ }
30
+ // Full — oldest entry sits at writeIdx (the next slot to overwrite).
31
+ for (let i = 0; i < this.capacity; i++) {
32
+ const idx = (this.writeIdx + i) % this.capacity
33
+ out.push(this.buf[idx] as T)
34
+ }
35
+ return out
36
+ }
37
+
38
+ get size(): number {
39
+ return this.count
40
+ }
41
+
42
+ clear(): void {
43
+ this.buf = new Array(this.capacity)
44
+ this.writeIdx = 0
45
+ this.count = 0
46
+ }
47
+ }