@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,135 @@
1
+ // todo-supervisor — markdown mode unit tests (§5.8, aspect 14a).
2
+ import { describe, expect, test } from "bun:test"
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
+ import {
5
+ displayPath,
6
+ effectiveFiles,
7
+ MarkdownCache,
8
+ openMarkdownItems,
9
+ parseChecklist,
10
+ renderFileSection,
11
+ resolveMarkdownPath,
12
+ toggleMarkdownItem,
13
+ } from "./markdown"
14
+
15
+ describe("markdown parser (§5.8)", () => {
16
+ test("parses bullets, ordered lists, indentation and states with 1-based line numbers", () => {
17
+ const content = [
18
+ "# Title",
19
+ "- [ ] first",
20
+ " * [x] nested done",
21
+ "1. [ ] ordered",
22
+ "3) [X] upper done",
23
+ "- [~] canceled",
24
+ "plain text",
25
+ "- not a checkbox",
26
+ ].join("\n")
27
+ const items = parseChecklist(content, "/p/TODO.md")
28
+ expect(items.map((entry) => [entry.line, entry.state, entry.text])).toEqual([
29
+ [2, "open", "first"],
30
+ [3, "completed", "nested done"],
31
+ [4, "open", "ordered"],
32
+ [5, "completed", "upper done"],
33
+ [6, "canceled", "canceled"],
34
+ ])
35
+ expect(items[1]!.indent).toBe(" ")
36
+ })
37
+
38
+ test("skips backtick and tilde fenced code blocks", () => {
39
+ const content = [
40
+ "- [ ] real",
41
+ "```md",
42
+ "- [ ] inside backticks",
43
+ "```",
44
+ "~~~",
45
+ "- [ ] inside tildes",
46
+ "~~~",
47
+ "- [x] after",
48
+ ].join("\n")
49
+ const items = parseChecklist(content, "/p/TODO.md")
50
+ expect(items.map((entry) => entry.text)).toEqual(["real", "after"])
51
+ })
52
+
53
+ test("handles CRLF line endings", () => {
54
+ const items = parseChecklist("- [ ] a\r\n- [~] b\r\n", "/p/TODO.md")
55
+ expect(items.map((entry) => entry.state)).toEqual(["open", "canceled"])
56
+ })
57
+ })
58
+
59
+ describe("markdown subscriptions (§5.8)", () => {
60
+ test("resolves and validates paths", () => {
61
+ expect(resolveMarkdownPath("/proj", "docs/TODO.md")).toBe("/proj/docs/TODO.md")
62
+ expect(resolveMarkdownPath("/proj", "/abs/PLAN.markdown")).toBe("/abs/PLAN.markdown")
63
+ expect(resolveMarkdownPath("/proj", "UPPER.MD")).toBe("/proj/UPPER.MD")
64
+ expect(() => resolveMarkdownPath("/proj", "notes.txt")).toThrow(/markdown/)
65
+ expect(() => resolveMarkdownPath("/proj", " ")).toThrow()
66
+ })
67
+
68
+ test("merges config, stored files and removed shadows", () => {
69
+ const options = { markdownFiles: ["PLAN.md", "/abs/OTHER.md"] }
70
+ const subs = { files: ["/proj/docs/TODO.md", "/abs/OTHER.md"], removed: ["/proj/PLAN.md"] }
71
+ expect(effectiveFiles(options, "/proj", subs)).toEqual(["/abs/OTHER.md", "/proj/docs/TODO.md"])
72
+ expect(effectiveFiles(options, "/proj", undefined)).toEqual(["/abs/OTHER.md", "/proj/PLAN.md"])
73
+ })
74
+ })
75
+
76
+ describe("markdown cache + renderer", () => {
77
+ test("caches by mtime/size, invalidates, and reports missing files", () => {
78
+ const dir = "/tmp/opencode"
79
+ mkdirSync(dir, { recursive: true })
80
+ const file = `${dir}/md-cache-${process.pid}.md`
81
+ writeFileSync(file, "- [ ] one\n")
82
+ const cache = new MarkdownCache()
83
+ const first = cache.parse(file)
84
+ expect(first.exists).toBe(true)
85
+ expect(first.items.length).toBe(1)
86
+ expect(cache.parse(file)).toBe(first)
87
+ cache.invalidate(file)
88
+ expect(cache.parse(file)).not.toBe(first)
89
+ const missing = cache.parse(`${dir}/missing-${process.pid}.md`)
90
+ expect(missing.exists).toBe(false)
91
+ expect(missing.items).toEqual([])
92
+ })
93
+
94
+ test("toggles a checklist item in place, fence-aware and format-preserving", () => {
95
+ const dir = "/tmp/opencode"
96
+ mkdirSync(dir, { recursive: true })
97
+ const file = `${dir}/md-toggle-${process.pid}.md`
98
+ writeFileSync(
99
+ file,
100
+ ["# List", " - [ ] nested item ", "```md", "- [ ] fenced", "```", "1) [x] done\r"].join("\r\n"),
101
+ )
102
+ // open -> completed, indentation/bullet/trailing spaces preserved
103
+ expect(toggleMarkdownItem(file, 2)).toBe("completed")
104
+ expect(readFileSync(file, "utf8")).toContain(" - [x] nested item ")
105
+ // completed -> open
106
+ expect(toggleMarkdownItem(file, 6)).toBe("open")
107
+ expect(readFileSync(file, "utf8")).toContain("1) [ ] done")
108
+ // CRLF line endings survive the rewrite
109
+ expect(readFileSync(file, "utf8").split("\n").length).toBe(6)
110
+ // a fenced look-alike is not an item
111
+ expect(() => toggleMarkdownItem(file, 4)).toThrow(/no checklist item/)
112
+ // a plain line is not an item either
113
+ expect(() => toggleMarkdownItem(file, 1)).toThrow(/no checklist item/)
114
+ })
115
+
116
+ test("renders path:line sections and open items", () => {
117
+ const path = "/proj/docs/TODO.md"
118
+ const parsed = {
119
+ path,
120
+ exists: true,
121
+ items: [
122
+ { path, line: 3, text: "do it", indent: "", state: "open" as const },
123
+ { path, line: 4, text: "done", indent: "", state: "completed" as const },
124
+ ],
125
+ }
126
+ const section = renderFileSection(parsed, "/proj")
127
+ expect(section).toContain("## docs/TODO.md (1 open of 2)")
128
+ expect(section).toContain("- [ ] do it (docs/TODO.md:3)")
129
+ expect(section).toContain("- [x] done (docs/TODO.md:4)")
130
+ expect(openMarkdownItems([parsed]).map((item) => item.text)).toEqual(["do it"])
131
+ expect(displayPath("/proj", path)).toBe("docs/TODO.md")
132
+ expect(displayPath("/proj", "/elsewhere/x.md")).toBe("/elsewhere/x.md")
133
+ expect(renderFileSection({ path: "/proj/gone.md", exists: false, items: [] }, "/proj")).toContain("missing")
134
+ })
135
+ })
@@ -0,0 +1,159 @@
1
+ // todo-supervisor — markdown checklist mode (PLAN.md §5.8, aspect 14).
2
+ //
3
+ // Sessions may subscribe to any number of markdown files whose GFM checklist
4
+ // items act as todos. This module owns: the fence-aware parser, path
5
+ // resolution/validation, the effective-subscription merge, a per-file parse
6
+ // cache and the shared renderer for `path:line` sections.
7
+ import { readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
8
+ import { isAbsolute, relative, resolve as resolvePath } from "node:path"
9
+
10
+ export type MarkdownState = "open" | "completed" | "canceled"
11
+
12
+ export type MarkdownItem = {
13
+ path: string
14
+ /** 1-based line number for `path:line` references. */
15
+ line: number
16
+ text: string
17
+ indent: string
18
+ state: MarkdownState
19
+ }
20
+
21
+ export type ParsedFile = {
22
+ path: string
23
+ exists: boolean
24
+ error?: string
25
+ items: MarkdownItem[]
26
+ }
27
+
28
+ // GFM checkbox: any bullet or ordered marker, any indentation (§5.8). The trailing
29
+ // `\r?` keeps a CRLF file whose last line has no final newline parseable.
30
+ const ITEM_RE = /^(\s*)(?:[-*+]|\d+[.)])\s+\[( |x|X|~)\]\s+(.*?)\r?$/
31
+ const FENCE_RE = /^\s*(`{3,}|~{3,})/
32
+
33
+ /** Fence-aware checklist parse; code fences are skipped (§5.8). */
34
+ export function parseChecklist(content: string, path: string): MarkdownItem[] {
35
+ const items: MarkdownItem[] = []
36
+ const lines = content.split(/\r?\n/)
37
+ let fence: string | undefined
38
+ for (let index = 0; index < lines.length; index += 1) {
39
+ const line = lines[index]!
40
+ const fenceMatch = FENCE_RE.exec(line)
41
+ if (fenceMatch) {
42
+ const marker = fenceMatch[1]!
43
+ if (!fence) fence = marker[0]
44
+ else if (marker[0] === fence) fence = undefined
45
+ continue
46
+ }
47
+ if (fence) continue
48
+ const match = ITEM_RE.exec(line)
49
+ if (!match) continue
50
+ const [, indent, marker, text] = match
51
+ const state: MarkdownState =
52
+ marker === "x" || marker === "X" ? "completed" : marker === "~" ? "canceled" : "open"
53
+ items.push({ path, line: index + 1, text: text!.trim(), indent: indent!, state })
54
+ }
55
+ return items
56
+ }
57
+
58
+ /** `.md` / `.markdown` only; relative paths resolve against the location directory (§5.8). */
59
+ export function resolveMarkdownPath(directory: string, raw: string): string {
60
+ const trimmed = raw.trim()
61
+ if (trimmed === "") throw new Error(`invalid input: "path" must be a non-empty string`)
62
+ const abs = isAbsolute(trimmed) ? resolvePath(trimmed) : resolvePath(directory, trimmed)
63
+ if (!/\.(md|markdown)$/i.test(abs)) {
64
+ throw new Error(`invalid input: "${raw}" is not a markdown file (.md or .markdown)`)
65
+ }
66
+ return abs
67
+ }
68
+
69
+ /** Effective set (§5.8): config `markdownFiles` ∪ stored `files`, minus `removed`. */
70
+ export function effectiveFiles(
71
+ options: { readonly markdownFiles: readonly string[] },
72
+ directory: string,
73
+ subs: { readonly files: readonly string[]; readonly removed: readonly string[] } | undefined,
74
+ ): string[] {
75
+ const set = new Set<string>()
76
+ for (const raw of options.markdownFiles) {
77
+ set.add(isAbsolute(raw) ? resolvePath(raw) : resolvePath(directory, raw))
78
+ }
79
+ for (const path of subs?.files ?? []) set.add(path)
80
+ for (const path of subs?.removed ?? []) set.delete(path)
81
+ return [...set].sort()
82
+ }
83
+
84
+ /** Per-file parse cache keyed by mtime + size; `parse` is synchronous and cheap. */
85
+ export class MarkdownCache {
86
+ private readonly cache = new Map<string, { mtimeMs: number; size: number; parsed: ParsedFile }>()
87
+
88
+ parse(path: string): ParsedFile {
89
+ try {
90
+ const stat = statSync(path)
91
+ const hit = this.cache.get(path)
92
+ if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.parsed
93
+ const parsed: ParsedFile = { path, exists: true, items: parseChecklist(readFileSync(path, "utf8"), path) }
94
+ this.cache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, parsed })
95
+ return parsed
96
+ } catch (err) {
97
+ const parsed: ParsedFile = { path, exists: false, error: String(err), items: [] }
98
+ this.cache.delete(path)
99
+ return parsed
100
+ }
101
+ }
102
+
103
+ invalidate(path?: string): void {
104
+ if (path === undefined) this.cache.clear()
105
+ else this.cache.delete(path)
106
+ }
107
+ }
108
+
109
+ /** Open markdown items across the given files. */
110
+ export function openMarkdownItems(files: readonly ParsedFile[]): MarkdownItem[] {
111
+ return files.flatMap((file) => file.items.filter((item) => item.state === "open"))
112
+ }
113
+
114
+ /**
115
+ * Toggle one checklist item in place (§5.8 panel write-back). Only lines the
116
+ * fence-aware parser reports as items are eligible, and only the marker is
117
+ * rewritten — bullet, indentation, trailing text and the file's line endings are
118
+ * preserved. Writes via a temp file + rename so a crash cannot truncate the file.
119
+ * Callers invalidate the parse cache. Returns the new state.
120
+ */
121
+ export function toggleMarkdownItem(path: string, line: number): MarkdownState {
122
+ const content = readFileSync(path, "utf8")
123
+ const item = parseChecklist(content, path).find((entry) => entry.line === line)
124
+ if (!item) throw new Error(`no checklist item at ${path}:${line} (fenced or missing?)`)
125
+ const next: MarkdownState = item.state === "completed" ? "open" : "completed"
126
+ const marker = next === "completed" ? "x" : next === "canceled" ? "~" : " "
127
+ const lines = content.split("\n")
128
+ const index = line - 1
129
+ const target = lines[index]
130
+ if (target === undefined) throw new Error(`no line ${line} in ${path}`)
131
+ const rewritten = target.replace(/\[( |x|X|~)\]/, `[${marker}]`)
132
+ if (rewritten === target) throw new Error(`could not rewrite the checklist marker at ${path}:${line}`)
133
+ lines[index] = rewritten
134
+ const tmp = `${path}.todo-supervisor.tmp`
135
+ writeFileSync(tmp, lines.join("\n"), "utf8")
136
+ renameSync(tmp, path)
137
+ return next
138
+ }
139
+
140
+ /** Path for display: relative to the location directory when inside it. */
141
+ export function displayPath(directory: string, path: string): string {
142
+ const rel = relative(directory, path)
143
+ return rel === "" || rel.startsWith("..") ? path : rel
144
+ }
145
+
146
+ /** Shared `path:line` section renderer (§5.3 `todo_read`, §5.4 nudges). */
147
+ export function renderFileSection(
148
+ file: ParsedFile,
149
+ directory: string,
150
+ options: { openOnly?: boolean } = {},
151
+ ): string {
152
+ const shown = displayPath(directory, file.path)
153
+ if (!file.exists) return `## ${shown} — missing (0 tasks)`
154
+ const open = file.items.filter((item) => item.state === "open").length
155
+ const marker = (state: MarkdownState) => (state === "completed" ? "[x]" : state === "canceled" ? "[~]" : "[ ]")
156
+ const shownItems = options.openOnly ? file.items.filter((item) => item.state === "open") : file.items
157
+ const rows = shownItems.map((item) => `- ${marker(item.state)} ${item.text} (${shown}:${item.line})`)
158
+ return [`## ${shown} (${open} open of ${file.items.length})`, ...rows].join("\n")
159
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { DEFAULTS, parseOptions } from "./options"
3
+
4
+ describe("parseOptions", () => {
5
+ test("uses defaults for absent input", () => {
6
+ expect(parseOptions(undefined)).toEqual(DEFAULTS)
7
+ expect(parseOptions(null)).toEqual(DEFAULTS)
8
+ expect(parseOptions({})).toEqual(DEFAULTS)
9
+ })
10
+
11
+ test("invalid values fall back to defaults", () => {
12
+ const options = parseOptions({
13
+ stallMinutes: "x",
14
+ settleMs: -5,
15
+ enabled: "yes",
16
+ scope: "nope",
17
+ markdownFiles: [1, 2],
18
+ minNudgeIntervalMs: Number.NaN,
19
+ })
20
+ expect(options).toEqual(DEFAULTS)
21
+ })
22
+
23
+ test("non-object input falls back to defaults", () => {
24
+ expect(parseOptions("nope")).toEqual(DEFAULTS)
25
+ expect(parseOptions([1, 2])).toEqual(DEFAULTS)
26
+ })
27
+
28
+ test("valid values are taken; unknown keys ignored", () => {
29
+ const options = parseOptions({
30
+ stallMinutes: 2,
31
+ scope: "all",
32
+ enabled: false,
33
+ markdownFiles: [" TODO.md ", "", "plans/x.md"],
34
+ unknown: true,
35
+ })
36
+ expect(options.stallMinutes).toBe(2)
37
+ expect(options.scope).toBe("all")
38
+ expect(options.enabled).toBe(false)
39
+ expect(options.markdownFiles).toEqual(["TODO.md", "plans/x.md"])
40
+ expect((options as Record<string, unknown>)["unknown"]).toBeUndefined()
41
+ })
42
+ })
package/src/options.ts ADDED
@@ -0,0 +1,100 @@
1
+ // todo-supervisor — options (PLAN.md §5.1, §5.7).
2
+ //
3
+ // Field-by-field type-checked parsing of `ctx.options`; invalid values fall
4
+ // back to the default with a warning so a typo never breaks loading.
5
+ import { log } from "./log"
6
+
7
+ export type Options = {
8
+ enabled: boolean
9
+ settleMs: number
10
+ minNudgeIntervalMs: number
11
+ stallMinutes: number
12
+ maxNoProgressNudges: number
13
+ injectTodoContext: boolean
14
+ markdownFiles: string[]
15
+ scope: "project" | "all"
16
+ pauseOnInterrupt: boolean
17
+ debugEvents: boolean
18
+ }
19
+
20
+ export const DEFAULTS: Options = {
21
+ enabled: true,
22
+ settleMs: 5000,
23
+ minNudgeIntervalMs: 45000,
24
+ stallMinutes: 5,
25
+ maxNoProgressNudges: 5,
26
+ injectTodoContext: true,
27
+ markdownFiles: [],
28
+ scope: "project",
29
+ pauseOnInterrupt: true,
30
+ debugEvents: false,
31
+ }
32
+
33
+ function readNumber(
34
+ input: Record<string, unknown>,
35
+ key: keyof Options,
36
+ min: number,
37
+ apply: (value: number) => void,
38
+ ): void {
39
+ const raw = input[key]
40
+ if (raw === undefined) return
41
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= min) {
42
+ apply(raw)
43
+ return
44
+ }
45
+ log(`options: invalid "${key}" (${JSON.stringify(raw)}) — using default ${String(DEFAULTS[key])}`)
46
+ }
47
+
48
+ function readBoolean(
49
+ input: Record<string, unknown>,
50
+ key: keyof Options,
51
+ apply: (value: boolean) => void,
52
+ ): void {
53
+ const raw = input[key]
54
+ if (raw === undefined) return
55
+ if (typeof raw === "boolean") {
56
+ apply(raw)
57
+ return
58
+ }
59
+ log(`options: invalid "${key}" (${JSON.stringify(raw)}) — using default ${String(DEFAULTS[key])}`)
60
+ }
61
+
62
+ export function parseOptions(raw: unknown): Options {
63
+ const options: Options = { ...DEFAULTS, markdownFiles: [] }
64
+
65
+ if (raw === undefined || raw === null) return options
66
+ if (typeof raw !== "object" || Array.isArray(raw)) {
67
+ log(`options: expected an object, got ${Array.isArray(raw) ? "array" : typeof raw} — using defaults`)
68
+ return options
69
+ }
70
+
71
+ const input = raw as Record<string, unknown>
72
+
73
+ readNumber(input, "settleMs", 0, (v) => (options.settleMs = v))
74
+ readNumber(input, "minNudgeIntervalMs", 0, (v) => (options.minNudgeIntervalMs = v))
75
+ readNumber(input, "stallMinutes", 0, (v) => (options.stallMinutes = v))
76
+ readNumber(input, "maxNoProgressNudges", 0, (v) => (options.maxNoProgressNudges = v))
77
+
78
+ readBoolean(input, "enabled", (v) => (options.enabled = v))
79
+ readBoolean(input, "injectTodoContext", (v) => (options.injectTodoContext = v))
80
+ readBoolean(input, "pauseOnInterrupt", (v) => (options.pauseOnInterrupt = v))
81
+ readBoolean(input, "debugEvents", (v) => (options.debugEvents = v))
82
+
83
+ const scope = input["scope"]
84
+ if (scope !== undefined) {
85
+ if (scope === "project" || scope === "all") options.scope = scope
86
+ else log(`options: invalid "scope" (${JSON.stringify(scope)}) — using default "project"`)
87
+ }
88
+
89
+ const files = input["markdownFiles"]
90
+ if (files !== undefined) {
91
+ if (Array.isArray(files) && files.every((entry) => typeof entry === "string")) {
92
+ options.markdownFiles = (files as string[]).map((s) => s.trim()).filter((s) => s.length > 0)
93
+ } else {
94
+ log(`options: invalid "markdownFiles" — using default []`)
95
+ }
96
+ }
97
+
98
+ // Unknown keys are intentionally ignored (§5.7).
99
+ return options
100
+ }
@@ -0,0 +1,193 @@
1
+ // todo-supervisor — pure panel helper tests (aspect 12).
2
+ import { describe, expect, test } from "bun:test"
3
+ import {
4
+ buildRows,
5
+ clampCursor,
6
+ countOpen,
7
+ dialogWidth,
8
+ footerHints,
9
+ isSelectable,
10
+ moveCursor,
11
+ popoverBodyHeight,
12
+ progressBar,
13
+ quietLabel,
14
+ statusMark,
15
+ supervisorChip,
16
+ truncate,
17
+ visibleWindow,
18
+ type PanelSnapshot,
19
+ } from "./panel"
20
+
21
+ const snapshot = (): PanelSnapshot => ({
22
+ items: [
23
+ { content: "first task", status: "pending", priority: "high" },
24
+ { content: "second task", status: "in_progress" },
25
+ { content: "third task", status: "completed" },
26
+ ],
27
+ files: [
28
+ {
29
+ path: "/proj/docs/TODO.md",
30
+ exists: true,
31
+ items: [
32
+ { content: "file item one", status: "pending", line: 3 },
33
+ { content: "file item two", status: "completed", line: 4 },
34
+ ],
35
+ },
36
+ { path: "/proj/GONE.md", exists: false, items: [] },
37
+ ],
38
+ supervisor: { registered: true, status: "idle", quietMs: 12_000, terminal: true, nudges: 2 },
39
+ })
40
+
41
+ describe("panel formatting", () => {
42
+ test("status marks and open counts match todo_read", () => {
43
+ expect([statusMark("completed"), statusMark("in_progress"), statusMark("canceled"), statusMark("pending")]).toEqual([
44
+ "[x]",
45
+ "[~]",
46
+ "[~]",
47
+ "[ ]",
48
+ ])
49
+ expect(countOpen([{ status: "completed" }, { status: "pending" }, { status: "in_progress" }])).toBe(2)
50
+ })
51
+
52
+ test("progress bar fills by ratio and degrades safely", () => {
53
+ expect(progressBar(0, 8, 8)).toBe("░░░░░░░░")
54
+ expect(progressBar(4, 8, 8)).toBe("████░░░░")
55
+ expect(progressBar(8, 8, 8)).toBe("████████")
56
+ expect(progressBar(1, 3, 0)).toBe("")
57
+ expect(progressBar(0, 0, 4)).toBe("░░░░")
58
+ })
59
+
60
+ test("truncate collapses whitespace and ellipsises", () => {
61
+ expect(truncate(" a b ", 10)).toBe("a b")
62
+ expect(truncate("exact", 5)).toBe("exact")
63
+ expect(truncate("too long here", 6)).toBe("too l…")
64
+ expect(truncate("abc", 1)).toBe("…")
65
+ expect(truncate("abc", 0)).toBe("")
66
+ })
67
+
68
+ test("quiet labels and the supervisor chip describe state", () => {
69
+ expect(quietLabel(3_400)).toBe("3s")
70
+ expect(quietLabel(80_000)).toBe("1m20s")
71
+ expect(quietLabel(undefined)).toBe("—")
72
+
73
+ expect(supervisorChip(undefined)).toBe("not supervised")
74
+ expect(supervisorChip({ registered: false })).toBe("not supervised")
75
+ expect(supervisorChip({ registered: true, paused: true, pauseReason: "no-progress", nudges: 3 })).toBe(
76
+ "paused (no-progress) · nudges 3",
77
+ )
78
+ expect(
79
+ supervisorChip({ registered: true, runningTools: 1, runningShells: 2, quietMs: 4_000 }),
80
+ ).toBe("working: 1 tool · 2 shells · quiet 4s")
81
+ expect(supervisorChip({ registered: true, terminal: false, quietMs: 90_000 })).toBe(
82
+ "waiting for turn end · quiet 1m30s",
83
+ )
84
+ expect(supervisorChip({ registered: true, status: "idle", terminal: true, quietMs: 2_000, nudges: 1 })).toBe(
85
+ "idle · quiet 2s · nudges 1",
86
+ )
87
+ })
88
+ })
89
+
90
+ describe("panel rows", () => {
91
+ test("builds the session section, file sections and items", () => {
92
+ const rows = buildRows(snapshot())
93
+ expect(rows.map((row) => row.kind)).toEqual([
94
+ "section",
95
+ "item",
96
+ "item",
97
+ "item",
98
+ "file",
99
+ "item",
100
+ "item",
101
+ "file",
102
+ ])
103
+ const sessionHeader = rows[0]!
104
+ expect(sessionHeader.kind === "section" && sessionHeader.detail).toBe("2 open of 3")
105
+ const firstItem = rows[1]!
106
+ expect(firstItem.kind === "item" && firstItem.key).toBe("session:0")
107
+ expect(firstItem.kind === "item" && firstItem.priority).toBe("high")
108
+ const fileRow = rows[4]!
109
+ expect(fileRow.kind === "file" && fileRow.open).toBe(1)
110
+ expect(fileRow.kind === "file" && fileRow.exists).toBe(true)
111
+ const missing = rows[7]!
112
+ expect(missing.kind === "file" && missing.exists).toBe(false)
113
+ })
114
+
115
+ test("collapsed files hide their items; formatPath is applied", () => {
116
+ const rows = buildRows(snapshot(), { collapsed: new Set(["/proj/docs/TODO.md"]), formatPath: () => "docs/TODO.md" })
117
+ expect(rows.filter((row) => row.kind === "item" && row.source === "file").length).toBe(0)
118
+ const fileRow = rows.find((row) => row.kind === "file")!
119
+ expect(fileRow.kind === "file" && fileRow.label).toBe("docs/TODO.md")
120
+ expect(fileRow.kind === "file" && fileRow.collapsed).toBe(true)
121
+ })
122
+
123
+ test("filter hides non-matching items and whole sections", () => {
124
+ const rows = buildRows(snapshot(), { filter: "FILE ITEM ONE" })
125
+ expect(rows.filter((row) => row.kind === "item").map((row) => row.kind === "item" && row.content)).toEqual([
126
+ "file item one",
127
+ ])
128
+ expect(rows.some((row) => row.kind === "section")).toBe(false)
129
+ const none = buildRows(snapshot(), { filter: "zzz" })
130
+ expect(none.length).toBe(1)
131
+ expect(none[0]!.kind === "section" && none[0]!.label).toBe("no matches")
132
+ })
133
+
134
+ test("empty and all-done states", () => {
135
+ const empty = buildRows({ items: [], files: [] })
136
+ expect(empty[0]!.kind === "section" && empty[0]!.label).toBe("no todos")
137
+ const done = buildRows({ items: [{ content: "done", status: "completed" }], files: [] })
138
+ expect(done.some((row) => row.kind === "section" && row.key === "section:done")).toBe(true)
139
+ expect(done.some((row) => row.kind === "item")).toBe(true)
140
+ })
141
+ })
142
+
143
+ describe("panel cursor", () => {
144
+ test("clamp keeps a live key and falls back to the first selectable row", () => {
145
+ const rows = buildRows(snapshot())
146
+ expect(clampCursor(rows, "session:1")).toBe("session:1")
147
+ expect(clampCursor(rows, "gone")).toBe("session:0")
148
+ expect(clampCursor(rows, undefined)).toBe("session:0")
149
+ expect(clampCursor([], "x")).toBe(undefined)
150
+ })
151
+
152
+ test("movement skips section headers and clamps at both ends", () => {
153
+ const rows = buildRows(snapshot())
154
+ expect(isSelectable(rows[0]!)).toBe(false)
155
+ expect(moveCursor(rows, "session:0", 1)).toBe("session:1")
156
+ expect(moveCursor(rows, "session:2", 1)).toBe("file:/proj/docs/TODO.md")
157
+ expect(moveCursor(rows, "session:0", -1)).toBe("session:0")
158
+ expect(moveCursor(rows, "file:/proj/GONE.md", 1)).toBe("file:/proj/GONE.md")
159
+ expect(moveCursor([], "x", 1)).toBe(undefined)
160
+ })
161
+
162
+ test("the scroll window keeps the selected row visible", () => {
163
+ const rows = buildRows(snapshot())
164
+ expect(visibleWindow(rows, "session:0", 20)).toEqual({ start: 0, end: rows.length })
165
+ const window = visibleWindow(rows, "file:/proj/GONE.md", 3)
166
+ expect(window.end - window.start).toBe(3)
167
+ expect(window.start).toBe(rows.length - 3)
168
+ })
169
+
170
+ test("footer hints shrink with the terminal width", () => {
171
+ expect(footerHints(140).length).toBe(13)
172
+ expect(footerHints(90).length).toBe(8)
173
+ expect(footerHints(40)).toEqual(["↑↓", "space toggle", "n nudge", "/ filter", "? help", "esc"])
174
+ })
175
+
176
+ test("popover body height stays a popover on any terminal", () => {
177
+ expect(popoverBodyHeight(40)).toBe(16)
178
+ expect(popoverBodyHeight(24)).toBe(14)
179
+ expect(popoverBodyHeight(24, 1)).toBe(13)
180
+ expect(popoverBodyHeight(14)).toBe(4)
181
+ expect(popoverBodyHeight(50, 2)).toBe(16)
182
+ })
183
+
184
+ test("popover width follows the dialog size and clamps", () => {
185
+ expect(dialogWidth("medium", 200)).toBe(72)
186
+ expect(dialogWidth("large", 200)).toBe(140)
187
+ expect(dialogWidth("xlarge", 200)).toBe(140)
188
+ expect(dialogWidth("medium", 80)).toBe(56)
189
+ expect(dialogWidth("large", 80)).toBe(64)
190
+ expect(dialogWidth("xlarge", 80)).toBe(72)
191
+ expect(dialogWidth("xlarge", 20)).toBe(36)
192
+ })
193
+ })