@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.
- package/LICENSE +21 -0
- package/README.md +366 -2
- package/index.ts +3 -0
- package/package.json +61 -4
- package/src/index.ts +186 -0
- package/src/log.ts +77 -0
- package/src/machine.test.ts +779 -0
- package/src/markdown.test.ts +135 -0
- package/src/markdown.ts +159 -0
- package/src/options.test.ts +42 -0
- package/src/options.ts +100 -0
- package/src/panel.test.ts +193 -0
- package/src/panel.ts +289 -0
- package/src/rpc.test.ts +226 -0
- package/src/rpc.ts +290 -0
- package/src/storage.test.ts +101 -0
- package/src/storage.ts +186 -0
- package/src/supervisor.test.ts +37 -0
- package/src/supervisor.ts +1269 -0
- package/src/tools.test.ts +260 -0
- package/src/tools.ts +405 -0
- package/src/tui.tsx +658 -0
- package/tui.ts +2 -0
|
@@ -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
|
+
})
|
package/src/markdown.ts
ADDED
|
@@ -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
|
+
})
|