tuiboard 0.8.5 → 0.9.1
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/CHANGELOG.md +63 -0
- package/README.md +37 -1
- package/bin/tuiboard.ts +4 -0
- package/package.json +3 -1
- package/src/app.tsx +36 -17
- package/src/boards/config-writer.ts +191 -0
- package/src/boards/create.ts +57 -0
- package/src/boards/scan.ts +95 -0
- package/src/boards/suggest.ts +34 -0
- package/src/cli/args.ts +2 -2
- package/src/cli/board.ts +151 -0
- package/src/config/loader.ts +37 -13
- package/src/input/handleKey.ts +58 -1
- package/src/io/watcher.ts +11 -0
- package/src/parser/markdown.ts +10 -0
- package/src/store/index.ts +379 -12
- package/src/ui/BoardView.tsx +8 -6
- package/src/ui/Chrome.tsx +65 -9
- package/src/ui/Modal.tsx +167 -4
- package/src/ui/PlannerPanel.tsx +1 -1
- package/src/ui/pane-ring.ts +73 -0
- package/src/views/BoardOnly.tsx +3 -2
- package/src/views/Dashboard.tsx +11 -27
- package/src/cli/args.test.ts +0 -40
- package/src/cli/headless.test.ts +0 -202
- package/src/scripts/agents-check.ts +0 -24
- package/src/scripts/parse-check.ts +0 -124
- package/src/scripts/roundtrip-check.ts +0 -79
- package/src/store/agents.test.ts +0 -181
- package/src/store/index.test.ts +0 -225
- package/src/store/parsers.test.ts +0 -64
- package/src/store/timeline.test.ts +0 -332
- package/src/ui/board-scroll.test.ts +0 -63
- package/src/ui/glyphs.test.ts +0 -31
package/src/cli/board.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board lifecycle from the command line.
|
|
3
|
+
*
|
|
4
|
+
* tuiboard board add --path <file.md> [--name <n>] [--columns "A,B,C"] [--dry-run]
|
|
5
|
+
* tuiboard board scan <dir>
|
|
6
|
+
* tuiboard board list
|
|
7
|
+
*
|
|
8
|
+
* Same three operations the TUI's `+` performs, over the same `src/boards/`
|
|
9
|
+
* functions. That is deliberate: if the modal and this file share everything
|
|
10
|
+
* but their input, the subsystem is genuinely decoupled from the renderer —
|
|
11
|
+
* and a board can be created from a script, a bar widget, or an agent.
|
|
12
|
+
*
|
|
13
|
+
* `add` creates the file when it is missing and registers it either way, so
|
|
14
|
+
* adopting an existing board and making a new one are the same command.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { basename, extname, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { addBoardToConfig } from "~/boards/config-writer";
|
|
21
|
+
import { createBoardFile, DEFAULT_COLUMNS } from "~/boards/create";
|
|
22
|
+
import { scanDirectory } from "~/boards/scan";
|
|
23
|
+
import { suggestBoardsDir } from "~/boards/suggest";
|
|
24
|
+
import { loadConfig } from "~/config/loader";
|
|
25
|
+
|
|
26
|
+
interface Args {
|
|
27
|
+
path?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
columns?: string[];
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
rest: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parse(argv: readonly string[]): Args {
|
|
35
|
+
const a: Args = { dryRun: false, rest: [] };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const arg = argv[i]!;
|
|
38
|
+
const take = () => argv[++i];
|
|
39
|
+
if (arg === "--path") a.path = take();
|
|
40
|
+
else if (arg === "--name") a.name = take();
|
|
41
|
+
else if (arg === "--columns") a.columns = splitColumns(take());
|
|
42
|
+
else if (arg === "--dry-run") a.dryRun = true;
|
|
43
|
+
else if (arg.startsWith("--path=")) a.path = arg.slice(7);
|
|
44
|
+
else if (arg.startsWith("--name=")) a.name = arg.slice(7);
|
|
45
|
+
else if (arg.startsWith("--columns=")) a.columns = splitColumns(arg.slice(10));
|
|
46
|
+
else if (arg.startsWith("--")) throw new Error(`unknown argument "${arg}"`);
|
|
47
|
+
else a.rest.push(arg);
|
|
48
|
+
}
|
|
49
|
+
return a;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitColumns(value: string | undefined): string[] {
|
|
53
|
+
return (value ?? "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((c) => c.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runBoard(argv: readonly string[]): Promise<number> {
|
|
60
|
+
const sub = argv[0];
|
|
61
|
+
if (sub !== "add" && sub !== "scan" && sub !== "list") {
|
|
62
|
+
console.error("usage: tuiboard board <add|scan|list> [options]");
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let a: Args;
|
|
67
|
+
try {
|
|
68
|
+
a = parse(argv.slice(1));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(`tuiboard board: ${(e as Error).message}`);
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (sub === "list") {
|
|
76
|
+
const config = loadConfig();
|
|
77
|
+
if (config.boards.length === 0) {
|
|
78
|
+
console.log("No boards configured. Add one with `tuiboard board add --path <file.md>`.");
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
for (const b of config.boards) {
|
|
82
|
+
const name = b.name ?? basename(b.path, extname(b.path));
|
|
83
|
+
const missing = existsSync(b.path) ? "" : " (file missing)";
|
|
84
|
+
console.log(`${name}\t${b.path}${missing}`);
|
|
85
|
+
}
|
|
86
|
+
console.log(`\nNew boards would be created in: ${suggestBoardsDir(config)}`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (sub === "scan") {
|
|
91
|
+
const dir = resolve(a.rest[0] ?? process.cwd());
|
|
92
|
+
const existingPaths = loadConfig().boards.map((b) => b.path);
|
|
93
|
+
const found = scanDirectory(dir, { existingPaths });
|
|
94
|
+
if (found.length === 0) {
|
|
95
|
+
console.log(`No board files in ${dir}.`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
for (const c of found) {
|
|
99
|
+
const mark = c.alreadyInConfig ? "already configured" : "not configured";
|
|
100
|
+
const tasks = `${c.taskCount} task${c.taskCount === 1 ? "" : "s"}`;
|
|
101
|
+
console.log(`${c.suggestedName}\t${tasks}\t${mark}\t${c.path}`);
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// add
|
|
107
|
+
if (!a.path) {
|
|
108
|
+
console.error("tuiboard board add: --path is required");
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
const path = resolve(a.path);
|
|
112
|
+
const name = a.name ?? basename(path, extname(path));
|
|
113
|
+
const columns = a.columns ?? [...DEFAULT_COLUMNS];
|
|
114
|
+
const exists = existsSync(path);
|
|
115
|
+
|
|
116
|
+
if (a.dryRun) {
|
|
117
|
+
console.log(
|
|
118
|
+
exists
|
|
119
|
+
? `[dry-run] would adopt existing board ${path} as "${name}"`
|
|
120
|
+
: `[dry-run] would create ${path} with columns ${columns.join(", ")} as "${name}"`,
|
|
121
|
+
);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// File first, config second: a file with no config entry is a board to
|
|
126
|
+
// adopt next time, while a config entry pointing at nothing is a broken
|
|
127
|
+
// launch. See docs/superpowers/specs/2026-09-01-board-lifecycle-design.md.
|
|
128
|
+
if (!exists) createBoardFile(path, { columns });
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const result = addBoardToConfig({ path, name });
|
|
132
|
+
console.log(
|
|
133
|
+
`${exists ? "adopted" : "created"} ${path} as "${name}" in ${result.configPath}` +
|
|
134
|
+
(result.created ? " (config created)" : ""),
|
|
135
|
+
);
|
|
136
|
+
return 0;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// Partial success is reported as such: the file is on disk either way.
|
|
139
|
+
if (!exists) {
|
|
140
|
+
console.error(
|
|
141
|
+
`created ${path}, but it was not registered: ${(e as Error).message}`,
|
|
142
|
+
);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error(`tuiboard board: ${(e as Error).message}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
19
19
|
import * as YAML from "js-yaml";
|
|
20
20
|
|
|
21
|
+
import { isBoardFile } from "~/boards/scan";
|
|
22
|
+
|
|
21
23
|
export interface BoardConfig {
|
|
22
24
|
/** Path to the .md file, absolute or relative to the config directory. */
|
|
23
25
|
path: string;
|
|
@@ -140,6 +142,35 @@ export interface LoadConfigOptions {
|
|
|
140
142
|
startDir?: string;
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Where the config lives, or where it would be created.
|
|
147
|
+
*
|
|
148
|
+
* Same resolution order as `loadConfig`, but it also answers for the case
|
|
149
|
+
* loadConfig cannot: no config anywhere. Writers need a target path even when
|
|
150
|
+
* nothing exists yet, and the user-global location is the one that works from
|
|
151
|
+
* any directory.
|
|
152
|
+
*/
|
|
153
|
+
export function findConfigPath({ startDir }: LoadConfigOptions = {}): {
|
|
154
|
+
path: string;
|
|
155
|
+
exists: boolean;
|
|
156
|
+
} {
|
|
157
|
+
// $TUIBOARD_CONFIG comes first and is absolute about it: when it is set,
|
|
158
|
+
// that file IS the config, whether or not it exists yet. Falling through to
|
|
159
|
+
// the home config when the named file is missing is how a caller aiming at a
|
|
160
|
+
// scratch path ends up writing to the user's real one.
|
|
161
|
+
const env = process.env.TUIBOARD_CONFIG;
|
|
162
|
+
if (env) {
|
|
163
|
+
const abs = resolve(env);
|
|
164
|
+
return { path: abs, exists: existsSync(abs) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const start = resolve(startDir ?? process.cwd());
|
|
168
|
+
const found = findConfigFile(start) ?? findGlobalConfigFile();
|
|
169
|
+
if (found) return { path: found.path, exists: true };
|
|
170
|
+
|
|
171
|
+
return { path: join(homedir(), ".config", "tuiboard", "config.yaml"), exists: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
143
174
|
export function loadConfig({ startDir }: LoadConfigOptions = {}): Config {
|
|
144
175
|
const start = resolve(startDir ?? process.cwd());
|
|
145
176
|
|
|
@@ -322,20 +353,13 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
322
353
|
}
|
|
323
354
|
|
|
324
355
|
function scanFallbackBoards(dir: string): BoardConfig[] {
|
|
356
|
+
// Delegates to the same recogniser the onboarding screen uses. Two rules
|
|
357
|
+
// would mean a file adopted by one path and ignored by the other.
|
|
325
358
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
.filter(
|
|
329
|
-
.
|
|
330
|
-
.filter((p) => {
|
|
331
|
-
try {
|
|
332
|
-
if (!statSync(p).isFile()) return false;
|
|
333
|
-
const head = readFileSync(p, "utf-8").slice(0, 4096);
|
|
334
|
-
return /^- \[[ xX]\] /m.test(head);
|
|
335
|
-
} catch {
|
|
336
|
-
return false;
|
|
337
|
-
}
|
|
338
|
-
})
|
|
359
|
+
return readdirSync(dir)
|
|
360
|
+
.map((name) => join(dir, name))
|
|
361
|
+
.filter(isBoardFile)
|
|
362
|
+
.sort()
|
|
339
363
|
.map((path) => ({ path }));
|
|
340
364
|
} catch {
|
|
341
365
|
return [];
|
package/src/input/handleKey.ts
CHANGED
|
@@ -46,9 +46,38 @@ export function handleKey(
|
|
|
46
46
|
// Modal dispatcher first — most keys go to the modal's <input>.
|
|
47
47
|
if (ui.modal) {
|
|
48
48
|
if (key.name === "escape") {
|
|
49
|
+
// The board wizard owns its own dismissal: on first run there is no
|
|
50
|
+
// board behind it, so Escape must not leave the user on a blank screen.
|
|
51
|
+
if (ui.modal.kind === "board-new") {
|
|
52
|
+
const b = store.state.ui.boardNew;
|
|
53
|
+
if (b?.step === "pick") { store.boardNewChooseMode("adopt"); return; }
|
|
54
|
+
store.closeBoardNew();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
49
57
|
store.closeModal();
|
|
50
58
|
return;
|
|
51
59
|
}
|
|
60
|
+
if (ui.modal.kind === "board-new") {
|
|
61
|
+
const b = store.state.ui.boardNew;
|
|
62
|
+
if (!b) return;
|
|
63
|
+
// Steps with a text field let the <input> have every key: only the list
|
|
64
|
+
// steps are driven from here.
|
|
65
|
+
if (b.step === "mode") {
|
|
66
|
+
if (key.name === "j" || key.name === "down") { store.boardNewMove(1); return; }
|
|
67
|
+
if (key.name === "k" || key.name === "up") { store.boardNewMove(-1); return; }
|
|
68
|
+
if (key.name === "enter" || key.name === "return") {
|
|
69
|
+
store.boardNewChooseMode(b.sel === 0 ? "create" : "adopt");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (b.step === "pick") {
|
|
74
|
+
if (key.name === "j" || key.name === "down") { store.boardNewMove(1); return; }
|
|
75
|
+
if (key.name === "k" || key.name === "up") { store.boardNewMove(-1); return; }
|
|
76
|
+
if (key.name === "space") { store.boardNewToggle(); return; }
|
|
77
|
+
if (key.name === "enter" || key.name === "return") { store.boardNewConfirmPick(); return; }
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
52
81
|
if (ui.modal.kind === "confirm-delete") {
|
|
53
82
|
if (key.name === "y" || key.name === "enter" || key.name === "return") {
|
|
54
83
|
// Delete the whole multi-selection if any, else just the cursor task.
|
|
@@ -165,6 +194,13 @@ export function handleKey(
|
|
|
165
194
|
return;
|
|
166
195
|
}
|
|
167
196
|
|
|
197
|
+
// New board — the `+` chip in the top bar, and its key. Free at this level:
|
|
198
|
+
// `+` is otherwise only used inside the timeline's duration sub-mode.
|
|
199
|
+
if (key.name === "+" || key.sequence === "+" || (key.name === "=" && key.shift)) {
|
|
200
|
+
store.openBoardNew();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
// Cycle boards
|
|
169
205
|
if (key.name === "tab") {
|
|
170
206
|
store.setActiveBoard(ui.activeBoardIndex + 1);
|
|
@@ -295,9 +331,16 @@ function handlePlannerZone(
|
|
|
295
331
|
return;
|
|
296
332
|
}
|
|
297
333
|
if (key.name === "l" || key.name === "right") {
|
|
334
|
+
if (store.stepPane(1)) return;
|
|
298
335
|
store.setActiveZone("board");
|
|
299
336
|
return;
|
|
300
337
|
}
|
|
338
|
+
if (key.name === "h" || key.name === "left") {
|
|
339
|
+
// Nothing to the planner's left at full width; in single-pane it wraps to
|
|
340
|
+
// the last pane of the ring.
|
|
341
|
+
if (store.stepPane(-1)) return;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
301
344
|
|
|
302
345
|
// Task actions on the planner cursor's target (works cross-board).
|
|
303
346
|
if (target) {
|
|
@@ -406,9 +449,14 @@ function handleTimelineZone(
|
|
|
406
449
|
return;
|
|
407
450
|
}
|
|
408
451
|
if (key.name === "h" || key.name === "left") {
|
|
452
|
+
if (store.stepPane(-1)) return;
|
|
409
453
|
store.setActiveZone("board");
|
|
410
454
|
return;
|
|
411
455
|
}
|
|
456
|
+
if (key.name === "l" || key.name === "right") {
|
|
457
|
+
if (store.stepPane(1)) return;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
412
460
|
// Enter on a timeline block bounces the kanban cursor to its source task.
|
|
413
461
|
if ((key.name === "enter" || key.name === "return") && target) {
|
|
414
462
|
jumpToKanban(store, target.ref);
|
|
@@ -456,7 +504,10 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
|
|
|
456
504
|
);
|
|
457
505
|
}
|
|
458
506
|
} else if (key.name === "h" || key.name === "left") {
|
|
507
|
+
if (store.stepPane(-1)) return;
|
|
459
508
|
store.setActiveZone("board");
|
|
509
|
+
} else if (key.name === "l" || key.name === "right") {
|
|
510
|
+
store.stepPane(1);
|
|
460
511
|
}
|
|
461
512
|
}
|
|
462
513
|
|
|
@@ -497,7 +548,7 @@ function handleBoardZone(
|
|
|
497
548
|
const openTasks = store.applyBoardFilter(allTasks.filter((t) => !t.done));
|
|
498
549
|
// Visible task list mirrors what the column renders: in zoom mode the
|
|
499
550
|
// user can navigate into done tasks too; otherwise only open.
|
|
500
|
-
const visibleTasks =
|
|
551
|
+
const visibleTasks = store.singlePane()
|
|
501
552
|
? [...openTasks, ...allTasks.filter((t) => t.done)]
|
|
502
553
|
: openTasks;
|
|
503
554
|
|
|
@@ -563,7 +614,12 @@ function handleBoardZone(
|
|
|
563
614
|
}
|
|
564
615
|
}
|
|
565
616
|
|
|
617
|
+
// In single-pane the zones are a ring, not a layout: h/l walk it one pane at
|
|
618
|
+
// a time — column, column, agenda, agents, planner — and wrap. stepPane
|
|
619
|
+
// returns false when it does not apply, and the side-by-side behaviour below
|
|
620
|
+
// takes over unchanged.
|
|
566
621
|
if (key.name === "h" || key.name === "left") {
|
|
622
|
+
if (store.stepPane(-1)) return;
|
|
567
623
|
// Step left over rendered columns. Hidden columns (Done / Archive) are
|
|
568
624
|
// never displayed, so navigating onto one would strand the cursor on an
|
|
569
625
|
// unrendered, unscrollable column.
|
|
@@ -576,6 +632,7 @@ function handleBoardZone(
|
|
|
576
632
|
return;
|
|
577
633
|
}
|
|
578
634
|
if (key.name === "l" || key.name === "right") {
|
|
635
|
+
if (store.stepPane(1)) return;
|
|
579
636
|
const next = adjacentVisibleColumn(store, board, ui.col, +1);
|
|
580
637
|
if (next !== undefined) {
|
|
581
638
|
store.setCursor(next, 0);
|
package/src/io/watcher.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface BoardWatcher {
|
|
|
22
22
|
onChange: (listener: ChangeListener) => () => void;
|
|
23
23
|
/** Mark the next change event for `filepath` as a self-write, to be ignored. */
|
|
24
24
|
markSelfWrite: (filepath: string) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Track one more file, after start(). A board adopted while tuiboard is
|
|
27
|
+
* running would otherwise stay deaf to external edits until the next
|
|
28
|
+
* launch — broken in the quietest possible way.
|
|
29
|
+
*/
|
|
30
|
+
watch: (filepath: string) => void;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface WatcherOptions {
|
|
@@ -76,6 +82,11 @@ export function createBoardWatcher(
|
|
|
76
82
|
listeners.add(listener);
|
|
77
83
|
return () => listeners.delete(listener);
|
|
78
84
|
},
|
|
85
|
+
watch(filepath) {
|
|
86
|
+
if (filepaths.includes(filepath)) return;
|
|
87
|
+
filepaths.push(filepath);
|
|
88
|
+
watcher?.add(filepath);
|
|
89
|
+
},
|
|
79
90
|
markSelfWrite(filepath) {
|
|
80
91
|
selfWrites.add(filepath);
|
|
81
92
|
// Guard against the watcher missing the event — clear after a short delay.
|
package/src/parser/markdown.ts
CHANGED
|
@@ -103,6 +103,16 @@ export function parseBoard(
|
|
|
103
103
|
const trailer = lines.slice(trailerStart).join(lineEnding);
|
|
104
104
|
const bodyLines = lines.slice(0, trailerStart);
|
|
105
105
|
|
|
106
|
+
// Splitting "a\nb\n" yields ["a", "b", ""]: that last empty element is the
|
|
107
|
+
// file's terminating newline, not a blank line. Keeping it would make the
|
|
108
|
+
// serializer — which emits one line ending per child — write it back as a
|
|
109
|
+
// real blank line AND terminate the file, growing the board by one blank
|
|
110
|
+
// line on every single save. Boards carrying a `%% kanban:settings %%`
|
|
111
|
+
// trailer never showed this: the trailer absorbs the final newline verbatim.
|
|
112
|
+
if (trailerStart === lines.length && bodyLines.length > 0 && bodyLines.at(-1) === "") {
|
|
113
|
+
bodyLines.pop();
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
// 3. Walk lines top-down. Build columns; *every line* before the first
|
|
107
117
|
// column is preamble, every line after a heading is a child of that
|
|
108
118
|
// column (task / section-break / blank / raw). Nothing is silently
|