tuiboard 0.8.3 → 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,235 @@
1
+ /**
2
+ * Headless mutations on a board, for scripts and widgets.
3
+ *
4
+ * tuiboard task done --board <name> --column <col> --match <text>
5
+ * tuiboard task undone --board <name> --column <col> --match <text>
6
+ * tuiboard task add --board <name> --column <col> --text <text>
7
+ * tuiboard task defer --board <name> --column <col> --match <text> [--days N|--to DATE]
8
+ * ... --dry-run report what would change, write nothing
9
+ *
10
+ * Why match by text and not by index: a task's id is `${column}:${position}`,
11
+ * which is only valid inside one render pass. Anything outside the TUI — a
12
+ * widget polling every two minutes, a cron job — holds a stale snapshot, and
13
+ * acting on a stale index silently hits the wrong task. Matching on the
14
+ * rendered title instead makes a moved task a miss rather than a mistake.
15
+ *
16
+ * Concurrency is handled by writeBoardFile()'s mtime watermark: if the file
17
+ * changed since we read it, it throws ConflictError and we refuse the write.
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+
22
+ import { loadConfig } from "~/config/loader";
23
+ import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
24
+ import { isTask, parseBoard } from "~/parser/markdown";
25
+ import { serializeBoard } from "~/parser/serialize";
26
+ import type { Board, Column, Task } from "~/types";
27
+
28
+ interface Args {
29
+ board?: string;
30
+ column?: string;
31
+ match?: string;
32
+ text?: string;
33
+ days?: number;
34
+ to?: string;
35
+ dryRun: boolean;
36
+ }
37
+
38
+ function parse(argv: readonly string[]): Args {
39
+ const a: Args = { dryRun: false };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const arg = argv[i]!;
42
+ const take = () => argv[++i];
43
+ if (arg === "--board") a.board = take();
44
+ else if (arg === "--column") a.column = take();
45
+ else if (arg === "--match") a.match = take();
46
+ else if (arg === "--text") a.text = take();
47
+ else if (arg === "--days") a.days = Number(take());
48
+ else if (arg === "--to") a.to = take();
49
+ else if (arg === "--dry-run") a.dryRun = true;
50
+ else if (arg.startsWith("--board=")) a.board = arg.slice(8);
51
+ else if (arg.startsWith("--column=")) a.column = arg.slice(9);
52
+ else if (arg.startsWith("--match=")) a.match = arg.slice(8);
53
+ else if (arg.startsWith("--text=")) a.text = arg.slice(7);
54
+ else if (arg.startsWith("--days=")) a.days = Number(arg.slice(7));
55
+ else if (arg.startsWith("--to=")) a.to = arg.slice(5);
56
+ else throw new Error(`unknown argument "${arg}"`);
57
+ }
58
+ return a;
59
+ }
60
+
61
+ function isoToday(): string {
62
+ const d = new Date();
63
+ const p = (n: number) => String(n).padStart(2, "0");
64
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
65
+ }
66
+
67
+ function isoShift(days: number): string {
68
+ const d = new Date();
69
+ d.setDate(d.getDate() + days);
70
+ const p = (n: number) => String(n).padStart(2, "0");
71
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
72
+ }
73
+
74
+ /** Resolve a board by configured name, then by path suffix. */
75
+ function resolveBoard(needle: string) {
76
+ const cfg = loadConfig();
77
+ const lower = needle.toLowerCase();
78
+ const hit =
79
+ cfg.boards.find((b) => (b.name ?? "").toLowerCase() === lower) ??
80
+ cfg.boards.find((b) => b.path.toLowerCase().endsWith(lower));
81
+ if (!hit) {
82
+ const names = cfg.boards.map((b) => b.name ?? b.path).join(", ");
83
+ throw new Error(`board "${needle}" not found. Configured: ${names || "(none)"}`);
84
+ }
85
+ return hit;
86
+ }
87
+
88
+ function findColumn(board: Board, name: string): Column {
89
+ const lower = name.toLowerCase();
90
+ const col = board.columns.find((c) => c.name.toLowerCase() === lower);
91
+ if (!col) {
92
+ throw new Error(
93
+ `column "${name}" not found. Available: ${board.columns.map((c) => c.name).join(", ")}`,
94
+ );
95
+ }
96
+ return col;
97
+ }
98
+
99
+ /** Exactly one match or nothing: an ambiguous title must not be guessed at. */
100
+ function findTask(col: Column, needle: string): Task {
101
+ const lower = needle.toLowerCase();
102
+ const tasks = col.children.filter(isTask);
103
+ let hits = tasks.filter((t) => t.displayTitle.toLowerCase() === lower);
104
+ if (hits.length === 0) {
105
+ hits = tasks.filter((t) => t.displayTitle.toLowerCase().includes(lower));
106
+ }
107
+ if (hits.length === 0) throw new Error(`no task in "${col.name}" matching "${needle}"`);
108
+ if (hits.length > 1) {
109
+ throw new Error(
110
+ `"${needle}" matches ${hits.length} tasks in "${col.name}"; be more specific`,
111
+ );
112
+ }
113
+ return hits[0]!;
114
+ }
115
+
116
+ export async function runTask(argv: readonly string[]): Promise<number> {
117
+ const sub = argv[0];
118
+ if (sub !== "done" && sub !== "undone" && sub !== "add" && sub !== "defer") {
119
+ console.error(
120
+ "usage: tuiboard task <done|undone|add|defer> --board <b> --column <c> [--match|--text] <s>",
121
+ );
122
+ return 2;
123
+ }
124
+
125
+ let a: Args;
126
+ try {
127
+ a = parse(argv.slice(1));
128
+ } catch (e) {
129
+ console.error(`tuiboard task: ${(e as Error).message}`);
130
+ return 2;
131
+ }
132
+
133
+ if (!a.board || !a.column) {
134
+ console.error("tuiboard task: --board and --column are required");
135
+ return 2;
136
+ }
137
+ if ((sub === "done" || sub === "undone" || sub === "defer") && !a.match) {
138
+ console.error(`tuiboard task ${sub}: --match is required`);
139
+ return 2;
140
+ }
141
+ if (sub === "defer" && a.days !== undefined && !Number.isFinite(a.days)) {
142
+ console.error("tuiboard task defer: --days must be a number");
143
+ return 2;
144
+ }
145
+ if (sub === "defer" && a.to !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(a.to)) {
146
+ console.error("tuiboard task defer: --to must be YYYY-MM-DD");
147
+ return 2;
148
+ }
149
+ if (sub === "add" && !a.text) {
150
+ console.error("tuiboard task add: --text is required");
151
+ return 2;
152
+ }
153
+
154
+ try {
155
+ const ref = resolveBoard(a.board);
156
+ const mtime = statMtime(ref.path);
157
+ const { board } = parseBoard(readFileSync(ref.path, "utf-8"), { filepath: ref.path });
158
+ const col = findColumn(board, a.column);
159
+
160
+ let summary: string;
161
+
162
+ if (sub === "done") {
163
+ const task = findTask(col, a.match!);
164
+ if (task.done) {
165
+ console.log(`already done: ${task.displayTitle}`);
166
+ return 0;
167
+ }
168
+ // Same semantics as the TUI's toggleDone: keep an existing completion
169
+ // date if one was already recorded, otherwise stamp today.
170
+ task.done = true;
171
+ task.dirty = true;
172
+ task.doneDate = task.doneDate ?? isoToday();
173
+ summary = `done: ${task.displayTitle}`;
174
+ } else if (sub === "undone") {
175
+ // The counterpart of `done`: ticking a task by mistake, or reopening one
176
+ // that turned out not to be finished, must not require the TUI.
177
+ // The completion date goes with it — a task that is open has not been
178
+ // completed on any date, and a stale ✅ would outlive the tick.
179
+ const task = findTask(col, a.match!);
180
+ if (!task.done) {
181
+ console.log(`already open: ${task.displayTitle}`);
182
+ return 0;
183
+ }
184
+ task.done = false;
185
+ task.doneDate = undefined;
186
+ task.dirty = true;
187
+ summary = `reopened: ${task.displayTitle}`;
188
+ } else if (sub === "defer") {
189
+ const task = findTask(col, a.match!);
190
+ const target = a.to ?? isoShift(a.days ?? 1);
191
+
192
+ // Move the field the planner actually reads. buildPlannerItems() buckets
193
+ // on `scheduled ?? due`, so shifting `due` on a task that also carries a
194
+ // `scheduled` date would write a change that never moves the row.
195
+ // A task with neither date gets a scheduled one, which is what puts it
196
+ // on the agenda in the first place.
197
+ if (task.scheduled !== undefined) task.scheduled = target;
198
+ else if (task.due !== undefined) task.due = target;
199
+ else task.scheduled = target;
200
+
201
+ task.dirty = true;
202
+ summary = `deferred to ${target}: ${task.displayTitle}`;
203
+ } else {
204
+ const task: Task = {
205
+ id: `${board.columns.indexOf(col)}:${col.children.filter(isTask).length}`,
206
+ done: false,
207
+ rawBody: a.text!,
208
+ rawLine: `- [ ] ${a.text!}`,
209
+ dirty: true,
210
+ displayTitle: a.text!,
211
+ tags: [],
212
+ wikilinks: [],
213
+ priority: "none",
214
+ };
215
+ col.children.push(task);
216
+ summary = `added to ${col.name}: ${a.text}`;
217
+ }
218
+
219
+ if (a.dryRun) {
220
+ console.log(`[dry-run] ${summary}`);
221
+ return 0;
222
+ }
223
+
224
+ writeBoardFile(ref.path, serializeBoard(board), { expectedMtimeMs: mtime });
225
+ console.log(summary);
226
+ return 0;
227
+ } catch (e) {
228
+ if (e instanceof ConflictError) {
229
+ console.error(`tuiboard task: the board changed on disk — refresh and retry.`);
230
+ return 3;
231
+ }
232
+ console.error(`tuiboard task: ${(e as Error).message}`);
233
+ return 1;
234
+ }
235
+ }
@@ -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;
@@ -43,6 +45,15 @@ export interface Config {
43
45
  * When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
44
46
  */
45
47
  resumeCommand?: string[];
48
+ /**
49
+ * Template for the shell command copied to the clipboard by `c` in the agents
50
+ * zone — one paste that `cd`s into the session's directory and resumes it.
51
+ * The tokens `{cwd}` and `{sessionId}` are substituted. Default:
52
+ * cd "{cwd}" && claude --resume {sessionId}
53
+ * `&&` works in bash/zsh/pwsh/cmd; Nushell users may prefer
54
+ * cd "{cwd}"; claude --resume {sessionId}
55
+ */
56
+ copyResumeCommand: string;
46
57
  /**
47
58
  * Optional read-only calendar feeds merged into the Agenda (timeline) zone.
48
59
  * Paths support `~` and are resolved against the config dir if relative.
@@ -102,10 +113,18 @@ export interface CalendarsConfig {
102
113
  microsoft?: MicrosoftCalendarConfig;
103
114
  }
104
115
 
116
+ /**
117
+ * Default clipboard template for `c` in the agents zone: `cd` + resume in one
118
+ * paste. `&&` chains in bash/zsh/pwsh/cmd (Nushell users override with `;`).
119
+ */
120
+ export const DEFAULT_COPY_RESUME_COMMAND =
121
+ 'cd "{cwd}" && claude --resume {sessionId}';
122
+
105
123
  export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
106
124
  assignees: [],
107
125
  doneColumn: "Done",
108
126
  archiveColumn: "Archive",
127
+ copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
109
128
  zones: { planner: "on", agenda: "on", agents: "on" },
110
129
  };
111
130
 
@@ -123,6 +142,35 @@ export interface LoadConfigOptions {
123
142
  startDir?: string;
124
143
  }
125
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
+
126
174
  export function loadConfig({ startDir }: LoadConfigOptions = {}): Config {
127
175
  const start = resolve(startDir ?? process.cwd());
128
176
 
@@ -149,6 +197,7 @@ interface RawConfig {
149
197
  done_column: string;
150
198
  archive_column: string;
151
199
  resume_command: string[];
200
+ copy_resume_command: string;
152
201
  calendars: {
153
202
  google?: {
154
203
  enabled?: boolean;
@@ -293,26 +342,24 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
293
342
  Array.isArray(raw.resume_command) && raw.resume_command.length > 0
294
343
  ? raw.resume_command.map(String)
295
344
  : undefined,
345
+ copyResumeCommand:
346
+ typeof raw.copy_resume_command === "string" &&
347
+ raw.copy_resume_command.trim().length > 0
348
+ ? raw.copy_resume_command
349
+ : DEFAULT_COPY_RESUME_COMMAND,
296
350
  calendars: normalizeCalendars(raw.calendars, root),
297
351
  zones: normalizeZones(raw.zones),
298
352
  };
299
353
  }
300
354
 
301
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.
302
358
  try {
303
- const entries = readdirSync(dir);
304
- return entries
305
- .filter((f) => f.endsWith(".md"))
306
- .map((f) => join(dir, f))
307
- .filter((p) => {
308
- try {
309
- if (!statSync(p).isFile()) return false;
310
- const head = readFileSync(p, "utf-8").slice(0, 4096);
311
- return /^- \[[ xX]\] /m.test(head);
312
- } catch {
313
- return false;
314
- }
315
- })
359
+ return readdirSync(dir)
360
+ .map((name) => join(dir, name))
361
+ .filter(isBoardFile)
362
+ .sort()
316
363
  .map((path) => ({ path }));
317
364
  } catch {
318
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);
@@ -432,6 +480,20 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
432
480
  // Open (resume) the selected session in a new WezTerm tab.
433
481
  const target = sessions[ui.row];
434
482
  if (target) void openSessionInWezterm(store, target.cwd, target.sessionId);
483
+ } else if (key.name === "c") {
484
+ // Copy a one-paste "cd + resume" command for the selected session, so you
485
+ // can drop it into any tab/pane anywhere and land in the right directory
486
+ // resuming the right session (no WezTerm dependency, unlike Enter).
487
+ const target = sessions[ui.row];
488
+ if (target) {
489
+ const cmd = store.config.copyResumeCommand
490
+ .replaceAll("{cwd}", target.cwd)
491
+ .replaceAll("{sessionId}", target.sessionId);
492
+ copyToClipboard(cmd).then(
493
+ () => store.flashBanner("info", `📋 Copied resume command (${target.sessionId.slice(0, 8)})`),
494
+ (err) => store.flashBanner("error", `Copy failed: ${err}`),
495
+ );
496
+ }
435
497
  } else if (key.name === "o") {
436
498
  // Inspect the session in the detail modal.
437
499
  const target = sessions[ui.row];
@@ -442,7 +504,10 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
442
504
  );
443
505
  }
444
506
  } else if (key.name === "h" || key.name === "left") {
507
+ if (store.stepPane(-1)) return;
445
508
  store.setActiveZone("board");
509
+ } else if (key.name === "l" || key.name === "right") {
510
+ store.stepPane(1);
446
511
  }
447
512
  }
448
513
 
@@ -483,7 +548,7 @@ function handleBoardZone(
483
548
  const openTasks = store.applyBoardFilter(allTasks.filter((t) => !t.done));
484
549
  // Visible task list mirrors what the column renders: in zoom mode the
485
550
  // user can navigate into done tasks too; otherwise only open.
486
- const visibleTasks = ui.zoomed
551
+ const visibleTasks = store.singlePane()
487
552
  ? [...openTasks, ...allTasks.filter((t) => t.done)]
488
553
  : openTasks;
489
554
 
@@ -549,7 +614,12 @@ function handleBoardZone(
549
614
  }
550
615
  }
551
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.
552
621
  if (key.name === "h" || key.name === "left") {
622
+ if (store.stepPane(-1)) return;
553
623
  // Step left over rendered columns. Hidden columns (Done / Archive) are
554
624
  // never displayed, so navigating onto one would strand the cursor on an
555
625
  // unrendered, unscrollable column.
@@ -562,6 +632,7 @@ function handleBoardZone(
562
632
  return;
563
633
  }
564
634
  if (key.name === "l" || key.name === "right") {
635
+ if (store.stepPane(1)) return;
565
636
  const next = adjacentVisibleColumn(store, board, ui.col, +1);
566
637
  if (next !== undefined) {
567
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