@hacky.software/opencode-todo-supervisor 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/src/panel.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// todo-supervisor — pure presentation helpers for the TUI todo panel (§6, aspect 12).
|
|
2
|
+
//
|
|
3
|
+
// Everything here is framework-free: layout math, row building, cursor movement
|
|
4
|
+
// and truncation are unit-tested without an OpenTUI renderer. src/tui.tsx only
|
|
5
|
+
// maps these rows to <text>/<box> elements.
|
|
6
|
+
|
|
7
|
+
export type PanelItem = { content: string; status: string; priority?: string }
|
|
8
|
+
export type PanelFileItem = { content: string; status: string; line?: number }
|
|
9
|
+
export type PanelFile = { path: string; exists: boolean; items: PanelFileItem[] }
|
|
10
|
+
|
|
11
|
+
/** RPC `list` → `supervisor` payload (see Supervisor.snapshot). */
|
|
12
|
+
export type PanelSupervisor = {
|
|
13
|
+
registered?: boolean
|
|
14
|
+
status?: string
|
|
15
|
+
paused?: boolean
|
|
16
|
+
pauseReason?: string | null
|
|
17
|
+
blocked?: number
|
|
18
|
+
runningChildren?: number
|
|
19
|
+
runningShells?: number
|
|
20
|
+
runningTools?: number
|
|
21
|
+
spans?: string[]
|
|
22
|
+
quietMs?: number
|
|
23
|
+
terminal?: boolean
|
|
24
|
+
nudges?: number
|
|
25
|
+
noProgress?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PanelSnapshot = {
|
|
29
|
+
items: PanelItem[]
|
|
30
|
+
files: PanelFile[]
|
|
31
|
+
supervisor?: PanelSupervisor
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Row =
|
|
35
|
+
| { kind: "section"; key: string; label: string; detail?: string; muted?: boolean }
|
|
36
|
+
| {
|
|
37
|
+
kind: "file"
|
|
38
|
+
key: string
|
|
39
|
+
path: string
|
|
40
|
+
label: string
|
|
41
|
+
open: number
|
|
42
|
+
total: number
|
|
43
|
+
exists: boolean
|
|
44
|
+
collapsed: boolean
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
kind: "item"
|
|
48
|
+
key: string
|
|
49
|
+
source: "session" | "file"
|
|
50
|
+
content: string
|
|
51
|
+
status: string
|
|
52
|
+
priority?: string
|
|
53
|
+
path?: string
|
|
54
|
+
line?: number
|
|
55
|
+
index?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ItemRow = Extract<Row, { kind: "item" }>
|
|
59
|
+
export type FileRow = Extract<Row, { kind: "file" }>
|
|
60
|
+
|
|
61
|
+
export function isSelectable(row: Row): row is ItemRow | FileRow {
|
|
62
|
+
return row.kind === "item" || row.kind === "file"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `[ ]` / `[~]` (in progress or canceled) / `[x]` — matches todo_read. */
|
|
66
|
+
export function statusMark(status: string): string {
|
|
67
|
+
if (status === "completed") return "[x]"
|
|
68
|
+
if (status === "in_progress" || status === "canceled") return "[~]"
|
|
69
|
+
return "[ ]"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function countOpen(items: readonly { status: string }[]): number {
|
|
73
|
+
return items.filter((item) => item.status !== "completed").length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `████░░░░` filled by completion ratio; `░` when there is nothing to do. */
|
|
77
|
+
export function progressBar(done: number, total: number, width: number): string {
|
|
78
|
+
const cells = Math.max(0, Math.floor(width))
|
|
79
|
+
if (cells === 0) return ""
|
|
80
|
+
if (total <= 0) return "░".repeat(cells)
|
|
81
|
+
const filled = Math.max(0, Math.min(cells, Math.round((done / total) * cells)))
|
|
82
|
+
return "█".repeat(filled) + "░".repeat(cells - filled)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Single-line, whitespace-collapsed, ellipsised to `width` cells. */
|
|
86
|
+
export function truncate(text: string, width: number): string {
|
|
87
|
+
const clean = text.replace(/\s+/g, " ").trim()
|
|
88
|
+
if (width <= 0) return ""
|
|
89
|
+
if (clean.length <= width) return clean
|
|
90
|
+
if (width === 1) return "…"
|
|
91
|
+
return `${clean.slice(0, width - 1)}…`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `3s` / `1m20s` — compact quiet-time label. */
|
|
95
|
+
export function quietLabel(ms: number | undefined): string {
|
|
96
|
+
if (ms === undefined) return "—"
|
|
97
|
+
const seconds = Math.max(0, Math.round(ms / 1000))
|
|
98
|
+
if (seconds < 60) return `${seconds}s`
|
|
99
|
+
return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One-line supervision summary for the panel header. */
|
|
103
|
+
export function supervisorChip(supervisor: PanelSupervisor | undefined): string {
|
|
104
|
+
if (!supervisor || supervisor.registered === false) return "not supervised"
|
|
105
|
+
const nudges = supervisor.nudges ?? 0
|
|
106
|
+
if (supervisor.paused) return `paused (${supervisor.pauseReason ?? "paused"}) · nudges ${nudges}`
|
|
107
|
+
const work: string[] = []
|
|
108
|
+
const plural = (count: number, one: string) => `${count} ${one}${count === 1 ? "" : "s"}`
|
|
109
|
+
if (supervisor.runningTools) work.push(plural(supervisor.runningTools, "tool"))
|
|
110
|
+
if (supervisor.runningChildren) work.push(plural(supervisor.runningChildren, "subagent"))
|
|
111
|
+
if (supervisor.runningShells) work.push(plural(supervisor.runningShells, "shell"))
|
|
112
|
+
if (work.length > 0) return `working: ${work.join(" · ")} · quiet ${quietLabel(supervisor.quietMs)}`
|
|
113
|
+
if (supervisor.terminal === false) return `waiting for turn end · quiet ${quietLabel(supervisor.quietMs)}`
|
|
114
|
+
return `idle · quiet ${quietLabel(supervisor.quietMs)} · nudges ${nudges}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type BuildOptions = {
|
|
118
|
+
/** Case-insensitive substring filter over item content. */
|
|
119
|
+
filter?: string
|
|
120
|
+
/** Collapsed markdown file paths (their items are hidden). */
|
|
121
|
+
collapsed?: ReadonlySet<string>
|
|
122
|
+
/** Path display, e.g. `context.ui.format.path` (defaults to identity). */
|
|
123
|
+
formatPath?: (path: string) => string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function matches(text: string, filter: string): boolean {
|
|
127
|
+
return filter === "" || text.toLowerCase().includes(filter)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Flatten a snapshot into panel rows: `SESSION` section, then one section per
|
|
132
|
+
* subscribed file (collapsible), each item selectable. Filters hide items and
|
|
133
|
+
* whole sections that no longer match; the cursor is kept by row key elsewhere.
|
|
134
|
+
*/
|
|
135
|
+
export function buildRows(snapshot: PanelSnapshot, options: BuildOptions = {}): Row[] {
|
|
136
|
+
const filter = (options.filter ?? "").trim().toLowerCase()
|
|
137
|
+
const collapsed = options.collapsed ?? new Set<string>()
|
|
138
|
+
const formatPath = options.formatPath ?? ((path: string) => path)
|
|
139
|
+
const rows: Row[] = []
|
|
140
|
+
|
|
141
|
+
const sessionVisible = snapshot.items.filter((item) => matches(item.content, filter))
|
|
142
|
+
if (snapshot.items.length > 0 && sessionVisible.length > 0) {
|
|
143
|
+
rows.push({
|
|
144
|
+
kind: "section",
|
|
145
|
+
key: "section:session",
|
|
146
|
+
label: "SESSION",
|
|
147
|
+
detail: `${countOpen(snapshot.items)} open of ${snapshot.items.length}`,
|
|
148
|
+
})
|
|
149
|
+
for (const item of sessionVisible) {
|
|
150
|
+
const index = snapshot.items.indexOf(item)
|
|
151
|
+
rows.push({
|
|
152
|
+
kind: "item",
|
|
153
|
+
key: `session:${index}`,
|
|
154
|
+
source: "session",
|
|
155
|
+
index,
|
|
156
|
+
content: item.content,
|
|
157
|
+
status: item.status,
|
|
158
|
+
priority: item.priority,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const file of snapshot.files) {
|
|
164
|
+
const visible = file.items.filter((item) => matches(item.content, filter))
|
|
165
|
+
if (filter !== "" && visible.length === 0) continue
|
|
166
|
+
const isCollapsed = collapsed.has(file.path)
|
|
167
|
+
rows.push({
|
|
168
|
+
kind: "file",
|
|
169
|
+
key: `file:${file.path}`,
|
|
170
|
+
path: file.path,
|
|
171
|
+
label: formatPath(file.path),
|
|
172
|
+
open: countOpen(file.items),
|
|
173
|
+
total: file.items.length,
|
|
174
|
+
exists: file.exists,
|
|
175
|
+
collapsed: isCollapsed,
|
|
176
|
+
})
|
|
177
|
+
if (isCollapsed) continue
|
|
178
|
+
for (const item of visible) {
|
|
179
|
+
rows.push({
|
|
180
|
+
kind: "item",
|
|
181
|
+
key: `file:${file.path}:${item.line ?? item.content}`,
|
|
182
|
+
source: "file",
|
|
183
|
+
path: file.path,
|
|
184
|
+
line: item.line,
|
|
185
|
+
content: item.content,
|
|
186
|
+
status: item.status,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (rows.length === 0) {
|
|
192
|
+
rows.push({
|
|
193
|
+
kind: "section",
|
|
194
|
+
key: "section:empty",
|
|
195
|
+
label: snapshot.items.length > 0 || snapshot.files.length > 0 ? "no matches" : "no todos",
|
|
196
|
+
detail:
|
|
197
|
+
snapshot.items.length > 0 || snapshot.files.length > 0
|
|
198
|
+
? "clear the filter to see everything"
|
|
199
|
+
: "the agent records todos with todo_write; /todos add <path> subscribes a file",
|
|
200
|
+
muted: true,
|
|
201
|
+
})
|
|
202
|
+
} else if (countOpen(snapshot.items) + snapshot.files.reduce((sum, file) => sum + countOpen(file.items), 0) === 0) {
|
|
203
|
+
rows.push({ kind: "section", key: "section:done", label: "all todos done 🎉", muted: true })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return rows
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Keeps a row key valid after a refresh; falls back to the first selectable row. */
|
|
210
|
+
export function clampCursor(rows: readonly Row[], key: string | undefined): string | undefined {
|
|
211
|
+
const selectable = rows.filter(isSelectable)
|
|
212
|
+
if (selectable.length === 0) return undefined
|
|
213
|
+
if (key && selectable.some((row) => row.key === key)) return key
|
|
214
|
+
return selectable[0]!.key
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Moves by `delta` over selectable rows only, clamped to the ends. */
|
|
218
|
+
export function moveCursor(rows: readonly Row[], key: string | undefined, delta: number): string | undefined {
|
|
219
|
+
const selectable = rows.filter(isSelectable)
|
|
220
|
+
if (selectable.length === 0) return undefined
|
|
221
|
+
const current = selectable.findIndex((row) => row.key === key)
|
|
222
|
+
const index = current === -1 ? 0 : current
|
|
223
|
+
const next = Math.max(0, Math.min(selectable.length - 1, index + delta))
|
|
224
|
+
return selectable[next]!.key
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function rowIndex(rows: readonly Row[], key: string | undefined): number {
|
|
228
|
+
return key === undefined ? -1 : rows.findIndex((row) => row.key === key)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Scroll window that keeps the selected row visible within `height` rows. */
|
|
232
|
+
export function visibleWindow(
|
|
233
|
+
rows: readonly Row[],
|
|
234
|
+
key: string | undefined,
|
|
235
|
+
height: number,
|
|
236
|
+
): { start: number; end: number } {
|
|
237
|
+
const size = Math.max(1, Math.floor(height))
|
|
238
|
+
if (rows.length <= size) return { start: 0, end: rows.length }
|
|
239
|
+
const index = Math.max(0, rowIndex(rows, key))
|
|
240
|
+
const start = Math.max(0, Math.min(index - Math.floor(size / 2), rows.length - size))
|
|
241
|
+
return { start, end: start + size }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Width-aware key hints for the footer (wide terminals get the long form). */
|
|
245
|
+
export function footerHints(width: number): string[] {
|
|
246
|
+
const wide = [
|
|
247
|
+
"↑↓ move",
|
|
248
|
+
"space toggle",
|
|
249
|
+
"enter collapse",
|
|
250
|
+
"n nudge",
|
|
251
|
+
"s pause",
|
|
252
|
+
"a add",
|
|
253
|
+
"d remove",
|
|
254
|
+
"r refresh",
|
|
255
|
+
"/ filter",
|
|
256
|
+
"f size",
|
|
257
|
+
"c clear",
|
|
258
|
+
"? help",
|
|
259
|
+
"esc close",
|
|
260
|
+
]
|
|
261
|
+
if (width >= 120) return wide
|
|
262
|
+
if (width >= 80) return wide.slice(0, 8)
|
|
263
|
+
return ["↑↓", "space toggle", "n nudge", "/ filter", "? help", "esc"]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Rows the popover list body may occupy. The dialog hugs its content, so the
|
|
268
|
+
* body must be bounded or the overlay grows to the whole screen; the cap keeps
|
|
269
|
+
* it a popover and leaves room for the header and the key legend. `extraRows`
|
|
270
|
+
* accounts for conditional chrome (e.g. the filter line).
|
|
271
|
+
*/
|
|
272
|
+
export function popoverBodyHeight(viewportHeight: number, extraRows = 0): number {
|
|
273
|
+
const total = Math.max(10, viewportHeight)
|
|
274
|
+
return Math.max(4, Math.min(total - 10 - Math.max(0, extraRows), 16))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type DialogSizeName = "medium" | "large" | "xlarge"
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Content width for the popover dialog. The host owns the dialog box, so the
|
|
281
|
+
* list sizes its rows from the render region instead and clamps for small
|
|
282
|
+
* terminals (a wider dialog just shows more of each row).
|
|
283
|
+
*/
|
|
284
|
+
export function dialogWidth(size: DialogSizeName, viewportWidth: number): number {
|
|
285
|
+
const total = Math.max(40, viewportWidth)
|
|
286
|
+
const bySize =
|
|
287
|
+
size === "xlarge" ? total - 8 : size === "large" ? total - 16 : Math.min(72, Math.max(48, total - 24))
|
|
288
|
+
return Math.max(36, Math.min(bySize, 140))
|
|
289
|
+
}
|
package/src/rpc.test.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// todo-supervisor — RPC contract + handler unit tests (aspect 11).
|
|
2
|
+
import { describe, expect, test } from "bun:test"
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
4
|
+
import type { Plugin } from "@opencode/plugin"
|
|
5
|
+
import { MarkdownCache } from "./markdown"
|
|
6
|
+
import { DEFAULTS, type Options } from "./options"
|
|
7
|
+
import { registerTodoRpc, resolveMarkdownPath, TodoRpc } from "./rpc"
|
|
8
|
+
import type { TodoItem, TodoStore } from "./storage"
|
|
9
|
+
import type { Supervisor } from "./supervisor"
|
|
10
|
+
|
|
11
|
+
type SubRecord = { version: 1; sessionID: string; files: string[]; removed: string[]; updatedAt: number }
|
|
12
|
+
|
|
13
|
+
function makeWorld(seedItems: TodoItem[] = []) {
|
|
14
|
+
const todos = new Map<string, TodoItem[]>([["s1", seedItems]])
|
|
15
|
+
const subs = new Map<string, SubRecord>()
|
|
16
|
+
const calls: string[] = []
|
|
17
|
+
const emitted: { name: string; payload: Record<string, unknown> }[] = []
|
|
18
|
+
|
|
19
|
+
const store = {
|
|
20
|
+
getTodos: (sessionID: string) =>
|
|
21
|
+
todos.has(sessionID)
|
|
22
|
+
? { version: 1 as const, sessionID, items: todos.get(sessionID)!, updatedAt: 0 }
|
|
23
|
+
: undefined,
|
|
24
|
+
refreshTodos: async (sessionID: string) =>
|
|
25
|
+
todos.has(sessionID)
|
|
26
|
+
? { version: 1 as const, sessionID, items: todos.get(sessionID)!, updatedAt: 0 }
|
|
27
|
+
: undefined,
|
|
28
|
+
setTodos: async (sessionID: string, items: TodoItem[]) => {
|
|
29
|
+
todos.set(sessionID, items)
|
|
30
|
+
return { version: 1 as const, sessionID, items, updatedAt: 0 }
|
|
31
|
+
},
|
|
32
|
+
removeTodos: async (sessionID: string) => void todos.delete(sessionID),
|
|
33
|
+
loadSubs: async (sessionID: string) => subs.get(sessionID),
|
|
34
|
+
setSubs: async (sessionID: string, files: string[], removed: string[]) => {
|
|
35
|
+
const state: SubRecord = { version: 1, sessionID, files, removed, updatedAt: 0 }
|
|
36
|
+
subs.set(sessionID, state)
|
|
37
|
+
return state
|
|
38
|
+
},
|
|
39
|
+
} as unknown as TodoStore
|
|
40
|
+
|
|
41
|
+
const supervisor = {
|
|
42
|
+
snapshot: () => ({ registered: true, status: "idle" }),
|
|
43
|
+
notifyTodosChanged: (sessionID: string) => void calls.push(`changed ${sessionID}`),
|
|
44
|
+
forceNudge: (sessionID: string) => void calls.push(`nudge ${sessionID}`),
|
|
45
|
+
setEnabled: (sessionID: string, enabled: boolean) => void calls.push(`enabled ${sessionID} ${enabled}`),
|
|
46
|
+
} as unknown as Supervisor
|
|
47
|
+
|
|
48
|
+
const handlers: Record<string, (input: unknown) => Promise<unknown>> = {}
|
|
49
|
+
const ctx = {
|
|
50
|
+
rpc: {
|
|
51
|
+
register: async (_definition: unknown, registered: typeof handlers) => {
|
|
52
|
+
Object.assign(handlers, registered)
|
|
53
|
+
return {
|
|
54
|
+
events: {
|
|
55
|
+
emit: async (name: string, payload: Record<string, unknown>) => void emitted.push({ name, payload }),
|
|
56
|
+
},
|
|
57
|
+
dispose: async () => {},
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
} as unknown as Plugin.Context
|
|
62
|
+
|
|
63
|
+
return { store, supervisor, calls, emitted, handlers, ctx, todos, subs }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function setup(seedItems: TodoItem[] = [], directory = "/proj", options: Options = DEFAULTS) {
|
|
67
|
+
const world = makeWorld(seedItems)
|
|
68
|
+
const logs: string[] = []
|
|
69
|
+
const reg = await registerTodoRpc(world.ctx, {
|
|
70
|
+
store: world.store,
|
|
71
|
+
supervisor: world.supervisor,
|
|
72
|
+
options,
|
|
73
|
+
markdown: new MarkdownCache(),
|
|
74
|
+
directory,
|
|
75
|
+
log: (...args: unknown[]) => void logs.push(args.map(String).join(" ")),
|
|
76
|
+
})
|
|
77
|
+
return { ...world, reg, logs }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("RPC contract (§5.6)", () => {
|
|
81
|
+
test("contract exposes all methods and events", () => {
|
|
82
|
+
expect(TodoRpc.id).toBe("todo")
|
|
83
|
+
expect(Object.keys(TodoRpc.methods).sort()).toEqual([
|
|
84
|
+
"clear",
|
|
85
|
+
"list",
|
|
86
|
+
"nudge",
|
|
87
|
+
"setEnabled",
|
|
88
|
+
"subscribe",
|
|
89
|
+
"toggle",
|
|
90
|
+
"toggleFile",
|
|
91
|
+
"unsubscribe",
|
|
92
|
+
])
|
|
93
|
+
expect(Object.keys(TodoRpc.events).sort()).toEqual(["nudged", "updated"])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("list returns items, subscriptions placeholder and the supervisor snapshot", async () => {
|
|
97
|
+
const w = await setup([{ content: "one", status: "pending" }])
|
|
98
|
+
const result = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
|
|
99
|
+
expect((result.items as TodoItem[]).length).toBe(1)
|
|
100
|
+
expect(result.files).toEqual([])
|
|
101
|
+
expect((result.supervisor as Record<string, unknown>).registered).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("clear drops the list, notifies the supervisor and emits updated once", async () => {
|
|
105
|
+
const w = await setup([{ content: "one", status: "pending" }])
|
|
106
|
+
await w.handlers.clear!({ sessionID: "s1" })
|
|
107
|
+
expect(w.todos.has("s1")).toBe(false)
|
|
108
|
+
expect(w.calls).toContain("changed s1")
|
|
109
|
+
expect(w.emitted).toEqual([{ name: "updated", payload: { sessionID: "s1" } }])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("nudge and setEnabled delegate to the supervisor", async () => {
|
|
113
|
+
const w = await setup()
|
|
114
|
+
await w.handlers.nudge!({ sessionID: "s1" })
|
|
115
|
+
await w.handlers.setEnabled!({ sessionID: "s1", enabled: false })
|
|
116
|
+
expect(w.calls).toContain("nudge s1")
|
|
117
|
+
expect(w.calls).toContain("enabled s1 false")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("subscribe resolves, dedupes and un-shadows; unsubscribe shadows", async () => {
|
|
121
|
+
const w = await setup()
|
|
122
|
+
await w.handlers.subscribe!({ sessionID: "s1", path: "docs/TASKS.md" })
|
|
123
|
+
expect(w.subs.get("s1")!.files).toEqual(["/proj/docs/TASKS.md"])
|
|
124
|
+
await w.handlers.subscribe!({ sessionID: "s1", path: "docs/TASKS.md" })
|
|
125
|
+
expect(w.subs.get("s1")!.files).toEqual(["/proj/docs/TASKS.md"])
|
|
126
|
+
// a config shadow (removed) is lifted by subscribing again
|
|
127
|
+
w.subs.set("s1", { version: 1, sessionID: "s1", files: [], removed: ["/proj/docs/TASKS.md"], updatedAt: 0 })
|
|
128
|
+
await w.handlers.subscribe!({ sessionID: "s1", path: "/proj/docs/TASKS.md" })
|
|
129
|
+
expect(w.subs.get("s1")!.files).toEqual(["/proj/docs/TASKS.md"])
|
|
130
|
+
expect(w.subs.get("s1")!.removed).toEqual([])
|
|
131
|
+
await w.handlers.unsubscribe!({ sessionID: "s1", path: "docs/TASKS.md" })
|
|
132
|
+
expect(w.subs.get("s1")!.files).toEqual([])
|
|
133
|
+
expect(w.subs.get("s1")!.removed).toEqual(["/proj/docs/TASKS.md"])
|
|
134
|
+
expect(w.emitted.filter((e) => e.name === "updated").length).toBe(4)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("toggle flips one item, notifies and emits updated", async () => {
|
|
138
|
+
const w = await setup([
|
|
139
|
+
{ content: "one", status: "pending" },
|
|
140
|
+
{ content: "two", status: "completed" },
|
|
141
|
+
])
|
|
142
|
+
const first = (await w.handlers.toggle!({ sessionID: "s1", index: 0 })) as Record<string, unknown>
|
|
143
|
+
expect(first.status).toBe("completed")
|
|
144
|
+
expect(w.todos.get("s1")![0]!.status).toBe("completed")
|
|
145
|
+
const second = (await w.handlers.toggle!({ sessionID: "s1", index: 1 })) as Record<string, unknown>
|
|
146
|
+
expect(second.status).toBe("pending")
|
|
147
|
+
expect(w.calls.filter((call) => call === "changed s1").length).toBe(2)
|
|
148
|
+
expect(w.emitted.filter((event) => event.name === "updated").length).toBe(2)
|
|
149
|
+
await expect(w.handlers.toggle!({ sessionID: "s1", index: 9 })).rejects.toThrow(/range/)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("toggleFile rewrites a subscribed file, refuses other paths and notifies", async () => {
|
|
153
|
+
const dir = "/tmp/opencode"
|
|
154
|
+
mkdirSync(dir, { recursive: true })
|
|
155
|
+
const file = `${dir}/rpc-toggle-${process.pid}.md`
|
|
156
|
+
const other = `${dir}/rpc-toggle-other-${process.pid}.md`
|
|
157
|
+
writeFileSync(file, "- [ ] one\n- [x] two\n")
|
|
158
|
+
writeFileSync(other, "- [ ] not subscribed\n")
|
|
159
|
+
const w = await setup([], dir)
|
|
160
|
+
await w.handlers.subscribe!({ sessionID: "s1", path: file })
|
|
161
|
+
|
|
162
|
+
const toggled = (await w.handlers.toggleFile!({ sessionID: "s1", path: file, line: 1 })) as Record<string, unknown>
|
|
163
|
+
expect(toggled.state).toBe("completed")
|
|
164
|
+
expect(readFileSync(file, "utf8")).toContain("- [x] one")
|
|
165
|
+
await w.handlers.toggleFile!({ sessionID: "s1", path: file, line: 2 })
|
|
166
|
+
expect(readFileSync(file, "utf8")).toContain("- [ ] two")
|
|
167
|
+
expect(w.calls.filter((call) => call === "changed s1").length).toBe(2)
|
|
168
|
+
expect(w.emitted.filter((event) => event.name === "updated").length).toBe(3) // subscribe + 2 toggles
|
|
169
|
+
await expect(w.handlers.toggleFile!({ sessionID: "s1", path: other, line: 1 })).rejects.toThrow(/not a subscribed/)
|
|
170
|
+
await expect(w.handlers.toggleFile!({ sessionID: "s1", path: file, line: 9 })).rejects.toThrow(/no checklist item/)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("list reports subscribed files with their existence", async () => {
|
|
174
|
+
const dir = "/tmp/opencode"
|
|
175
|
+
mkdirSync(dir, { recursive: true })
|
|
176
|
+
const file = `${dir}/rpc-todos-${process.pid}.md`
|
|
177
|
+
writeFileSync(file, "- [ ] one\n- [x] two\n")
|
|
178
|
+
const w = await setup([], dir)
|
|
179
|
+
await w.handlers.subscribe!({ sessionID: "s1", path: file })
|
|
180
|
+
const result = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
|
|
181
|
+
expect(result.files).toEqual([
|
|
182
|
+
{
|
|
183
|
+
path: file,
|
|
184
|
+
exists: true,
|
|
185
|
+
items: [
|
|
186
|
+
{ content: "one", status: "pending", line: 1 },
|
|
187
|
+
{ content: "two", status: "completed", line: 2 },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("config markdownFiles merge into list.files and unsubscribe shadows them", async () => {
|
|
194
|
+
const options: Options = { ...DEFAULTS, markdownFiles: ["PLAN.md"] }
|
|
195
|
+
const w = await setup([], "/proj", options)
|
|
196
|
+
const before = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
|
|
197
|
+
expect(before.files).toEqual([{ path: "/proj/PLAN.md", exists: false, items: [] }])
|
|
198
|
+
await w.handlers.unsubscribe!({ sessionID: "s1", path: "PLAN.md" })
|
|
199
|
+
const after = (await w.handlers.list!({ sessionID: "s1" })) as Record<string, unknown>
|
|
200
|
+
expect(after.files).toEqual([])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("rejects non-markdown paths and invalid input", async () => {
|
|
204
|
+
const w = await setup()
|
|
205
|
+
await expect(w.handlers.subscribe!({ sessionID: "s1", path: "notes.txt" })).rejects.toThrow(/markdown/)
|
|
206
|
+
await expect(w.handlers.list!({})).rejects.toThrow(/sessionID/)
|
|
207
|
+
await expect(w.handlers.setEnabled!({ sessionID: "s1", enabled: "yes" })).rejects.toThrow(/boolean/)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("handler failures are logged and rethrown", async () => {
|
|
211
|
+
const w = await setup([{ content: "one", status: "pending" }])
|
|
212
|
+
const failing = w.store as unknown as { removeTodos: () => Promise<void> }
|
|
213
|
+
failing.removeTodos = async () => {
|
|
214
|
+
throw new Error("storage offline")
|
|
215
|
+
}
|
|
216
|
+
await expect(w.handlers.clear!({ sessionID: "s1" })).rejects.toThrow(/storage offline/)
|
|
217
|
+
expect(w.logs.some((line) => line.includes("rpc clear failed"))).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test("resolveMarkdownPath resolves against the directory and accepts absolute paths", () => {
|
|
221
|
+
expect(resolveMarkdownPath("/proj", "TODO.md")).toBe("/proj/TODO.md")
|
|
222
|
+
expect(resolveMarkdownPath("/proj", "/abs/PLAN.markdown")).toBe("/abs/PLAN.markdown")
|
|
223
|
+
expect(resolveMarkdownPath("/proj", "UPPER.MD")).toBe("/proj/UPPER.MD")
|
|
224
|
+
expect(() => resolveMarkdownPath("/proj", "x.txt")).toThrow()
|
|
225
|
+
})
|
|
226
|
+
})
|