@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/tui.tsx
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
// todo-supervisor — OpenCode V2 CLI (TUI) plugin (PLAN.md §6, aspect 12).
|
|
3
|
+
//
|
|
4
|
+
// Contributions:
|
|
5
|
+
// - composer strip (`session.composer.top`): one-line progress chip;
|
|
6
|
+
// - **popover**: `context.ui.dialog.show()` renders the todo list as a centered
|
|
7
|
+
// overlay, exactly like the host's own dialogs (`/todos` and the palette);
|
|
8
|
+
// - side panel (`session.panel`, name `todo.todos`): the same list, docked to
|
|
9
|
+
// the session panel (palette "Todo: open side panel");
|
|
10
|
+
// - toasts on `nudged` and on the all-done transition;
|
|
11
|
+
// - RPC events (`updated`/`nudged`) keep a memory-store snapshot fresh.
|
|
12
|
+
//
|
|
13
|
+
// All layout math lives in ./panel.ts (pure, unit-tested); this file only maps
|
|
14
|
+
// rows to OpenTUI elements. Only the server plugin may write markdown files —
|
|
15
|
+
// the UI calls RPC `toggleFile`, which rewrites the checkbox in place.
|
|
16
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
17
|
+
import type { PanelInput } from "@opencode/plugin/tui/context"
|
|
18
|
+
import { Plugin, usePlugin, type Context } from "@opencode/plugin/tui"
|
|
19
|
+
import { logFile } from "./log"
|
|
20
|
+
import {
|
|
21
|
+
buildRows,
|
|
22
|
+
clampCursor,
|
|
23
|
+
countOpen,
|
|
24
|
+
dialogWidth,
|
|
25
|
+
footerHints,
|
|
26
|
+
moveCursor,
|
|
27
|
+
popoverBodyHeight,
|
|
28
|
+
progressBar,
|
|
29
|
+
rowIndex,
|
|
30
|
+
statusMark,
|
|
31
|
+
supervisorChip,
|
|
32
|
+
truncate,
|
|
33
|
+
type DialogSizeName,
|
|
34
|
+
type PanelSnapshot,
|
|
35
|
+
type Row,
|
|
36
|
+
} from "./panel"
|
|
37
|
+
import { TodoRpc } from "./rpc"
|
|
38
|
+
|
|
39
|
+
type Store = { snapshots: Record<string, PanelSnapshot>; seenOpen: Record<string, number> }
|
|
40
|
+
type DialogSize = DialogSizeName
|
|
41
|
+
|
|
42
|
+
const SIZE_ORDER: DialogSize[] = ["medium", "large", "xlarge"]
|
|
43
|
+
|
|
44
|
+
/** Defensive theme reader: unknown token shapes degrade to no color. */
|
|
45
|
+
function themeColor(theme: unknown, path: string): string | undefined {
|
|
46
|
+
let node: unknown = theme
|
|
47
|
+
for (const key of path.split(".")) {
|
|
48
|
+
if (!node || typeof node !== "object") return undefined
|
|
49
|
+
node = (node as Record<string, unknown>)[key]
|
|
50
|
+
}
|
|
51
|
+
return typeof node === "string" ? node : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default Plugin.define({
|
|
55
|
+
id: "todo-supervisor.tui",
|
|
56
|
+
setup(context) {
|
|
57
|
+
try {
|
|
58
|
+
return register(context)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
logFile("tui", `setup failed: ${String(err)}`)
|
|
61
|
+
return () => {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function register(context: Context) {
|
|
65
|
+
logFile("tui", "setup", context.app.version ?? "")
|
|
66
|
+
const rpc = context.client.rpc(TodoRpc)
|
|
67
|
+
// Memory store: survives plugin hot reloads (old/new generations share it).
|
|
68
|
+
const [state, update] = context.storage.memory<Store>("todo-supervisor", {
|
|
69
|
+
initial: { snapshots: {}, seenOpen: {} },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Render-region width, kept reactive for resize (renderer.width is the
|
|
73
|
+
// region the TUI owns; `terminalWidth` would include any split footer).
|
|
74
|
+
const rendererWidth = () => {
|
|
75
|
+
const renderer = context.renderer as unknown as {
|
|
76
|
+
width?: number
|
|
77
|
+
root?: { width?: number }
|
|
78
|
+
}
|
|
79
|
+
const width = renderer.width ?? renderer.root?.width
|
|
80
|
+
return typeof width === "number" && width > 0 ? width : 100
|
|
81
|
+
}
|
|
82
|
+
const [viewportWidth, setViewportWidth] = createSignal(rendererWidth())
|
|
83
|
+
// Render-region height, reactive for resize (bounds the popover body).
|
|
84
|
+
const rendererHeight = () => {
|
|
85
|
+
const renderer = context.renderer as unknown as {
|
|
86
|
+
height?: number
|
|
87
|
+
root?: { height?: number }
|
|
88
|
+
}
|
|
89
|
+
const height = renderer.height ?? renderer.root?.height
|
|
90
|
+
return typeof height === "number" && height > 0 ? height : 24
|
|
91
|
+
}
|
|
92
|
+
const [viewportHeight, setViewportHeight] = createSignal(rendererHeight())
|
|
93
|
+
const onResize = (width: number, height: number) => {
|
|
94
|
+
if (typeof width === "number" && width > 0) setViewportWidth(width)
|
|
95
|
+
if (typeof height === "number" && height > 0) setViewportHeight(height)
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
context.renderer.on?.("resize" as never, onResize as never)
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logFile("tui", `resize subscribe failed: ${String(err)}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const toastError = (where: string, err: unknown) => {
|
|
104
|
+
logFile("tui", `${where} failed: ${String(err)}`)
|
|
105
|
+
try {
|
|
106
|
+
context.ui.toast.show({ title: "todo-supervisor", message: `${where} failed`, variant: "error" })
|
|
107
|
+
} catch {
|
|
108
|
+
// toast is best-effort
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const guard = (label: string, action: () => Promise<unknown>): void => {
|
|
113
|
+
action().catch((err) => toastError(label, err))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const notify = (message: string, variant: "info" | "success" | "warning" = "info"): void => {
|
|
117
|
+
try {
|
|
118
|
+
context.ui.toast.show({ title: "todo-supervisor", message, variant })
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logFile("tui", `toast failed: ${String(err)}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const refresh = async (sessionID: string) => {
|
|
125
|
+
try {
|
|
126
|
+
const result = (await rpc.list({ sessionID })) as PanelSnapshot | undefined
|
|
127
|
+
const items = Array.isArray(result?.items) ? result.items : []
|
|
128
|
+
const files = Array.isArray(result?.files) ? result.files : []
|
|
129
|
+
const open = countOpen(items) + files.reduce((sum, file) => sum + countOpen(file.items ?? []), 0)
|
|
130
|
+
const total = items.length + files.reduce((sum, file) => sum + (file.items?.length ?? 0), 0)
|
|
131
|
+
const before = state.seenOpen[sessionID]
|
|
132
|
+
update((draft) => {
|
|
133
|
+
draft.snapshots[sessionID] = { items, files, supervisor: result?.supervisor }
|
|
134
|
+
draft.seenOpen[sessionID] = open
|
|
135
|
+
})
|
|
136
|
+
if (before !== undefined && before > 0 && open === 0 && total > 0) {
|
|
137
|
+
context.ui.toast.show({ title: "todo-supervisor", message: "All todos done 🎉", variant: "success", sessionID })
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logFile("tui", `refresh(${sessionID}) failed: ${String(err)}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const offUpdated = rpc.events.on("updated", (event) => {
|
|
145
|
+
const sessionID = (event.data as { sessionID?: unknown }).sessionID
|
|
146
|
+
if (typeof sessionID === "string") void refresh(sessionID)
|
|
147
|
+
})
|
|
148
|
+
const offNudged = rpc.events.on("nudged", (event) => {
|
|
149
|
+
const data = event.data as { sessionID?: unknown; reason?: unknown }
|
|
150
|
+
if (typeof data.sessionID === "string") {
|
|
151
|
+
void refresh(data.sessionID)
|
|
152
|
+
try {
|
|
153
|
+
context.ui.toast.show({
|
|
154
|
+
title: "todo-supervisor",
|
|
155
|
+
message: data.reason === "stalled-task" ? "Checking stalled work…" : "Continuing open todos…",
|
|
156
|
+
variant: data.reason === "stalled-task" ? "warning" : "info",
|
|
157
|
+
sessionID: data.sessionID,
|
|
158
|
+
})
|
|
159
|
+
} catch (err) {
|
|
160
|
+
logFile("tui", `nudged toast failed: ${String(err)}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ---- composer strip ---------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function Strip(props: { sessionID: string }) {
|
|
168
|
+
const themed = usePlugin()
|
|
169
|
+
createEffect(() => {
|
|
170
|
+
void refresh(props.sessionID)
|
|
171
|
+
})
|
|
172
|
+
const snap = () => state.snapshots[props.sessionID]
|
|
173
|
+
const items = () => snap()?.items ?? []
|
|
174
|
+
const files = () => snap()?.files ?? []
|
|
175
|
+
const total = () => items().length + files().reduce((sum, file) => sum + (file.items?.length ?? 0), 0)
|
|
176
|
+
const open = () => countOpen(items()) + files().reduce((sum, file) => sum + countOpen(file.items ?? []), 0)
|
|
177
|
+
const paused = () => snap()?.supervisor?.paused === true
|
|
178
|
+
return (
|
|
179
|
+
<Show when={total() > 0}>
|
|
180
|
+
<text fg={themeColor(themed.theme, "text.base")}>
|
|
181
|
+
{"📋 " +
|
|
182
|
+
progressBar(total() - open(), total(), 8) +
|
|
183
|
+
" " +
|
|
184
|
+
open() +
|
|
185
|
+
"/" +
|
|
186
|
+
total() +
|
|
187
|
+
" open" +
|
|
188
|
+
(paused() ? " · ⏸" : "")}
|
|
189
|
+
</text>
|
|
190
|
+
</Show>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- the list (shared by the popover and the side panel) --------------
|
|
195
|
+
|
|
196
|
+
type ListProps = {
|
|
197
|
+
sessionID: string
|
|
198
|
+
/** Keymap layer is only live while this is true. */
|
|
199
|
+
active: () => boolean
|
|
200
|
+
/** Available content width (dialog width or panel width). */
|
|
201
|
+
width: () => number
|
|
202
|
+
/** Bounded body rows (popover); undefined lets the host size the panel. */
|
|
203
|
+
height?: () => number
|
|
204
|
+
close: () => void
|
|
205
|
+
/** Popover only: cycle the dialog size (medium → large → xlarge). */
|
|
206
|
+
cycleSize?: () => void
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function TodoList(props: ListProps) {
|
|
210
|
+
const themed = usePlugin()
|
|
211
|
+
const [selected, setSelected] = createSignal<string | undefined>(undefined)
|
|
212
|
+
const [filter, setFilter] = createSignal("")
|
|
213
|
+
const [collapsed, setCollapsed] = createSignal<ReadonlySet<string>>(new Set<string>())
|
|
214
|
+
let scrollNode: {
|
|
215
|
+
scrollChildIntoView?: (id: string) => void
|
|
216
|
+
scrollTop?: number
|
|
217
|
+
} | undefined
|
|
218
|
+
|
|
219
|
+
const sessionID = () => props.sessionID
|
|
220
|
+
const width = () => Math.max(30, props.width())
|
|
221
|
+
const snap = (): PanelSnapshot => state.snapshots[sessionID()] ?? { items: [], files: [] }
|
|
222
|
+
const rows = createMemo(() =>
|
|
223
|
+
buildRows(snap(), {
|
|
224
|
+
filter: filter(),
|
|
225
|
+
collapsed: collapsed(),
|
|
226
|
+
formatPath: (path) => context.ui.format.path(path),
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
const cursor = createMemo(() => clampCursor(rows(), selected()))
|
|
230
|
+
const current = createMemo(() => rows().find((row) => row.key === cursor()))
|
|
231
|
+
const rowId = (key: string) => `todo-row-${key}`
|
|
232
|
+
|
|
233
|
+
const base = () => themeColor(themed.theme, "text.base")
|
|
234
|
+
const muted = () => themeColor(themed.theme, "text.muted")
|
|
235
|
+
const accent = () => themeColor(themed.theme, "text.action.primary.base") ?? base()
|
|
236
|
+
const selectedBg = () =>
|
|
237
|
+
themeColor(themed.theme, "background.raised.high") ?? themeColor(themed.theme, "background.raised.base")
|
|
238
|
+
|
|
239
|
+
createEffect(() => {
|
|
240
|
+
void refresh(sessionID())
|
|
241
|
+
})
|
|
242
|
+
const poll = setInterval(() => void refresh(sessionID()), 3000)
|
|
243
|
+
onCleanup(() => clearInterval(poll))
|
|
244
|
+
|
|
245
|
+
// Keep the selected row on screen without owning the viewport height:
|
|
246
|
+
// `scrollChildIntoView` uses nearest-edge behaviour; `scrollTop = index`
|
|
247
|
+
// (row height is one line) is a guaranteed-visible fallback.
|
|
248
|
+
createEffect(() => {
|
|
249
|
+
const key = cursor()
|
|
250
|
+
if (!key || !scrollNode) return
|
|
251
|
+
try {
|
|
252
|
+
if (typeof scrollNode.scrollChildIntoView === "function") {
|
|
253
|
+
scrollNode.scrollChildIntoView(rowId(key))
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
if (typeof scrollNode.scrollTop === "number") {
|
|
257
|
+
scrollNode.scrollTop = Math.max(0, rowIndex(rows(), key))
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// scrolling is best-effort
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const move = (delta: number) => setSelected(moveCursor(rows(), cursor(), delta))
|
|
265
|
+
const jumpHome = () => setSelected(moveCursor(rows(), undefined, 0))
|
|
266
|
+
const jumpEnd = () => setSelected(moveCursor(rows(), cursor(), Number.MAX_SAFE_INTEGER))
|
|
267
|
+
|
|
268
|
+
const toggleCollapse = (path: string) =>
|
|
269
|
+
setCollapsed((previous) => {
|
|
270
|
+
const next = new Set(previous)
|
|
271
|
+
if (next.has(path)) next.delete(path)
|
|
272
|
+
else next.add(path)
|
|
273
|
+
return next
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const toggleSelected = () => {
|
|
277
|
+
const row = current()
|
|
278
|
+
if (!row) return
|
|
279
|
+
if (row.kind === "file") {
|
|
280
|
+
toggleCollapse(row.path)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (row.kind !== "item") return
|
|
284
|
+
if (row.source === "session" && row.index !== undefined) {
|
|
285
|
+
guard("toggle", () => rpc.toggle({ sessionID: sessionID(), index: row.index! }))
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (row.path && row.line !== undefined) {
|
|
289
|
+
guard("toggle file item", () => rpc.toggleFile({ sessionID: sessionID(), path: row.path!, line: row.line! }))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const addFile = () =>
|
|
294
|
+
guard("add file", async () => {
|
|
295
|
+
const path = await context.ui.dialog.prompt({
|
|
296
|
+
title: "Subscribe to a markdown file",
|
|
297
|
+
description: "Checklist items in the file count as todos.",
|
|
298
|
+
placeholder: "TODO.md",
|
|
299
|
+
})
|
|
300
|
+
if (!path) return
|
|
301
|
+
await rpc.subscribe({ sessionID: sessionID(), path })
|
|
302
|
+
notify(`Subscribed: ${path}`, "success")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const removeFile = () =>
|
|
306
|
+
guard("remove file", async () => {
|
|
307
|
+
const row = current()
|
|
308
|
+
const direct = row && row.kind === "file" ? row.path : row && row.kind === "item" ? row.path : undefined
|
|
309
|
+
const files = snap().files
|
|
310
|
+
if (files.length === 0) {
|
|
311
|
+
notify("No subscribed files", "warning")
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
const path =
|
|
315
|
+
direct ??
|
|
316
|
+
(await context.ui.dialog.select({
|
|
317
|
+
title: "Unsubscribe file",
|
|
318
|
+
options: files.map((file) => ({ title: context.ui.format.path(file.path), value: file.path })),
|
|
319
|
+
}))
|
|
320
|
+
if (!path) return
|
|
321
|
+
await rpc.unsubscribe({ sessionID: sessionID(), path })
|
|
322
|
+
notify("Unsubscribed", "success")
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
const pauseResume = () => {
|
|
326
|
+
const paused = snap().supervisor?.paused === true
|
|
327
|
+
guard("supervision", async () => {
|
|
328
|
+
await rpc.setEnabled({ sessionID: sessionID(), enabled: paused })
|
|
329
|
+
notify(paused ? "Supervision resumed" : "Supervision paused", paused ? "success" : "warning")
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const applyFilter = () =>
|
|
334
|
+
guard("filter", async () => {
|
|
335
|
+
const value = await context.ui.dialog.prompt({
|
|
336
|
+
title: "Filter todos",
|
|
337
|
+
description: "Case-insensitive substring match; empty clears the filter.",
|
|
338
|
+
value: filter() ?? undefined,
|
|
339
|
+
placeholder: "search",
|
|
340
|
+
})
|
|
341
|
+
if (value === undefined) return
|
|
342
|
+
setFilter(value)
|
|
343
|
+
setSelected(undefined)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
context.keymap.layer(() => ({
|
|
347
|
+
mode: "global",
|
|
348
|
+
enabled: () => props.active(),
|
|
349
|
+
priority: 10,
|
|
350
|
+
commands: [
|
|
351
|
+
{ title: "Todo: select next", bind: "down", run: () => move(1) },
|
|
352
|
+
{ title: "Todo: select previous", bind: "up", run: () => move(-1) },
|
|
353
|
+
{ bind: "j", run: () => move(1) },
|
|
354
|
+
{ bind: "k", run: () => move(-1) },
|
|
355
|
+
{ title: "Todo: next page", bind: "pagedown", run: () => move(10) },
|
|
356
|
+
{ title: "Todo: previous page", bind: "pageup", run: () => move(-10) },
|
|
357
|
+
{ title: "Todo: first", bind: "home", run: jumpHome },
|
|
358
|
+
{ title: "Todo: last", bind: "end", run: jumpEnd },
|
|
359
|
+
{ bind: "g", run: jumpHome },
|
|
360
|
+
{ bind: "G", run: jumpEnd },
|
|
361
|
+
{ title: "Todo: toggle item", bind: "space", run: toggleSelected },
|
|
362
|
+
{ title: "Todo: toggle item", bind: "x", run: toggleSelected },
|
|
363
|
+
{
|
|
364
|
+
title: "Todo: collapse section",
|
|
365
|
+
bind: "enter",
|
|
366
|
+
run: () => {
|
|
367
|
+
const row = current()
|
|
368
|
+
if (row?.kind === "file") toggleCollapse(row.path)
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
title: "Todo: nudge now",
|
|
373
|
+
bind: "n",
|
|
374
|
+
run: () =>
|
|
375
|
+
guard("nudge", async () => {
|
|
376
|
+
await rpc.nudge({ sessionID: sessionID() })
|
|
377
|
+
notify("Nudge sent", "success")
|
|
378
|
+
}),
|
|
379
|
+
},
|
|
380
|
+
{ title: "Todo: pause/resume supervision", bind: "s", run: pauseResume },
|
|
381
|
+
{ title: "Todo: add markdown file", bind: "a", run: addFile },
|
|
382
|
+
{ title: "Todo: remove markdown file", bind: "d", run: removeFile },
|
|
383
|
+
{
|
|
384
|
+
title: "Todo: refresh",
|
|
385
|
+
bind: "r",
|
|
386
|
+
run: () =>
|
|
387
|
+
guard("refresh", async () => {
|
|
388
|
+
await refresh(sessionID())
|
|
389
|
+
notify("Refreshed")
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
392
|
+
{ title: "Todo: filter", bind: "/", run: applyFilter },
|
|
393
|
+
{
|
|
394
|
+
title: "Todo: resize",
|
|
395
|
+
bind: "f",
|
|
396
|
+
run: () => props.cycleSize?.(),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
title: "Todo: clear list",
|
|
400
|
+
bind: "c",
|
|
401
|
+
run: () =>
|
|
402
|
+
guard("clear", async () => {
|
|
403
|
+
const ok = await context.ui.dialog.confirm({
|
|
404
|
+
title: "Clear todos",
|
|
405
|
+
message: "Remove the session todo list? Markdown subscriptions stay.",
|
|
406
|
+
label: { confirm: "Clear", cancel: "Cancel" },
|
|
407
|
+
})
|
|
408
|
+
if (ok) await rpc.clear({ sessionID: sessionID() })
|
|
409
|
+
}),
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
title: "Todo: help",
|
|
413
|
+
bind: "?",
|
|
414
|
+
run: () =>
|
|
415
|
+
guard("help", async () => {
|
|
416
|
+
await context.ui.dialog.alert({
|
|
417
|
+
title: "Todo panel",
|
|
418
|
+
message:
|
|
419
|
+
"↑↓/jk move · space/x toggle · enter collapse · n nudge · s pause/resume\n" +
|
|
420
|
+
"a add file · d remove file · r refresh · / filter · f resize · c clear · esc close",
|
|
421
|
+
})
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
{ title: "Todo: close", bind: "escape", run: () => props.close() },
|
|
425
|
+
],
|
|
426
|
+
}))
|
|
427
|
+
|
|
428
|
+
const totals = () => {
|
|
429
|
+
const current = snap()
|
|
430
|
+
const total = current.items.length + current.files.reduce((sum, file) => sum + file.items.length, 0)
|
|
431
|
+
const open = countOpen(current.items) + current.files.reduce((sum, file) => sum + countOpen(file.items), 0)
|
|
432
|
+
return { total, open }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Selected position among selectable rows, e.g. `2/9` (windowed lists). */
|
|
436
|
+
const position = () => {
|
|
437
|
+
const selectable = rows().filter((row) => row.kind !== "section")
|
|
438
|
+
const key = cursor()
|
|
439
|
+
const index = selectable.findIndex((row) => row.key === key)
|
|
440
|
+
return index === -1 ? undefined : { index: index + 1, total: selectable.length }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const bodyHeight = () => {
|
|
444
|
+
const available = props.height?.()
|
|
445
|
+
if (available === undefined) return undefined
|
|
446
|
+
// The filter line, when shown, takes one row from the body budget.
|
|
447
|
+
const filtered = (filter() ?? "") !== "" ? 1 : 0
|
|
448
|
+
return Math.max(3, available - filtered)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const barWidth = () => Math.max(6, Math.min(24, width() - 44))
|
|
452
|
+
|
|
453
|
+
const renderRow = (row: Row) => {
|
|
454
|
+
const isCurrent = () => row.key === cursor()
|
|
455
|
+
const prefix = () => (isCurrent() ? "❯ " : " ")
|
|
456
|
+
const contentWidth = () => Math.max(8, width() - 6)
|
|
457
|
+
const background = () => (isCurrent() ? selectedBg() : undefined)
|
|
458
|
+
if (row.kind === "section") {
|
|
459
|
+
return (
|
|
460
|
+
<text fg={muted()}>
|
|
461
|
+
{prefix() + truncate(row.label + (row.detail ? " · " + row.detail : ""), contentWidth())}
|
|
462
|
+
</text>
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
if (row.kind === "file") {
|
|
466
|
+
const label = row.exists ? row.label : `${row.label} (missing)`
|
|
467
|
+
const detail = `${row.open} open of ${row.total}`
|
|
468
|
+
return (
|
|
469
|
+
<box backgroundColor={background()}>
|
|
470
|
+
<text fg={isCurrent() ? accent() : base()}>
|
|
471
|
+
{prefix() + (row.collapsed ? "▸ " : "▾ ") + truncate(`${label} · ${detail}`, contentWidth())}
|
|
472
|
+
</text>
|
|
473
|
+
</box>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
const suffix =
|
|
477
|
+
(row.priority ? ` (${row.priority})` : "") + (row.line !== undefined ? ` :${row.line}` : "")
|
|
478
|
+
return (
|
|
479
|
+
<box backgroundColor={background()}>
|
|
480
|
+
<text fg={row.status === "completed" ? muted() : isCurrent() ? accent() : base()}>
|
|
481
|
+
{prefix() + statusMark(row.status) + " " + truncate(row.content + suffix, contentWidth())}
|
|
482
|
+
</text>
|
|
483
|
+
</box>
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<box flexDirection="column">
|
|
489
|
+
<text fg={base()}>
|
|
490
|
+
{"Todos " + progressBar(totals().total - totals().open, totals().total, barWidth()) + " "}
|
|
491
|
+
{`${totals().open}/${totals().total} open` +
|
|
492
|
+
(position() ? ` · row ${position()!.index}/${position()!.total}` : "")}
|
|
493
|
+
</text>
|
|
494
|
+
<text fg={muted()}>{"supervision: " + supervisorChip(snap().supervisor)}</text>
|
|
495
|
+
<Show when={(filter() ?? "") !== ""}>
|
|
496
|
+
<text fg={muted()}>{`filter: "${filter()}" (press / to change)`}</text>
|
|
497
|
+
</Show>
|
|
498
|
+
<scrollbox
|
|
499
|
+
ref={(node: unknown) => (scrollNode = node as typeof scrollNode)}
|
|
500
|
+
height={bodyHeight()}
|
|
501
|
+
flexGrow={bodyHeight() === undefined ? 1 : undefined}
|
|
502
|
+
>
|
|
503
|
+
<For each={rows()}>
|
|
504
|
+
{(row) => (
|
|
505
|
+
<box id={rowId(row.key)} flexDirection="row">
|
|
506
|
+
{renderRow(row)}
|
|
507
|
+
</box>
|
|
508
|
+
)}
|
|
509
|
+
</For>
|
|
510
|
+
</scrollbox>
|
|
511
|
+
<text fg={muted()}>{truncate(footerHints(width()).join(" · "), Math.max(10, width() - 2))}</text>
|
|
512
|
+
</box>
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- popover (an overlay dialog, like the host's own panes) -----------
|
|
517
|
+
|
|
518
|
+
function TodoPopover(props: { sessionID: string }) {
|
|
519
|
+
const [size, setSize] = createSignal<DialogSize>("large")
|
|
520
|
+
const cycleSize = () => {
|
|
521
|
+
const next = SIZE_ORDER[(SIZE_ORDER.indexOf(size()) + 1) % SIZE_ORDER.length]!
|
|
522
|
+
setSize(next)
|
|
523
|
+
try {
|
|
524
|
+
context.ui.dialog.set({ size: next, centered: true })
|
|
525
|
+
} catch (err) {
|
|
526
|
+
logFile("tui", `dialog resize failed: ${String(err)}`)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return (
|
|
530
|
+
<TodoList
|
|
531
|
+
sessionID={props.sessionID}
|
|
532
|
+
active={() => true}
|
|
533
|
+
width={() => dialogWidth(size(), viewportWidth())}
|
|
534
|
+
height={() => popoverBodyHeight(viewportHeight())}
|
|
535
|
+
close={() => context.ui.dialog.clear()}
|
|
536
|
+
cycleSize={cycleSize}
|
|
537
|
+
/>
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const openPopover = (sessionID: string): boolean => {
|
|
542
|
+
try {
|
|
543
|
+
context.ui.dialog.set({ size: "large", centered: true })
|
|
544
|
+
context.ui.dialog.show(
|
|
545
|
+
() => <TodoPopover sessionID={sessionID} />,
|
|
546
|
+
() => logFile("tui", "popover closed"),
|
|
547
|
+
)
|
|
548
|
+
return true
|
|
549
|
+
} catch (err) {
|
|
550
|
+
toastError("open popover", err)
|
|
551
|
+
return false
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---- slots, commands --------------------------------------------------
|
|
556
|
+
|
|
557
|
+
const disposeStrip = context.ui.slot({
|
|
558
|
+
append: "session.composer.top",
|
|
559
|
+
render: (input) => <Strip sessionID={input.sessionID} />,
|
|
560
|
+
})
|
|
561
|
+
const disposePanel = context.ui.slot({
|
|
562
|
+
append: "session.panel",
|
|
563
|
+
render: (panel: PanelInput) => (
|
|
564
|
+
<Show when={panel.name === "todo.todos"}>
|
|
565
|
+
<TodoList
|
|
566
|
+
sessionID={panel.sessionID}
|
|
567
|
+
active={() => panel.focused}
|
|
568
|
+
width={() => panel.width}
|
|
569
|
+
close={() => panel.close()}
|
|
570
|
+
cycleSize={() => panel.toggleFullscreen()}
|
|
571
|
+
/>
|
|
572
|
+
</Show>
|
|
573
|
+
),
|
|
574
|
+
})
|
|
575
|
+
logFile("tui", "slots registered (session.composer.top, session.panel todo.todos)")
|
|
576
|
+
|
|
577
|
+
const currentSessionID = () => {
|
|
578
|
+
const route = context.ui.router.current()
|
|
579
|
+
return route.type === "session" ? route.sessionID : undefined
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
context.keymap.layer(() => ({
|
|
583
|
+
mode: "global",
|
|
584
|
+
commands: [
|
|
585
|
+
{
|
|
586
|
+
id: "todo-supervisor.open",
|
|
587
|
+
title: "Todo: open todos",
|
|
588
|
+
description: "Open the todo popover; /todos add <path> or /todos remove <path> manages files",
|
|
589
|
+
group: "Todo",
|
|
590
|
+
palette: true,
|
|
591
|
+
slash: { name: "todos", arguments: true },
|
|
592
|
+
run: (input) => {
|
|
593
|
+
const args = String(input ?? "")
|
|
594
|
+
.trim()
|
|
595
|
+
.split(/\s+/)
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
const sessionID = currentSessionID()
|
|
598
|
+
const inSession = (action: (id: string) => Promise<unknown>) => {
|
|
599
|
+
if (!sessionID) {
|
|
600
|
+
notify("Open a session first", "warning")
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
guard("todos", () => action(sessionID))
|
|
604
|
+
}
|
|
605
|
+
if (args[0] === "add" && args[1]) {
|
|
606
|
+
const path = args[1]
|
|
607
|
+
inSession(async (id) => {
|
|
608
|
+
await rpc.subscribe({ sessionID: id, path })
|
|
609
|
+
notify(`Subscribed: ${path}`, "success")
|
|
610
|
+
})
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
if (args[0] === "remove" && args[1]) {
|
|
614
|
+
const path = args[1]
|
|
615
|
+
inSession(async (id) => {
|
|
616
|
+
await rpc.unsubscribe({ sessionID: id, path })
|
|
617
|
+
notify("Unsubscribed", "success")
|
|
618
|
+
})
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
if (!sessionID) {
|
|
622
|
+
notify("Open a session first", "warning")
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
openPopover(sessionID)
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
id: "todo-supervisor.open-panel",
|
|
630
|
+
title: "Todo: open side panel",
|
|
631
|
+
description: "Dock the todo list to the session panel instead of the popover",
|
|
632
|
+
group: "Todo",
|
|
633
|
+
palette: true,
|
|
634
|
+
run: () => {
|
|
635
|
+
if (!context.ui.panel.open("todo.todos")) {
|
|
636
|
+
notify("Open a session to view todos", "warning")
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
}))
|
|
642
|
+
|
|
643
|
+
logFile("tui", "ready")
|
|
644
|
+
return () => {
|
|
645
|
+
offUpdated()
|
|
646
|
+
offNudged()
|
|
647
|
+
disposeStrip()
|
|
648
|
+
disposePanel()
|
|
649
|
+
try {
|
|
650
|
+
context.renderer.off?.("resize" as never, onResize as never)
|
|
651
|
+
} catch {
|
|
652
|
+
// best-effort
|
|
653
|
+
}
|
|
654
|
+
logFile("tui", "disposed")
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
})
|
package/tui.ts
ADDED