tuiboard 0.5.0

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.
Files changed (41) hide show
  1. package/.tuiboard/config.example.yaml +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -0
  4. package/bin/tuiboard.ts +28 -0
  5. package/package.json +62 -0
  6. package/src/app.tsx +129 -0
  7. package/src/cli/args.test.ts +40 -0
  8. package/src/cli/args.ts +41 -0
  9. package/src/config/loader.ts +169 -0
  10. package/src/input/handleKey.ts +733 -0
  11. package/src/io/watcher.ts +85 -0
  12. package/src/io/writer.ts +92 -0
  13. package/src/parser/markdown.ts +351 -0
  14. package/src/parser/serialize.ts +97 -0
  15. package/src/scripts/agents-check.ts +24 -0
  16. package/src/scripts/parse-check.ts +124 -0
  17. package/src/scripts/roundtrip-check.ts +79 -0
  18. package/src/store/agents.test.ts +181 -0
  19. package/src/store/agents.ts +435 -0
  20. package/src/store/index.test.ts +110 -0
  21. package/src/store/index.ts +972 -0
  22. package/src/store/parsers.ts +243 -0
  23. package/src/store/timeline.test.ts +279 -0
  24. package/src/store/timeline.ts +279 -0
  25. package/src/store/virtual-panel.ts +0 -0
  26. package/src/types.ts +116 -0
  27. package/src/ui/AgentRow.tsx +79 -0
  28. package/src/ui/AgentsBar.tsx +102 -0
  29. package/src/ui/BoardView.tsx +333 -0
  30. package/src/ui/Chrome.tsx +122 -0
  31. package/src/ui/Modal.tsx +613 -0
  32. package/src/ui/TaskRow.tsx +240 -0
  33. package/src/ui/TimelineView.tsx +643 -0
  34. package/src/ui/VirtualPanel.tsx +237 -0
  35. package/src/ui/board-scroll.test.ts +63 -0
  36. package/src/ui/board-scroll.ts +49 -0
  37. package/src/ui/glyphs.ts +129 -0
  38. package/src/views/AgentsOnly.tsx +103 -0
  39. package/src/views/BoardOnly.tsx +35 -0
  40. package/src/views/Dashboard.tsx +106 -0
  41. package/src/views/TimelineOnly.tsx +12 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Modal overlay router.
3
+ *
4
+ * Reads the active modal from the store and renders the appropriate dialog.
5
+ * Each dialog handles its own input via the OpenTUI <input> element. Submit
6
+ * (Enter) commits the action; Escape closes.
7
+ *
8
+ * Keyboard routing: when a modal is open, the OpenTUI <input> is focused, so
9
+ * navigation keys are consumed by the input field. The main keyboard handler
10
+ * still gets a chance for Escape, but to avoid conflicts the app's handleKey
11
+ * checks `ui.modal` first and bails if set (only Escape passes through).
12
+ */
13
+
14
+ import { For, Show, createMemo, createSignal } from "solid-js";
15
+
16
+ import { isTask } from "~/parser/markdown";
17
+ import {
18
+ parseDateShortcut,
19
+ parseQuickAdd,
20
+ parseTimeBlockShortcut,
21
+ } from "~/store/parsers";
22
+ import { ATTR, T } from "~/ui/glyphs";
23
+ import type { TuiStore } from "~/store/index";
24
+ import type { PriorityLevel, TimeBlock } from "~/types";
25
+
26
+ /** Fixed side-panel width for any modal. Wide enough for Detail / Help
27
+ * without being absurd for Edit / Confirm. */
28
+ const MODAL_WIDTH = 64;
29
+
30
+ export function ModalLayer(props: { store: TuiStore }) {
31
+ const modal = createMemo(() => props.store.state.ui.modal);
32
+ return (
33
+ <Show when={modal()}>
34
+ <box
35
+ style={{
36
+ flexDirection: "column",
37
+ width: MODAL_WIDTH,
38
+ minWidth: MODAL_WIDTH,
39
+ flexGrow: 0,
40
+ flexShrink: 0,
41
+ // Stretch to the parent's height so the modal panel matches
42
+ // the timeline / board zones it sits next to. Same contract as
43
+ // every other zone — fixed cross-axis size, full-height fill.
44
+ alignSelf: "stretch",
45
+ marginLeft: 1,
46
+ backgroundColor: T.panelBgActive,
47
+ border: true,
48
+ borderStyle: "rounded",
49
+ borderColor: T.borderActive,
50
+ paddingLeft: 1,
51
+ paddingRight: 1,
52
+ paddingTop: 1,
53
+ paddingBottom: 1,
54
+ }}
55
+ >
56
+ <ModalRouter store={props.store} modal={modal()!} />
57
+ </box>
58
+ </Show>
59
+ );
60
+ }
61
+
62
+ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["state"]["ui"]["modal"]> }) {
63
+ const m = props.modal;
64
+ switch (m.kind) {
65
+ case "add": return <AddModal store={props.store} columnIndex={m.targetColumnIndex} />;
66
+ case "edit": return <EditModal store={props.store} modal={m} />;
67
+ case "schedule": return <ScheduleModal store={props.store} modal={m} />;
68
+ case "timeblock":return <TimeBlockModal store={props.store} modal={m} />;
69
+ case "assign": return <AssignModal store={props.store} modal={m} />;
70
+ case "confirm-delete": return <ConfirmDeleteModal store={props.store} modal={m} />;
71
+ case "detail": return <DetailModal store={props.store} modal={m} />;
72
+ case "agent-detail": return <AgentDetailModal store={props.store} modal={m} />;
73
+ case "search": return <SearchModal store={props.store} />;
74
+ case "help": return <HelpModal store={props.store} />;
75
+ }
76
+ }
77
+
78
+ // ─── Common shell ────────────────────────────────────────────────────────────
79
+
80
+ interface DialogShellProps {
81
+ title: string;
82
+ hint?: string;
83
+ children: any;
84
+ width?: number;
85
+ }
86
+
87
+ function DialogShell(props: DialogShellProps) {
88
+ void props.width;
89
+ return (
90
+ <box style={{ flexDirection: "column" }}>
91
+ <text>
92
+ <span style={{ fg: T.accent, attributes: ATTR.bold }}>{props.title}</span>
93
+ </text>
94
+ <box style={{ height: 1 }} />
95
+ {props.children}
96
+ <Show when={props.hint}>
97
+ <text>
98
+ <span style={{ fg: T.textDim }}>{props.hint}</span>
99
+ </text>
100
+ </Show>
101
+ </box>
102
+ );
103
+ }
104
+
105
+ // ─── Add new task ────────────────────────────────────────────────────────────
106
+
107
+ function AddModal(props: { store: TuiStore; columnIndex: number }) {
108
+ const [value, setValue] = createSignal("");
109
+
110
+ function submit(text: string) {
111
+ const trimmed = text.trim();
112
+ if (!trimmed) {
113
+ props.store.closeModal();
114
+ return;
115
+ }
116
+ const board = props.store.state.boards[props.store.state.ui.activeBoardIndex]?.board;
117
+ if (!board) return;
118
+ const parsed = parseQuickAdd(trimmed);
119
+ const ref = props.store.addTask(
120
+ board.filepath,
121
+ props.columnIndex,
122
+ {
123
+ displayTitle: parsed.title || trimmed,
124
+ assignee: parsed.assignee,
125
+ tags: parsed.tags,
126
+ scheduled: parsed.scheduled,
127
+ timeBlock: parsed.timeBlock,
128
+ priority: parsed.priority,
129
+ },
130
+ "top",
131
+ );
132
+ if (ref) {
133
+ props.store.setCursor(props.columnIndex, ref.taskIndex);
134
+ props.store.flashBanner("info", `Added: ${parsed.title || trimmed}`);
135
+ }
136
+ props.store.closeModal();
137
+ }
138
+
139
+ return (
140
+ <DialogShell
141
+ title="New task"
142
+ hint="Quick syntax: @assignee #tag t/tm/+N HH:MM-HH:MM 🔺 · Enter to add, Esc to cancel"
143
+ width={70}
144
+ >
145
+ <input
146
+ focused
147
+ value={value()}
148
+ onInput={(v: string) => setValue(v)}
149
+ onSubmit={((v: string) => submit(v)) as any}
150
+ />
151
+ </DialogShell>
152
+ );
153
+ }
154
+
155
+ // ─── Edit existing ───────────────────────────────────────────────────────────
156
+
157
+ function EditModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "edit" }> }) {
158
+ const task = props.store.getTask(props.modal.ref);
159
+ const [value, setValue] = createSignal(task?.displayTitle ?? "");
160
+
161
+ function submit(text: string) {
162
+ const t = text.trim();
163
+ if (!t) {
164
+ props.store.closeModal();
165
+ return;
166
+ }
167
+ props.store.editDisplayTitle(props.modal.ref, t);
168
+ props.store.closeModal();
169
+ }
170
+
171
+ return (
172
+ <DialogShell
173
+ title="Edit task"
174
+ hint="Enter to save, Esc to cancel"
175
+ width={70}
176
+ >
177
+ <input
178
+ focused
179
+ value={value()}
180
+ onInput={(v: string) => setValue(v)}
181
+ onSubmit={((v: string) => submit(v)) as any}
182
+ />
183
+ </DialogShell>
184
+ );
185
+ }
186
+
187
+ // ─── Schedule date ───────────────────────────────────────────────────────────
188
+
189
+ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "schedule" }> }) {
190
+ const task = props.store.getTask(props.modal.ref);
191
+ const [value, setValue] = createSignal(task?.scheduled ?? "");
192
+ const [error, setError] = createSignal<string | undefined>();
193
+
194
+ function submit(text: string) {
195
+ const d = parseDateShortcut(text);
196
+ if (d === null) {
197
+ setError(`Cannot parse "${text}". Try: t · tm · +3 · lun · 2026-06-15`);
198
+ return;
199
+ }
200
+ props.store.setScheduled(props.modal.ref, d ?? undefined);
201
+ props.store.closeModal();
202
+ }
203
+
204
+ return (
205
+ <DialogShell
206
+ title={`Schedule: ${task?.displayTitle.slice(0, 50) ?? ""}`}
207
+ hint="t = today · tm = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
208
+ width={70}
209
+ >
210
+ <input
211
+ focused
212
+ value={value()}
213
+ onInput={(v: string) => { setValue(v); setError(undefined); }}
214
+ onSubmit={((v: string) => submit(v)) as any}
215
+ />
216
+ <Show when={error()}>
217
+ <text>
218
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
219
+ </text>
220
+ </Show>
221
+ </DialogShell>
222
+ );
223
+ }
224
+
225
+ // ─── Time block ──────────────────────────────────────────────────────────────
226
+
227
+ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "timeblock" }> }) {
228
+ const task = props.store.getTask(props.modal.ref);
229
+ const init = task?.timeBlock
230
+ ? `${fmtMin(task.timeBlock.startMin)}-${fmtMin(task.timeBlock.endMin)}`
231
+ : "";
232
+ const [value, setValue] = createSignal(init);
233
+ const [error, setError] = createSignal<string | undefined>();
234
+
235
+ function submit(text: string) {
236
+ const r = parseTimeBlockShortcut(text);
237
+ if (r === null) {
238
+ setError(`Cannot parse "${text}". Try: n · 9:00 · 9-11 · 09:30-10:45 · - to clear`);
239
+ return;
240
+ }
241
+ props.store.setTimeBlock(props.modal.ref, r ?? undefined);
242
+ props.store.closeModal();
243
+ }
244
+
245
+ return (
246
+ <DialogShell
247
+ title={`Time block: ${task?.displayTitle.slice(0, 50) ?? ""}`}
248
+ hint="n = now+30 · 9:00 · 9-11 · 09:30-10:45 · - to clear · Esc to cancel"
249
+ width={70}
250
+ >
251
+ <input
252
+ focused
253
+ value={value()}
254
+ onInput={(v: string) => { setValue(v); setError(undefined); }}
255
+ onSubmit={((v: string) => submit(v)) as any}
256
+ />
257
+ <Show when={error()}>
258
+ <text>
259
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
260
+ </text>
261
+ </Show>
262
+ </DialogShell>
263
+ );
264
+ }
265
+
266
+ // ─── Assign ──────────────────────────────────────────────────────────────────
267
+
268
+ function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "assign" }> }) {
269
+ const task = props.store.getTask(props.modal.ref);
270
+ const [value, setValue] = createSignal(task?.assignee ?? "");
271
+
272
+ function submit(text: string) {
273
+ const t = text.trim().replace(/^@/, "");
274
+ props.store.setAssignee(props.modal.ref, t || undefined);
275
+ props.store.closeModal();
276
+ }
277
+
278
+ return (
279
+ <DialogShell title="Assignee" hint="Name without @ · empty to clear · Esc to cancel" width={50}>
280
+ <input
281
+ focused
282
+ value={value()}
283
+ onInput={(v: string) => setValue(v)}
284
+ onSubmit={((v: string) => submit(v)) as any}
285
+ />
286
+ </DialogShell>
287
+ );
288
+ }
289
+
290
+ // ─── Confirm delete ──────────────────────────────────────────────────────────
291
+
292
+ function ConfirmDeleteModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "confirm-delete" }> }) {
293
+ const task = props.store.getTask(props.modal.ref);
294
+ return (
295
+ <DialogShell
296
+ title="Delete task?"
297
+ hint="y to confirm · Esc/n to cancel"
298
+ width={70}
299
+ >
300
+ <text>
301
+ <span style={{ fg: T.text }}>{task?.displayTitle ?? "(missing)"}</span>
302
+ </text>
303
+ </DialogShell>
304
+ );
305
+ }
306
+
307
+ // ─── Detail ──────────────────────────────────────────────────────────────────
308
+
309
+ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "detail" }> }) {
310
+ const task = props.store.getTask(props.modal.ref);
311
+ const lb = props.store.getBoardByPath(props.modal.ref.boardPath);
312
+ const column = lb?.board.columns[props.modal.ref.columnIndex];
313
+ if (!task) {
314
+ return <DialogShell title="Task not found" hint="Esc to close" width={50}><text>{" "}</text></DialogShell>;
315
+ }
316
+ return (
317
+ <DialogShell title="Detail" hint="Esc to close" width={90}>
318
+ <text wrapMode="word">
319
+ <span style={{ fg: T.text, attributes: ATTR.bold }}>{task.displayTitle}</span>
320
+ </text>
321
+ <box style={{ height: 1 }} />
322
+ <text>
323
+ <span style={{ fg: T.textDim }}>Board: </span>
324
+ <span style={{ fg: T.text }}>{lb?.board.name ?? "?"} · {column?.name ?? "?"}</span>
325
+ </text>
326
+ <Show when={task.scheduled}>
327
+ <text>
328
+ <span style={{ fg: T.textDim }}>Scheduled: </span>
329
+ <span style={{ fg: T.scheduled }}>⏳ {task.scheduled}</span>
330
+ </text>
331
+ </Show>
332
+ <Show when={task.due}>
333
+ <text>
334
+ <span style={{ fg: T.textDim }}>Due: </span>
335
+ <span style={{ fg: T.scheduled }}>📅 {task.due}</span>
336
+ </text>
337
+ </Show>
338
+ <Show when={task.doneDate}>
339
+ <text>
340
+ <span style={{ fg: T.textDim }}>Done: </span>
341
+ <span style={{ fg: T.textDone }}>✅ {task.doneDate}</span>
342
+ </text>
343
+ </Show>
344
+ <Show when={task.timeBlock}>
345
+ <text>
346
+ <span style={{ fg: T.textDim }}>Time: </span>
347
+ <span style={{ fg: T.time }}>
348
+ ⌚ {fmtMin(task.timeBlock!.startMin)}-{fmtMin(task.timeBlock!.endMin)}
349
+ </span>
350
+ </text>
351
+ </Show>
352
+ <Show when={task.assignee}>
353
+ <text>
354
+ <span style={{ fg: T.textDim }}>Assignee: </span>
355
+ <span style={{ fg: T.assignee }}>@{task.assignee}</span>
356
+ </text>
357
+ </Show>
358
+ <Show when={task.priority !== "none"}>
359
+ <text>
360
+ <span style={{ fg: T.textDim }}>Priority: </span>
361
+ <span style={{ fg: T.highest }}>{task.priority}</span>
362
+ </text>
363
+ </Show>
364
+ <Show when={task.tags.length > 0}>
365
+ <text wrapMode="word">
366
+ <span style={{ fg: T.textDim }}>Tags: </span>
367
+ <span style={{ fg: T.tag }}>{task.tags.map((t) => "#" + t).join(" ")}</span>
368
+ </text>
369
+ </Show>
370
+ <Show when={task.wikilinks.length > 0}>
371
+ <box style={{ height: 1 }} />
372
+ <text>
373
+ <span style={{ fg: T.textDim }}>Wikilinks (open in Obsidian):</span>
374
+ </text>
375
+ <For each={task.wikilinks}>
376
+ {(link) => (
377
+ <text wrapMode="word">
378
+ <span style={{ fg: T.tag }}> → [[{link}]]</span>
379
+ </text>
380
+ )}
381
+ </For>
382
+ </Show>
383
+ </DialogShell>
384
+ );
385
+ }
386
+
387
+ // ─── Search ──────────────────────────────────────────────────────────────────
388
+
389
+ function SearchModal(props: { store: TuiStore }) {
390
+ const [value, setValue] = createSignal("");
391
+
392
+ function submit(text: string) {
393
+ const q = text.trim().toLowerCase();
394
+ if (!q) {
395
+ props.store.closeModal();
396
+ return;
397
+ }
398
+ // First open-task match across boards, in display order.
399
+ const boards = props.store.state.boards;
400
+ for (let bi = 0; bi < boards.length; bi++) {
401
+ const board = boards[bi]!.board;
402
+ for (let ci = 0; ci < board.columns.length; ci++) {
403
+ const col = board.columns[ci]!;
404
+ const allTasks = col.children.filter(isTask);
405
+ const openTasks = props.store.applyBoardFilter(
406
+ allTasks.filter((t) => !t.done),
407
+ );
408
+ const matchIdx = openTasks.findIndex((t) =>
409
+ t.displayTitle.toLowerCase().includes(q),
410
+ );
411
+ if (matchIdx >= 0) {
412
+ props.store.setActiveBoard(bi);
413
+ props.store.setActiveZone("board");
414
+ props.store.setCursor(ci, matchIdx);
415
+ props.store.closeModal();
416
+ props.store.flashBanner(
417
+ "info",
418
+ `Found in [${board.name} · ${col.name}]`,
419
+ );
420
+ return;
421
+ }
422
+ }
423
+ }
424
+ props.store.flashBanner("warn", `No match for "${text}"`);
425
+ props.store.closeModal();
426
+ }
427
+
428
+ return (
429
+ <DialogShell
430
+ title="Search tasks"
431
+ hint="Enter to find first match · Esc to cancel"
432
+ width={70}
433
+ >
434
+ <input
435
+ focused
436
+ value={value()}
437
+ onInput={(v: string) => setValue(v)}
438
+ onSubmit={((v: string) => submit(v)) as any}
439
+ />
440
+ </DialogShell>
441
+ );
442
+ }
443
+
444
+ // ─── Agent detail ────────────────────────────────────────────────────────────
445
+
446
+ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "agent-detail" }> }) {
447
+ const session = createMemo(() =>
448
+ props.store.agents.sessions().find((s) => s.sessionId === props.modal.sessionId),
449
+ );
450
+ return (
451
+ <DialogShell title="Agent session detail" hint="Esc/o to close" width={100}>
452
+ <Show
453
+ when={session()}
454
+ fallback={
455
+ <text>
456
+ <span style={{ fg: T.textDim }}>Session no longer present.</span>
457
+ </text>
458
+ }
459
+ >
460
+ {(s: () => NonNullable<ReturnType<typeof session>>) => (
461
+ <box style={{ flexDirection: "column" }}>
462
+ <text wrapMode="word">
463
+ <span style={{ fg: T.text, attributes: ATTR.bold }}>
464
+ {s().displayName}
465
+ </span>
466
+ </text>
467
+ <box style={{ height: 1 }} />
468
+ <text>
469
+ <span style={{ fg: T.textDim }}>session </span>
470
+ <span style={{ fg: T.accent }}>{s().sessionId}</span>
471
+ </text>
472
+ <text>
473
+ <span style={{ fg: T.textDim }}>status </span>
474
+ <span style={{ fg: T.text }}>{s().status}</span>
475
+ </text>
476
+ <text>
477
+ <span style={{ fg: T.textDim }}>cwd </span>
478
+ <span style={{ fg: T.text }}>{s().cwd}</span>
479
+ </text>
480
+ <Show when={s().gitBranch}>
481
+ <text>
482
+ <span style={{ fg: T.textDim }}>branch </span>
483
+ <span style={{ fg: T.warm }}>{s().gitBranch}</span>
484
+ </text>
485
+ </Show>
486
+ <text>
487
+ <span style={{ fg: T.textDim }}>messages </span>
488
+ <span style={{ fg: T.text }}>
489
+ {s().messageCount} ({s().toolCount} tool uses)
490
+ </span>
491
+ </text>
492
+ <Show when={s().customTitle}>
493
+ <text>
494
+ <span style={{ fg: T.textDim }}>★ name </span>
495
+ <span style={{ fg: T.warm }}>{s().customTitle}</span>
496
+ </text>
497
+ </Show>
498
+ <Show when={s().aiTitle && s().aiTitle !== s().customTitle}>
499
+ <text>
500
+ <span style={{ fg: T.textDim }}>ai title </span>
501
+ <span style={{ fg: T.text }}>{s().aiTitle}</span>
502
+ </text>
503
+ </Show>
504
+ <Show when={s().lastUser}>
505
+ <box style={{ height: 1 }} />
506
+ <text>
507
+ <span style={{ fg: T.textDim }}>last user prompt:</span>
508
+ </text>
509
+ <text wrapMode="word">
510
+ <span style={{ fg: T.text }}>
511
+ {truncate(s().lastUser ?? "", 400)}
512
+ </span>
513
+ </text>
514
+ </Show>
515
+ <Show when={s().lastAssistant}>
516
+ <box style={{ height: 1 }} />
517
+ <text>
518
+ <span style={{ fg: T.textDim }}>last assistant reply:</span>
519
+ </text>
520
+ <text wrapMode="word">
521
+ <span style={{ fg: T.text }}>
522
+ {truncate(s().lastAssistant ?? "", 400)}
523
+ </span>
524
+ </text>
525
+ </Show>
526
+ <box style={{ height: 1 }} />
527
+ <text>
528
+ <span style={{ fg: T.textDim }}>resume command (copy by hand for now):</span>
529
+ </text>
530
+ <text wrapMode="word">
531
+ <span style={{ fg: T.scheduled }}>
532
+ claude --resume {s().sessionId}
533
+ </span>
534
+ </text>
535
+ </box>
536
+ )}
537
+ </Show>
538
+ </DialogShell>
539
+ );
540
+ }
541
+
542
+ function truncate(s: string, n: number): string {
543
+ const trimmed = s.trim();
544
+ if (trimmed.length <= n) return trimmed;
545
+ return trimmed.slice(0, n) + "…";
546
+ }
547
+
548
+ // ─── Help ────────────────────────────────────────────────────────────────────
549
+
550
+ function HelpModal(props: { store: TuiStore }) {
551
+ void props;
552
+ return (
553
+ <DialogShell title="tuiboard — keyboard reference" hint="Esc/? to close" width={92}>
554
+ <text>
555
+ <span style={{ fg: T.textDim }}>{"Navigation\n"}</span>
556
+ <span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
557
+ <span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
558
+ <span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
559
+ <span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow virtual panel focus\n"}</span>
560
+ <span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (virtual → board → timeline → agents)\n"}</span>
561
+ <span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Virtual / Timeline / Agents zones\n"}</span>
562
+ <span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
563
+ <span style={{ fg: T.textDim }}>{"\nTask actions (work in board, virtual, AND timeline zones)\n"}</span>
564
+ <span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
565
+ <span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
566
+ <span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
567
+ <span style={{ fg: T.text }}>{" s Schedule date modal (t/tm/+N/lun/YYYY-MM-DD)\n"}</span>
568
+ <span style={{ fg: T.text }}>{" t Set scheduled = today\n"}</span>
569
+ <span style={{ fg: T.text }}>{" m Set scheduled = tomorrow\n"}</span>
570
+ <span style={{ fg: T.text }}>{" . Schedule now — time block at next 15-min slot (30min)\n"}</span>
571
+ <span style={{ fg: T.text }}>{" b Set time block modal\n"}</span>
572
+ <span style={{ fg: T.text }}>{" p Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)\n"}</span>
573
+ <span style={{ fg: T.text }}>{" a Set assignee\n"}</span>
574
+ <span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
575
+ <span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
576
+ <span style={{ fg: T.text }}>{" c Copy task to clipboard (markdown line)\n"}</span>
577
+ <span style={{ fg: T.text }}>{" C Calendar-arm — arm this task + jump to the timeline\n"}</span>
578
+ <span style={{ fg: T.textDim }}>{"\nTimeline scheduling\n"}</span>
579
+ <span style={{ fg: T.text }}>{" C (any zone) Arm the cursor task, then click a timeline slot to place it\n"}</span>
580
+ <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
581
+ <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
582
+ <span style={{ fg: T.text }}>{" shift+click row While armed (existing block): resize end to that row\n"}</span>
583
+ <span style={{ fg: T.text }}>{" j / k While armed: nudge block ±15 min\n"}</span>
584
+ <span style={{ fg: T.text }}>{" + / - While armed: resize block end ±15 min\n"}</span>
585
+ <span style={{ fg: T.text }}>{" Enter While armed: commit + jump to source task\n"}</span>
586
+ <span style={{ fg: T.text }}>{" Esc Disarm\n"}</span>
587
+ <span style={{ fg: T.textDim }}>{"\nBoard-only actions\n"}</span>
588
+ <span style={{ fg: T.text }}>{" n New task in current column (quick-add syntax)\n"}</span>
589
+ <span style={{ fg: T.text }}>{" g Grab task — h/l then moves it between columns; g/Esc to drop\n"}</span>
590
+ <span style={{ fg: T.text }}>{" f Cycle board filter: all → today → overdue → tomorrow → followup\n"}</span>
591
+ <span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
592
+ <span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
593
+ <span style={{ fg: T.text }}>{" Space Mark / unmark task — single-task actions then\n"}</span>
594
+ <span style={{ fg: T.text }}>{" apply to ALL marked instead of just the cursor\n"}</span>
595
+ <span style={{ fg: T.text }}>{" Esc Clear marks (when no modal is open)\n"}</span>
596
+ <span style={{ fg: T.textDim }}>{"\nBulk\n"}</span>
597
+ <span style={{ fg: T.text }}>{" T Reset ALL overdue tasks (any board) to today\n"}</span>
598
+ <span style={{ fg: T.textDim }}>{"\nGlobal\n"}</span>
599
+ <span style={{ fg: T.text }}>{" Ctrl-Z Undo last mutation\n"}</span>
600
+ <span style={{ fg: T.text }}>{" ? This help\n"}</span>
601
+ <span style={{ fg: T.text }}>{" q · Ctrl-C Quit\n"}</span>
602
+ </text>
603
+ </DialogShell>
604
+ );
605
+ }
606
+
607
+ // ─── helpers ─────────────────────────────────────────────────────────────────
608
+
609
+ function fmtMin(m: number): string {
610
+ const h = Math.floor(m / 60).toString().padStart(2, "0");
611
+ const mm = (m % 60).toString().padStart(2, "0");
612
+ return `${h}:${mm}`;
613
+ }