tuiboard 0.8.5 → 0.9.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.
@@ -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
+ }
@@ -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
- const entries = readdirSync(dir);
327
- return entries
328
- .filter((f) => f.endsWith(".md"))
329
- .map((f) => join(dir, f))
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 [];
@@ -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 = ui.zoomed
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.
@@ -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