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,733 @@
1
+ /**
2
+ * Centralized keyboard input handler. Dispatches based on:
3
+ * 1. modal state (modal eats most keys)
4
+ * 2. global keys (quit, help, undo, board switch, escape, zone cycle)
5
+ * 3. active zone (virtual / board / timeline / agents)
6
+ *
7
+ * Task-level actions (`t`/`m`/`s`/`b`/`a`/`o`/`Space`/`X`/`d`/`p`/`.`/`Enter`)
8
+ * all route through `dispatchTaskAction`, so they work identically on a
9
+ * cursor task whether you reach it via the kanban board, the virtual
10
+ * panel, or a timeline block.
11
+ *
12
+ * Extracted from app.tsx so every root view (Dashboard, BoardOnly, etc.)
13
+ * shares the same input contract.
14
+ */
15
+
16
+ import { isHiddenColumn } from "~/config/loader";
17
+ import { isTask } from "~/parser/markdown";
18
+ import {
19
+ isoToday,
20
+ isoTomorrow,
21
+ type ModalKind,
22
+ type TaskRef,
23
+ type TuiStore,
24
+ } from "~/store/index";
25
+ import type { Board, PriorityLevel } from "~/types";
26
+ import { buildTimelineEntries } from "~/store/timeline";
27
+ import { buildVirtualItems } from "~/store/virtual-panel";
28
+ import { jumpToKanban } from "~/ui/TimelineView";
29
+
30
+ interface KeyEvent {
31
+ name: string;
32
+ ctrl?: boolean;
33
+ shift?: boolean;
34
+ sequence?: string;
35
+ }
36
+
37
+ export function handleKey(
38
+ store: TuiStore,
39
+ key: KeyEvent,
40
+ virtualCount: number,
41
+ ): void {
42
+ const ui = store.state.ui;
43
+ const board = store.state.boards[ui.activeBoardIndex]?.board;
44
+
45
+ // Modal dispatcher first — most keys go to the modal's <input>.
46
+ if (ui.modal) {
47
+ if (key.name === "escape") {
48
+ store.closeModal();
49
+ return;
50
+ }
51
+ if (ui.modal.kind === "confirm-delete") {
52
+ if (key.name === "y") {
53
+ const ref = ui.modal.ref;
54
+ store.deleteTask(ref);
55
+ store.closeModal();
56
+ } else if (key.name === "n") {
57
+ store.closeModal();
58
+ }
59
+ return;
60
+ }
61
+ if ((ui.modal.kind === "help" ||
62
+ ui.modal.kind === "detail" ||
63
+ ui.modal.kind === "agent-detail") &&
64
+ (key.name === "?" || key.sequence === "?" || key.name === "o")) {
65
+ store.closeModal();
66
+ return;
67
+ }
68
+ return;
69
+ }
70
+
71
+ // Quit
72
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
73
+ store.dispose().finally(() => process.exit(0));
74
+ return;
75
+ }
76
+
77
+ // Escape priority: timeline arm → grab mode → marks. Most disruptive first.
78
+ if (key.name === "escape") {
79
+ if (ui.armedTimelineRef) {
80
+ store.armTimeline(undefined);
81
+ store.flashBanner("info", "Disarmed");
82
+ return;
83
+ }
84
+ if (ui.grabbing) {
85
+ store.exitGrab();
86
+ store.flashBanner("info", "Grab released");
87
+ return;
88
+ }
89
+ if (Object.keys(ui.marked).length > 0) {
90
+ store.clearMarks();
91
+ store.flashBanner("info", "Selection cleared");
92
+ }
93
+ return;
94
+ }
95
+
96
+ // Help
97
+ if (key.name === "?" || key.sequence === "?") {
98
+ store.openModal({ kind: "help" });
99
+ return;
100
+ }
101
+
102
+ // Search — opens a modal that jumps the kanban cursor to the first
103
+ // matching open task. Available globally so users can `/` from any
104
+ // zone. OpenTUI may name the key `slash` or pass `/` as sequence,
105
+ // depending on terminal — accept all common variants.
106
+ if (key.name === "slash" || key.name === "/" || key.sequence === "/") {
107
+ setTimeout(() => store.openModal({ kind: "search" }), 0);
108
+ return;
109
+ }
110
+
111
+ // Bulk: reset all overdue across all boards → today (Shift+T).
112
+ if (key.name === "t" && key.shift) {
113
+ const n = store.resetAllOverdueToToday();
114
+ store.flashBanner("info", n > 0 ? `Reset ${n} overdue → today` : "No overdue tasks");
115
+ return;
116
+ }
117
+
118
+ // Shift+Tab cycles the active dashboard zone (skips hidden zones).
119
+ if (key.name === "tab" && key.shift) {
120
+ store.cycleActiveZone();
121
+ return;
122
+ }
123
+
124
+ // F1/F2/F3 toggle zone visibility. Board cannot be hidden.
125
+ if (key.name === "f1") {
126
+ store.setZoneVisible("virtual", !ui.visibleZones.virtual);
127
+ return;
128
+ }
129
+ if (key.name === "f2") {
130
+ store.setZoneVisible("timeline", !ui.visibleZones.timeline);
131
+ return;
132
+ }
133
+ if (key.name === "f3") {
134
+ store.setZoneVisible("agents", !ui.visibleZones.agents);
135
+ return;
136
+ }
137
+
138
+ // Cycle boards
139
+ if (key.name === "tab") {
140
+ store.setActiveBoard(ui.activeBoardIndex + 1);
141
+ return;
142
+ }
143
+ if (/^[1-9]$/.test(key.name)) {
144
+ const i = parseInt(key.name, 10) - 1;
145
+ if (i < store.state.boards.length) store.setActiveBoard(i);
146
+ return;
147
+ }
148
+
149
+ // Switch in/out of virtual panel with `v`
150
+ if (key.name === "v") {
151
+ store.setActiveZone(ui.activeZone === "virtual" ? "board" : "virtual");
152
+ return;
153
+ }
154
+
155
+ // Cycle the board filter — affects which open tasks show up in board
156
+ // columns. Mirrors Python kanban `action_cycle_filter`. Cycle order:
157
+ // all → today → overdue → tomorrow → followup → all.
158
+ if (key.name === "f") {
159
+ const cycle = ["all", "today", "overdue", "tomorrow", "followup"] as const;
160
+ const idx = cycle.indexOf(ui.filter);
161
+ const next = cycle[(idx + 1) % cycle.length]!;
162
+ store.setFilter(next);
163
+ store.flashBanner("info", `Filter: ${next}`);
164
+ return;
165
+ }
166
+
167
+ // Undo
168
+ if (key.ctrl && key.name === "z") {
169
+ store.undo();
170
+ return;
171
+ }
172
+
173
+ // Zoom toggle: focus the active panel (board column or virtual panel)
174
+ // at full width.
175
+ if (key.name === "z") {
176
+ store.toggleZoom();
177
+ return;
178
+ }
179
+
180
+ // Defer modal opens by one macrotask so the OpenTUI <input> mounts after
181
+ // the current key event has been fully dispatched.
182
+ const openLater = (m: ModalKind) => {
183
+ setTimeout(() => store.openModal(m), 0);
184
+ };
185
+
186
+ // ─── Per-zone dispatching ───────────────────────────────────────────────
187
+
188
+ if (ui.activeZone === "virtual") {
189
+ handleVirtualZone(store, key, virtualCount, openLater);
190
+ return;
191
+ }
192
+
193
+ if (ui.activeZone === "timeline") {
194
+ handleTimelineZone(store, key, openLater);
195
+ return;
196
+ }
197
+
198
+ if (ui.activeZone === "agents") {
199
+ handleAgentsZone(store, key);
200
+ return;
201
+ }
202
+
203
+ // Inside a board
204
+ if (!board) return;
205
+ handleBoardZone(store, key, openLater);
206
+ }
207
+
208
+ // ─── Zone handlers ──────────────────────────────────────────────────────────
209
+
210
+ function handleVirtualZone(
211
+ store: TuiStore,
212
+ key: KeyEvent,
213
+ virtualCount: number,
214
+ openLater: (m: ModalKind) => void,
215
+ ): void {
216
+ const ui = store.state.ui;
217
+ const items = buildVirtualItems(store.state.boards.map((b) => b.board));
218
+ const target = items[ui.row];
219
+
220
+ // Navigation
221
+ if (key.name === "j" || key.name === "down") {
222
+ store.setCursor(ui.col, Math.min(virtualCount - 1, ui.row + 1));
223
+ return;
224
+ }
225
+ if (key.name === "k" || key.name === "up") {
226
+ store.setCursor(ui.col, Math.max(0, ui.row - 1));
227
+ return;
228
+ }
229
+ if (key.name === "l" || key.name === "right") {
230
+ store.setActiveZone("board");
231
+ return;
232
+ }
233
+
234
+ // Task actions on the virtual cursor's target (works cross-board).
235
+ if (target) {
236
+ dispatchTaskAction(store, key, target.ref, openLater);
237
+ }
238
+ }
239
+
240
+ function handleTimelineZone(
241
+ store: TuiStore,
242
+ key: KeyEvent,
243
+ openLater: (m: ModalKind) => void,
244
+ ): void {
245
+ const ui = store.state.ui;
246
+ const entries = buildTimelineEntries(
247
+ store.state.boards.map((b) => b.board),
248
+ isoToday(),
249
+ );
250
+ const target = entries[ui.row];
251
+
252
+ // Armed-block adjustments take priority over navigation. While a block
253
+ // is armed, j/k nudge its start time and +/- nudge its end.
254
+ const armedRef = ui.armedTimelineRef;
255
+ const armed = armedRef
256
+ ? entries.find(
257
+ (e) =>
258
+ e.ref.boardPath === armedRef.boardPath &&
259
+ e.ref.columnIndex === armedRef.columnIndex &&
260
+ e.ref.taskIndex === armedRef.taskIndex,
261
+ )
262
+ : undefined;
263
+
264
+ if (armed) {
265
+ const NUDGE = 15; // minutes
266
+ if (key.name === "j" || key.name === "down") {
267
+ const newStart = Math.min(24 * 60 - 1 - (armed.endMin - armed.startMin), armed.startMin + NUDGE);
268
+ const newEnd = newStart + (armed.endMin - armed.startMin);
269
+ store.setTimeBlock(armed.ref, { startMin: newStart, endMin: newEnd });
270
+ store.flashBanner("info", `✋ ${fmtHm(newStart)}-${fmtHm(newEnd)}`);
271
+ return;
272
+ }
273
+ if (key.name === "k" || key.name === "up") {
274
+ const newStart = Math.max(0, armed.startMin - NUDGE);
275
+ const newEnd = newStart + (armed.endMin - armed.startMin);
276
+ store.setTimeBlock(armed.ref, { startMin: newStart, endMin: newEnd });
277
+ store.flashBanner("info", `✋ ${fmtHm(newStart)}-${fmtHm(newEnd)}`);
278
+ return;
279
+ }
280
+ if (key.name === "+" || key.name === "=" || key.sequence === "+") {
281
+ const newEnd = Math.min(24 * 60 - 1, armed.endMin + NUDGE);
282
+ store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
283
+ store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
284
+ return;
285
+ }
286
+ if (key.name === "-" || key.name === "_" || key.sequence === "-") {
287
+ const newEnd = Math.max(armed.startMin + 15, armed.endMin - NUDGE);
288
+ store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
289
+ store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
290
+ return;
291
+ }
292
+ if (key.name === "enter" || key.name === "return") {
293
+ // Commit + jump to kanban + disarm.
294
+ store.armTimeline(undefined);
295
+ jumpToKanban(store, armed.ref);
296
+ return;
297
+ }
298
+ // Fall through for other keys (Esc handled globally, task actions below).
299
+ }
300
+
301
+ // Plain navigation (no armed block, or non-adjustment key while armed).
302
+ if (key.name === "j" || key.name === "down") {
303
+ store.setCursor(0, Math.min(entries.length - 1, ui.row + 1));
304
+ return;
305
+ }
306
+ if (key.name === "k" || key.name === "up") {
307
+ store.setCursor(0, Math.max(0, ui.row - 1));
308
+ return;
309
+ }
310
+ if (key.name === "h" || key.name === "left") {
311
+ store.setActiveZone("board");
312
+ return;
313
+ }
314
+ // Enter on a timeline block bounces the kanban cursor to its source task.
315
+ if ((key.name === "enter" || key.name === "return") && target) {
316
+ jumpToKanban(store, target.ref);
317
+ return;
318
+ }
319
+
320
+ // All other task actions operate on the timeline entry's task.
321
+ if (target) {
322
+ dispatchTaskAction(store, key, target.ref, openLater);
323
+ }
324
+ }
325
+
326
+ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
327
+ const ui = store.state.ui;
328
+ const sessions = store.agents.sessions();
329
+ if (key.name === "j" || key.name === "down") {
330
+ store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
331
+ } else if (key.name === "k" || key.name === "up") {
332
+ store.setCursor(0, Math.max(0, ui.row - 1));
333
+ } else if (
334
+ key.name === "enter" ||
335
+ key.name === "return" ||
336
+ key.name === "o"
337
+ ) {
338
+ const target = sessions[ui.row];
339
+ if (target) {
340
+ setTimeout(
341
+ () => store.openModal({ kind: "agent-detail", sessionId: target.sessionId }),
342
+ 0,
343
+ );
344
+ }
345
+ } else if (key.name === "h" || key.name === "left") {
346
+ store.setActiveZone("board");
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Find the next/previous *rendered* column index moving in `dir` (+1 right,
352
+ * -1 left) from `fromCol`, skipping hidden columns (Done / Archive) that
353
+ * BoardView never displays. Returns a `board.columns` index, or undefined
354
+ * when there is no further visible column in that direction.
355
+ */
356
+ function adjacentVisibleColumn(
357
+ store: TuiStore,
358
+ board: Board,
359
+ fromCol: number,
360
+ dir: 1 | -1,
361
+ ): number | undefined {
362
+ for (let i = fromCol + dir; i >= 0 && i < board.columns.length; i += dir) {
363
+ const name = board.columns[i]?.name;
364
+ if (name !== undefined && !isHiddenColumn(store.config, name)) return i;
365
+ }
366
+ return undefined;
367
+ }
368
+
369
+ function handleBoardZone(
370
+ store: TuiStore,
371
+ key: KeyEvent,
372
+ openLater: (m: ModalKind) => void,
373
+ ): void {
374
+ const ui = store.state.ui;
375
+ const board = store.state.boards[ui.activeBoardIndex]?.board;
376
+ if (!board) return;
377
+ const col = board.columns[ui.col];
378
+ if (!col) return;
379
+
380
+ const allTasks = col.children.filter(isTask);
381
+ // Open tasks pass through the same filter the board view applies — so
382
+ // the cursor row index always lines up with the rendered list, even
383
+ // when `f` has narrowed it to today/overdue/etc.
384
+ const openTasks = store.applyBoardFilter(allTasks.filter((t) => !t.done));
385
+ // Visible task list mirrors what the column renders: in zoom mode the
386
+ // user can navigate into done tasks too; otherwise only open.
387
+ const visibleTasks = ui.zoomed
388
+ ? [...openTasks, ...allTasks.filter((t) => t.done)]
389
+ : openTasks;
390
+
391
+ // Navigation
392
+ if (key.name === "j" || key.name === "down") {
393
+ store.setCursor(ui.col, Math.min(visibleTasks.length - 1, ui.row + 1));
394
+ return;
395
+ }
396
+ if (key.name === "k" || key.name === "up") {
397
+ store.setCursor(ui.col, Math.max(0, ui.row - 1));
398
+ return;
399
+ }
400
+ // Cursor task (computed early so grab mode can use it for h/l moves).
401
+ const cursorTaskForGrab = visibleTasks[ui.row];
402
+ const cursorRefForGrab: TaskRef | undefined = cursorTaskForGrab
403
+ ? {
404
+ boardPath: board.filepath,
405
+ columnIndex: ui.col,
406
+ taskIndex: allTasks.indexOf(cursorTaskForGrab),
407
+ }
408
+ : undefined;
409
+
410
+ // Grab mode: h/l physically MOVES the task to the adjacent column.
411
+ // Cursor follows the moved task. Other navigation keys behave normally
412
+ // (j/k still scrolls cursor within column; user releases grab with `g`
413
+ // or Esc).
414
+ if (ui.grabbing && cursorRefForGrab) {
415
+ if (key.name === "h" || key.name === "left") {
416
+ if (ui.col > 0) {
417
+ const newRef = store.moveTaskWithinBoard(cursorRefForGrab, ui.col - 1, "top");
418
+ if (newRef) {
419
+ // Cursor goes to the moved task's new visible-row position in
420
+ // the destination column.
421
+ const destCol = board.columns[newRef.columnIndex];
422
+ if (destCol) {
423
+ const opens = store.applyBoardFilter(
424
+ destCol.children.filter(isTask).filter((t) => !t.done),
425
+ );
426
+ const moved = destCol.children.filter(isTask)[newRef.taskIndex];
427
+ const newRow = moved ? opens.indexOf(moved) : 0;
428
+ store.setCursor(newRef.columnIndex, Math.max(0, newRow));
429
+ }
430
+ }
431
+ }
432
+ return;
433
+ }
434
+ if (key.name === "l" || key.name === "right") {
435
+ if (ui.col < board.columns.length - 1) {
436
+ const newRef = store.moveTaskWithinBoard(cursorRefForGrab, ui.col + 1, "top");
437
+ if (newRef) {
438
+ const destCol = board.columns[newRef.columnIndex];
439
+ if (destCol) {
440
+ const opens = store.applyBoardFilter(
441
+ destCol.children.filter(isTask).filter((t) => !t.done),
442
+ );
443
+ const moved = destCol.children.filter(isTask)[newRef.taskIndex];
444
+ const newRow = moved ? opens.indexOf(moved) : 0;
445
+ store.setCursor(newRef.columnIndex, Math.max(0, newRow));
446
+ }
447
+ }
448
+ }
449
+ return;
450
+ }
451
+ }
452
+
453
+ if (key.name === "h" || key.name === "left") {
454
+ // Step left over rendered columns. Hidden columns (Done / Archive) are
455
+ // never displayed, so navigating onto one would strand the cursor on an
456
+ // unrendered, unscrollable column.
457
+ const prev = adjacentVisibleColumn(store, board, ui.col, -1);
458
+ if (prev === undefined) {
459
+ store.setActiveZone("virtual");
460
+ } else {
461
+ store.setCursor(prev, 0);
462
+ }
463
+ return;
464
+ }
465
+ if (key.name === "l" || key.name === "right") {
466
+ const next = adjacentVisibleColumn(store, board, ui.col, +1);
467
+ if (next !== undefined) {
468
+ store.setCursor(next, 0);
469
+ }
470
+ return;
471
+ }
472
+
473
+ // `g` toggles grab mode. Only meaningful in board zone with a task under
474
+ // the cursor.
475
+ if (key.name === "g") {
476
+ if (ui.grabbing) {
477
+ store.exitGrab();
478
+ store.flashBanner("info", "Grab released");
479
+ } else if (cursorRefForGrab) {
480
+ store.toggleGrab();
481
+ store.flashBanner("info", "Grabbed — h/l moves between columns, Esc to release");
482
+ }
483
+ return;
484
+ }
485
+
486
+ // `n` adds a new task in the current column — this needs a column context
487
+ // which only the board zone has, so it lives outside dispatchTaskAction.
488
+ if (key.name === "n") {
489
+ openLater({ kind: "add", targetColumnIndex: ui.col });
490
+ return;
491
+ }
492
+
493
+ // Task-level actions need a task under the cursor.
494
+ const cursorTask = visibleTasks[ui.row];
495
+ if (!cursorTask) return;
496
+ const cursorRef: TaskRef = {
497
+ boardPath: board.filepath,
498
+ columnIndex: ui.col,
499
+ taskIndex: allTasks.indexOf(cursorTask),
500
+ };
501
+
502
+ dispatchTaskAction(store, key, cursorRef, openLater);
503
+ }
504
+
505
+ // ─── Shared task-level action dispatcher ────────────────────────────────────
506
+
507
+ /**
508
+ * Apply a task-level action to the given cursorRef. Used by every zone
509
+ * (board / virtual / timeline) so the keys feel identical wherever the
510
+ * cursor lives. Multi-select aware: when there are marked tasks, the
511
+ * action operates on all of them via `applyToMarkedOr`.
512
+ *
513
+ * Returns true when the key was recognized as a task action (whether or
514
+ * not it produced a visible change). Callers can ignore the return value
515
+ * — this is mostly a contract for future composition.
516
+ */
517
+ function dispatchTaskAction(
518
+ store: TuiStore,
519
+ key: KeyEvent,
520
+ ref: TaskRef,
521
+ openLater: (m: ModalKind) => void,
522
+ ): boolean {
523
+ // Toggle done (Enter). Only meaningful for board/virtual; timeline has
524
+ // its own Enter behavior (jump to kanban) which is handled earlier.
525
+ if (key.name === "enter" || key.name === "return") {
526
+ const n = store.applyToMarkedOr(ref, (r) => store.toggleDone(r));
527
+ if (n > 1) store.flashBanner("info", `Toggled done on ${n} tasks`);
528
+ return true;
529
+ }
530
+
531
+ // Multi-select toggle
532
+ if (key.name === "space") {
533
+ store.toggleMark(ref);
534
+ return true;
535
+ }
536
+
537
+ // Detail
538
+ if (key.name === "o") {
539
+ openLater({ kind: "detail", ref });
540
+ return true;
541
+ }
542
+
543
+ // Quick set scheduled = today / tomorrow
544
+ if (key.name === "t" && !key.shift) {
545
+ const n = store.applyToMarkedOr(ref, (r) => store.setScheduled(r, isoToday()));
546
+ if (n > 1) store.flashBanner("info", `${n} tasks → today`);
547
+ return true;
548
+ }
549
+ if (key.name === "m") {
550
+ const n = store.applyToMarkedOr(ref, (r) => store.setScheduled(r, isoTomorrow()));
551
+ if (n > 1) store.flashBanner("info", `${n} tasks → tomorrow`);
552
+ return true;
553
+ }
554
+
555
+ // Archive (Shift+X) — move to Archive column (creates it if absent).
556
+ if (key.name === "x" && key.shift) {
557
+ const n = store.applyToMarkedOr(ref, (r) => { store.archiveTask(r); });
558
+ store.flashBanner("info", n > 1 ? `Archived ${n} tasks` : "Archived");
559
+ return true;
560
+ }
561
+
562
+ // Calendar-arm (Shift+C): arm this task for the timeline and jump focus
563
+ // there so the user can immediately click a slot to place it. Works from
564
+ // the board, the virtual panel, or the timeline — wherever the cursor is.
565
+ // Replaces the removed sticky 'Unscheduled' list: instead of duplicating
566
+ // today's tasks at the top of the timeline, arm one in place and drop it.
567
+ if (key.name === "C" || (key.name === "c" && key.shift)) {
568
+ store.armTimeline(ref);
569
+ store.setZoneVisible("timeline", true);
570
+ store.setActiveZone("timeline");
571
+ const t = store.getTask(ref);
572
+ store.flashBanner(
573
+ "info",
574
+ t
575
+ ? `⤤ Armed "${t.displayTitle.slice(0, 32)}" — click a timeline slot to place`
576
+ : "⤤ Armed — click a timeline slot to place",
577
+ );
578
+ return true;
579
+ }
580
+
581
+ // Copy task as a markdown line to the system clipboard. Mirrors Python
582
+ // kanban `action_copy_context`. Single-task only (multi-select would
583
+ // require deciding how to join lines). Explicitly non-shift so Shift+C
584
+ // (calendar-arm, above) doesn't also trigger a clipboard copy.
585
+ if (key.name === "c" && !key.shift) {
586
+ const t = store.getTask(ref);
587
+ if (t) {
588
+ copyToClipboard(t.rawLine).then(
589
+ () => store.flashBanner("info", "📋 Copied task"),
590
+ (err) => store.flashBanner("error", `Copy failed: ${err}`),
591
+ );
592
+ }
593
+ return true;
594
+ }
595
+
596
+ // Toggle priority — cycle: none → highest → high → medium → low → lowest → none.
597
+ // Mirrors Python kanban `action_toggle_priority`.
598
+ if (key.name === "p") {
599
+ const n = store.applyToMarkedOr(ref, (r) => {
600
+ const t = store.getTask(r);
601
+ if (!t) return;
602
+ store.setPriority(r, nextPriority(t.priority));
603
+ });
604
+ if (n > 1) store.flashBanner("info", `Priority cycled on ${n} tasks`);
605
+ return true;
606
+ }
607
+
608
+ // Schedule now: time block at the next 15-min slot, 30min default duration.
609
+ // Mirrors Python kanban `action_schedule_now`. Also forces scheduled=today
610
+ // since a time block without a date is meaningless to Day Planner.
611
+ if (key.name === "." || key.sequence === ".") {
612
+ const { startMin, endMin } = nextNowBlock();
613
+ const n = store.applyToMarkedOr(ref, (r) => {
614
+ store.setScheduled(r, isoToday());
615
+ store.setTimeBlock(r, { startMin, endMin });
616
+ });
617
+ const label = `${fmtHm(startMin)}-${fmtHm(endMin)}`;
618
+ store.flashBanner("info", n > 1 ? `${n} tasks ⌚${label}` : `⌚${label}`);
619
+ return true;
620
+ }
621
+
622
+ // Modals
623
+ if (key.name === "e") {
624
+ openLater({ kind: "edit", ref });
625
+ return true;
626
+ }
627
+ if (key.name === "s") {
628
+ openLater({ kind: "schedule", ref });
629
+ return true;
630
+ }
631
+ if (key.name === "b") {
632
+ openLater({ kind: "timeblock", ref });
633
+ return true;
634
+ }
635
+ if (key.name === "a") {
636
+ openLater({ kind: "assign", ref });
637
+ return true;
638
+ }
639
+ if (key.name === "d") {
640
+ openLater({ kind: "confirm-delete", ref });
641
+ return true;
642
+ }
643
+
644
+ return false;
645
+ }
646
+
647
+ // ─── Helpers ────────────────────────────────────────────────────────────────
648
+
649
+ const PRIORITY_CYCLE: PriorityLevel[] = [
650
+ "none",
651
+ "highest",
652
+ "high",
653
+ "medium",
654
+ "low",
655
+ "lowest",
656
+ ];
657
+
658
+ function nextPriority(p: PriorityLevel): PriorityLevel {
659
+ const i = PRIORITY_CYCLE.indexOf(p);
660
+ if (i < 0) return "highest";
661
+ return PRIORITY_CYCLE[(i + 1) % PRIORITY_CYCLE.length]!;
662
+ }
663
+
664
+ /**
665
+ * Round the current minute up to the nearest 15-minute slot, return a
666
+ * 30-minute time block starting there.
667
+ *
668
+ * 12:03 → 12:15-12:45
669
+ * 12:14 → 12:15-12:45
670
+ * 12:16 → 12:30-13:00
671
+ */
672
+ function nextNowBlock(): { startMin: number; endMin: number } {
673
+ const d = new Date();
674
+ const minutes = d.getHours() * 60 + d.getMinutes();
675
+ const slot = Math.ceil(minutes / 15) * 15;
676
+ return { startMin: slot, endMin: Math.min(slot + 30, 24 * 60 - 1) };
677
+ }
678
+
679
+ function fmtHm(m: number): string {
680
+ const h = Math.floor(m / 60) % 24;
681
+ const mm = m % 60;
682
+ return `${h.toString().padStart(2, "0")}:${mm.toString().padStart(2, "0")}`;
683
+ }
684
+
685
+ /**
686
+ * Cross-platform clipboard copy. Picks the host's native cli tool:
687
+ * Windows → clip
688
+ * macOS → pbcopy
689
+ * Linux → wl-copy (Wayland) with xclip fallback (X11)
690
+ *
691
+ * Returns a Promise that resolves on success and rejects with the stderr
692
+ * output of the failing command. We swallow ENOENT (tool not installed) and
693
+ * surface the same banner message either way.
694
+ */
695
+ async function copyToClipboard(text: string): Promise<void> {
696
+ const { spawn } = await import("node:child_process");
697
+ const platform = process.platform;
698
+ const candidates: Array<{ cmd: string; args: string[] }> =
699
+ platform === "win32"
700
+ ? [{ cmd: "clip", args: [] }]
701
+ : platform === "darwin"
702
+ ? [{ cmd: "pbcopy", args: [] }]
703
+ : [
704
+ { cmd: "wl-copy", args: [] },
705
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
706
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
707
+ ];
708
+
709
+ let lastError: string = "no clipboard tool found";
710
+ for (const { cmd, args } of candidates) {
711
+ try {
712
+ await new Promise<void>((resolve, reject) => {
713
+ const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
714
+ let stderr = "";
715
+ child.stderr?.on("data", (chunk: Buffer) => {
716
+ stderr += chunk.toString();
717
+ });
718
+ child.on("error", (e: NodeJS.ErrnoException) => reject(e.message));
719
+ child.on("close", (code: number) => {
720
+ if (code === 0) resolve();
721
+ else reject(stderr || `${cmd} exited ${code}`);
722
+ });
723
+ child.stdin?.write(text);
724
+ child.stdin?.end();
725
+ });
726
+ return; // Success — done.
727
+ } catch (e) {
728
+ lastError = String(e);
729
+ // Try next candidate.
730
+ }
731
+ }
732
+ throw new Error(lastError);
733
+ }