@hacky.software/opencode-todo-supervisor 0.3.6 → 0.3.8

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/README.md CHANGED
@@ -23,7 +23,7 @@ Tested against OpenCode `2.0.24`.
23
23
  ## Install
24
24
 
25
25
  ```sh
26
- opencode plugin add @hacky.software/opencode-todo-supervisor@0.3.6
26
+ opencode plugin add @hacky.software/opencode-todo-supervisor@0.3.8
27
27
  ```
28
28
 
29
29
  This also loads the bundled TUI entry. To pass options, use the object form:
@@ -174,12 +174,14 @@ effective set is `config.markdownFiles ∪ stored files − removed` per session
174
174
 
175
175
  - **Composer strip** (`session.composer.top`): `📋 ███░░░░░ 8/17 open` (plus `⏸`
176
176
  while paused), hidden when there is neither a list nor a subscription.
177
- - **Popover** (`/todos`, palette "Todo: open todos"): a centered overlay dialog.
178
- Its body and width scale with the terminal (up to 48 rows / a share of the
179
- window), so the key legend stays visible on a short terminal and a tall one
177
+ - **Popover** (`/todos`, palette "Todo: open todos"): a centered overlay dialog,
178
+ opened at the widest preset. The body scales with the terminal height (up to
179
+ 48 rows) so the key legend stays visible on a short terminal and a tall one
180
180
  shows more rows; the header shows `open/total` and the selected `row n/m`. `f`
181
- cycles the dialog size — medium (70%), large (90%), xlarge (98% of the
182
- terminal width, capped at 200 cells) — and `esc` closes it.
181
+ cycles the host dialog width — medium (60), large (88), xlarge (116 cells) —
182
+ and `esc` closes it. Rows are laid out to the card width exactly (and render
183
+ with wrapping off), so content never wraps and the card cannot grow past the
184
+ bottom of the screen.
183
185
  - header: progress bar, `open/total`, and a supervision chip (`idle · quiet
184
186
  12s · nudges 2`, `working: 1 tool · 1 shell · quiet 4s`, `paused
185
187
  (no-progress)`, `not supervised`);
@@ -195,7 +197,10 @@ effective set is `config.markdownFiles ∪ stored files − removed` per session
195
197
  - it re-reads every 3 s while open, so edits made outside OpenCode show up
196
198
  without waiting for an event.
197
199
  - **Side panel** (palette "Todo: open side panel"): the same list docked to
198
- `session.panel` (`todo.todos`), with `f` for fullscreen.
200
+ `session.panel` (`todo.todos`), with `f` for fullscreen. Its keys are live only
201
+ while the panel is focused *and* no overlay is up (dialog, menu, completion
202
+ list), so a docked panel never steals the arrow keys from the model picker,
203
+ command palette or the prompt's completions.
199
204
  - `/todos add <path>` and `/todos remove <path>` manage subscriptions from the
200
205
  prompt.
201
206
  - **Toasts** on `nudged` (idle vs stalled wording) and on the all-done transition.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hacky.software/opencode-todo-supervisor",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "OpenCode V2 plugin that gives every session a todo list (todo_write/todo_read), supervises idle sessions until the list is done, and can drive checklists in markdown files from a TUI panel.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/panel.test.ts CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  isSelectable,
11
11
  itemColumns,
12
12
  moveCursor,
13
+ panelKeys,
13
14
  popoverBodyHeight,
15
+ popoverKeys,
14
16
  priorityTag,
15
17
  progressBar,
16
18
  quietLabel,
@@ -189,16 +191,35 @@ describe("panel cursor", () => {
189
191
  expect(popoverBodyHeight(200)).toBe(48)
190
192
  })
191
193
 
192
- test("popover width follows the dialog size and clamps", () => {
193
- expect(dialogWidth("medium", 200)).toBe(140)
194
- expect(dialogWidth("large", 200)).toBe(180)
195
- expect(dialogWidth("xlarge", 200)).toBe(196)
196
- expect(dialogWidth("medium", 80)).toBe(56)
197
- expect(dialogWidth("large", 80)).toBe(72)
198
- expect(dialogWidth("xlarge", 80)).toBe(76)
199
- expect(dialogWidth("xlarge", 20)).toBe(36)
200
- // xlarge tracks the window instead of a fixed cap.
201
- expect(dialogWidth("xlarge", 120)).toBe(116)
194
+ test("popover width matches the host dialog card and clamps", () => {
195
+ expect(dialogWidth("medium", 200)).toBe(60)
196
+ expect(dialogWidth("large", 200)).toBe(88)
197
+ expect(dialogWidth("xlarge", 200)).toBe(116)
198
+ expect(dialogWidth("medium", 80)).toBe(60)
199
+ expect(dialogWidth("large", 80)).toBe(78)
200
+ expect(dialogWidth("xlarge", 80)).toBe(78)
201
+ // Never wider than the window (minus the host card's 2-cell inset).
202
+ expect(dialogWidth("xlarge", 20)).toBe(20)
203
+ })
204
+
205
+ test("the panel layer yields to overlays that own the keyboard", () => {
206
+ // Normal session view: live while focused.
207
+ expect(panelKeys(true, "base")).toBe(true)
208
+ expect(panelKeys(false, "base")).toBe(false)
209
+ // The model picker, confirmations and other dialogs run in `modal`.
210
+ expect(panelKeys(true, "modal")).toBe(false)
211
+ expect(panelKeys(true, "menu")).toBe(false)
212
+ expect(panelKeys(true, "autocomplete")).toBe(false)
213
+ // An unknown mode is not treated as an overlay.
214
+ expect(panelKeys(true, "composer")).toBe(true)
215
+ })
216
+
217
+ test("the popover keeps the keyboard in its own dialog", () => {
218
+ expect(popoverKeys("modal")).toBe(true)
219
+ expect(popoverKeys("base")).toBe(true)
220
+ expect(popoverKeys("composer")).toBe(true)
221
+ expect(popoverKeys("menu")).toBe(false)
222
+ expect(popoverKeys("autocomplete")).toBe(false)
202
223
  })
203
224
 
204
225
  test("id and priority tags and their column widths", () => {
package/src/panel.ts CHANGED
@@ -306,15 +306,45 @@ export function popoverBodyHeight(viewportHeight: number, extraRows = 0): number
306
306
 
307
307
  export type DialogSizeName = "medium" | "large" | "xlarge"
308
308
 
309
- const DIALOG_FACTOR: Record<DialogSizeName, number> = { medium: 0.7, large: 0.9, xlarge: 0.98 }
310
-
311
309
  /**
312
- * Content width for the popover dialog, as a fraction of the terminal width so
313
- * it scales with the window (`medium` 70%, `large` 90%, `xlarge` 98%), clamped
314
- * for small terminals and capped so it never spans an ultrawide screen.
310
+ * The host draws a dialog inside a card of a *fixed* width per size preset
311
+ * (`medium` 60, `large` 88, `xlarge` 116 cells), capped at the render width
312
+ * minus two columns; the card height hugs its content.
313
+ *
314
+ * Content must be laid out to exactly that width. Text passed a wider budget
315
+ * wraps (`wrapMode` defaults to `"word"`), which grows the content-hugging card
316
+ * past the bottom of the screen. So we mirror the host preset rather than a
317
+ * share of the terminal.
315
318
  */
319
+ const DIALOG_WIDTH: Record<DialogSizeName, number> = { medium: 60, large: 88, xlarge: 116 }
320
+
321
+ /** Content width for the popover dialog: the host card width for `size`. */
316
322
  export function dialogWidth(size: DialogSizeName, viewportWidth: number): number {
317
- const total = Math.max(40, viewportWidth)
318
- const target = Math.round(total * DIALOG_FACTOR[size])
319
- return Math.max(36, Math.min(target, total - 4, 200))
323
+ return Math.min(DIALOG_WIDTH[size], Math.max(20, viewportWidth - 2))
324
+ }
325
+
326
+ /**
327
+ * Keymap modes that own the keyboard while they are up: `modal` (dialogs, the
328
+ * model picker, confirmations), `menu` (the command/palette menus) and
329
+ * `autocomplete` (the prompt's completion list). They all drive themselves with
330
+ * the arrow keys.
331
+ */
332
+ const OVERLAY_MODES: ReadonlySet<string> = new Set(["modal", "menu", "autocomplete"])
333
+
334
+ /**
335
+ * Whether the side panel's keys should be live. The layer binds the arrows
336
+ * globally at a high priority, so it must yield the moment an overlay opens —
337
+ * otherwise a focused docked panel swallows the model picker's up/down.
338
+ */
339
+ export function panelKeys(focused: boolean, mode: string): boolean {
340
+ return focused && !OVERLAY_MODES.has(mode)
341
+ }
342
+
343
+ /**
344
+ * Whether the popover's keys should be live. The popover *is* a dialog, so
345
+ * `modal` is ours; only the non-dialog overlays (palette, completion list) take
346
+ * the keyboard away from it.
347
+ */
348
+ export function popoverKeys(mode: string): boolean {
349
+ return mode !== "menu" && mode !== "autocomplete"
320
350
  }
package/src/tui.tsx CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  idTag,
27
27
  itemColumns,
28
28
  moveCursor,
29
+ panelKeys,
29
30
  popoverBodyHeight,
31
+ popoverKeys,
30
32
  priorityTag,
31
33
  progressBar,
32
34
  rowIndex,
@@ -539,7 +541,7 @@ export default Plugin.define({
539
541
  const background = () => (isCurrent() ? selectedBg() : undefined)
540
542
  if (row.kind === "section") {
541
543
  return (
542
- <text fg={muted()}>
544
+ <text fg={muted()} wrapMode="none">
543
545
  {prefix() + truncate(row.label + (row.detail ? " · " + row.detail : ""), width() - 6)}
544
546
  </text>
545
547
  )
@@ -549,7 +551,7 @@ export default Plugin.define({
549
551
  const detail = `${row.open} open of ${row.total}`
550
552
  return (
551
553
  <box backgroundColor={background()}>
552
- <text fg={isCurrent() ? accent() : base()}>
554
+ <text fg={isCurrent() ? accent() : base()} wrapMode="none">
553
555
  {prefix() + (row.collapsed ? "▸ " : "▾ ") + truncate(`${label} · ${detail}`, width() - 6)}
554
556
  </text>
555
557
  </box>
@@ -562,7 +564,7 @@ export default Plugin.define({
562
564
  parts.push(truncate(row.content + (row.line !== undefined ? ` :${row.line}` : ""), contentWidth()))
563
565
  return (
564
566
  <box backgroundColor={background()}>
565
- <text fg={row.status === "completed" ? muted() : isCurrent() ? accent() : base()}>
567
+ <text fg={row.status === "completed" ? muted() : isCurrent() ? accent() : base()} wrapMode="none">
566
568
  {parts.join(" ")}
567
569
  </text>
568
570
  </box>
@@ -571,14 +573,23 @@ export default Plugin.define({
571
573
 
572
574
  return (
573
575
  <box flexDirection="column">
574
- <text fg={base()}>
575
- {"Todos " + progressBar(totals().total - totals().open, totals().total, barWidth()) + " "}
576
- {`${totals().open}/${totals().total} open` +
577
- (position() ? ` · row ${position()!.index}/${position()!.total}` : "")}
576
+ <text fg={base()} wrapMode="none">
577
+ {truncate(
578
+ "Todos " +
579
+ progressBar(totals().total - totals().open, totals().total, barWidth()) +
580
+ " " +
581
+ `${totals().open}/${totals().total} open` +
582
+ (position() ? ` · row ${position()!.index}/${position()!.total}` : ""),
583
+ width() - 2,
584
+ )}
585
+ </text>
586
+ <text fg={muted()} wrapMode="none">
587
+ {truncate("supervision: " + supervisorChip(snap().supervisor), width() - 2)}
578
588
  </text>
579
- <text fg={muted()}>{"supervision: " + supervisorChip(snap().supervisor)}</text>
580
589
  <Show when={(filter() ?? "") !== ""}>
581
- <text fg={muted()}>{`filter: "${filter()}" (press / to change)`}</text>
590
+ <text fg={muted()} wrapMode="none">
591
+ {truncate(`filter: "${filter()}" (press / to change)`, width() - 2)}
592
+ </text>
582
593
  </Show>
583
594
  <scrollbox
584
595
  ref={(node: unknown) => (scrollNode = node as typeof scrollNode)}
@@ -593,7 +604,9 @@ export default Plugin.define({
593
604
  )}
594
605
  </For>
595
606
  </scrollbox>
596
- <text fg={muted()}>{truncate(footerHints(width()).join(" · "), Math.max(10, width() - 2))}</text>
607
+ <text fg={muted()} wrapMode="none">
608
+ {truncate(footerHints(width()).join(" · "), Math.max(10, width() - 2))}
609
+ </text>
597
610
  </box>
598
611
  )
599
612
  }
@@ -601,7 +614,7 @@ export default Plugin.define({
601
614
  // ---- popover (an overlay dialog, like the host's own panes) -----------
602
615
 
603
616
  function TodoPopover(props: { sessionID: string }) {
604
- const [size, setSize] = createSignal<DialogSize>("large")
617
+ const [size, setSize] = createSignal<DialogSize>("xlarge")
605
618
  const cycleSize = () => {
606
619
  const next = SIZE_ORDER[(SIZE_ORDER.indexOf(size()) + 1) % SIZE_ORDER.length]!
607
620
  setSize(next)
@@ -614,7 +627,7 @@ export default Plugin.define({
614
627
  return (
615
628
  <TodoList
616
629
  sessionID={props.sessionID}
617
- active={() => true}
630
+ active={() => popoverKeys(context.keymap.mode.current())}
618
631
  width={() => dialogWidth(size(), viewportWidth())}
619
632
  height={() => popoverBodyHeight(viewportHeight())}
620
633
  close={() => context.ui.dialog.clear()}
@@ -625,7 +638,7 @@ export default Plugin.define({
625
638
 
626
639
  const openPopover = (sessionID: string): boolean => {
627
640
  try {
628
- context.ui.dialog.set({ size: "large", centered: true })
641
+ context.ui.dialog.set({ size: "xlarge", centered: true })
629
642
  context.ui.dialog.show(
630
643
  () => <TodoPopover sessionID={sessionID} />,
631
644
  () => logFile("tui", "popover closed"),
@@ -649,7 +662,7 @@ export default Plugin.define({
649
662
  <Show when={panel.name === "todo.todos"}>
650
663
  <TodoList
651
664
  sessionID={panel.sessionID}
652
- active={() => panel.focused}
665
+ active={() => panelKeys(panel.focused, context.keymap.mode.current())}
653
666
  width={() => panel.width}
654
667
  close={() => panel.close()}
655
668
  cycleSize={() => panel.toggleFullscreen()}