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.
- package/.tuiboard/config.example.yaml +32 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bin/tuiboard.ts +28 -0
- package/package.json +62 -0
- package/src/app.tsx +129 -0
- package/src/cli/args.test.ts +40 -0
- package/src/cli/args.ts +41 -0
- package/src/config/loader.ts +169 -0
- package/src/input/handleKey.ts +733 -0
- package/src/io/watcher.ts +85 -0
- package/src/io/writer.ts +92 -0
- package/src/parser/markdown.ts +351 -0
- package/src/parser/serialize.ts +97 -0
- package/src/scripts/agents-check.ts +24 -0
- package/src/scripts/parse-check.ts +124 -0
- package/src/scripts/roundtrip-check.ts +79 -0
- package/src/store/agents.test.ts +181 -0
- package/src/store/agents.ts +435 -0
- package/src/store/index.test.ts +110 -0
- package/src/store/index.ts +972 -0
- package/src/store/parsers.ts +243 -0
- package/src/store/timeline.test.ts +279 -0
- package/src/store/timeline.ts +279 -0
- package/src/store/virtual-panel.ts +0 -0
- package/src/types.ts +116 -0
- package/src/ui/AgentRow.tsx +79 -0
- package/src/ui/AgentsBar.tsx +102 -0
- package/src/ui/BoardView.tsx +333 -0
- package/src/ui/Chrome.tsx +122 -0
- package/src/ui/Modal.tsx +613 -0
- package/src/ui/TaskRow.tsx +240 -0
- package/src/ui/TimelineView.tsx +643 -0
- package/src/ui/VirtualPanel.tsx +237 -0
- package/src/ui/board-scroll.test.ts +63 -0
- package/src/ui/board-scroll.ts +49 -0
- package/src/ui/glyphs.ts +129 -0
- package/src/views/AgentsOnly.tsx +103 -0
- package/src/views/BoardOnly.tsx +35 -0
- package/src/views/Dashboard.tsx +106 -0
- 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
|
+
}
|