@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/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
@@ -0,0 +1,2 @@
1
+ // Root-level shim for the CLI/TUI entry (see PLAN.md §7 fallback ladder, rung 3).
2
+ export { default } from "./src/tui.tsx"