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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-09-08
11
+
12
+ ### Added
13
+ - **Single-pane mode for narrow terminals.** Below 100 columns tuiboard used to
14
+ drop every zone but the kanban — the least readable thing at that width, and
15
+ the only one that cannot be hidden — leaving the planner and agenda
16
+ unreachable. Now the zones queue instead of disappearing: one on screen at a
17
+ time, `h`/`l` walking a ring (planner → each board column → agenda → agents,
18
+ wrapping), `Shift-Tab` jumping whole zones, and the top bar showing where you
19
+ are (`⤢ Today / Tomorrow ‹ 1/8 ›`). `z` still enters and leaves it by hand at
20
+ any width. Resizing no longer moves your focus.
21
+ - **`tuiboard --view=planner`** — open on Today/Tomorrow alone, for a vertical
22
+ strip beside other work.
23
+ - **Boards can be created from inside tuiboard.** A `+` chip at the end of the
24
+ board tabs — clickable, or the `+` key — opens a wizard that either creates a
25
+ new markdown board or scans a folder and adopts the boards already in it.
26
+ - **First run onboards instead of failing.** Launching with no config used to
27
+ print `No boards found` and exit, sending the user to write a file they had
28
+ never seen. The same wizard now opens, writes the config, and opens the board.
29
+ - **`tuiboard board add|scan|list`** — the same operations headless, for scripts
30
+ and widgets. Adding a board edits the config **by insertion**: comments,
31
+ calendars, `resume_command` and formatting survive because they are never
32
+ rewritten.
33
+ - New boards are proposed next to the boards you already have, so they inherit
34
+ that folder's sync and versioning; the fallback is `~/.local/share/tuiboard/boards/`.
35
+
36
+ ### Fixed
37
+ - **The key hints no longer truncate mid-word on a narrow terminal.** The
38
+ bottom bar is a 130-character line that truncates rather than wraps, so at 60
39
+ columns it read `⏎ don…schedule`. In single-pane it keeps only the keys that
40
+ matter with one pane on screen; the full sheet is one `?` away.
41
+ - **A board without a `%% kanban:settings %%` trailer grew a blank line on every
42
+ save.** The final newline of the file was parsed as a blank line and written
43
+ back as one, plus a new terminator. Boards with the trailer were unaffected,
44
+ which is why it went unseen — but the demo boards in `examples/` were failing
45
+ the round-trip check, and any board tuiboard creates itself would have too.
46
+
47
+ ### Changed
48
+ - **Slimmer published package.** The `files` field shipped `src/` whole, so the
49
+ tarball carried the test suite and the dev check scripts to every install.
50
+ 53 files → 42, 453 kB → 406 kB unpacked. Nothing that runs was removed:
51
+ verified by installing the tarball into a clean project and running both
52
+ headless commands from it.
53
+
10
54
  ## [0.8.5] - 2026-08-31
11
55
 
12
56
  ### Added
@@ -229,6 +273,7 @@ First public release on npm. This entry captures the full feature set at launch.
229
273
 
230
274
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
231
275
 
276
+ [0.9.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.0
232
277
  [0.8.5]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.5
233
278
  [0.8.4]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.4
234
279
  [0.8.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.3
package/README.md CHANGED
@@ -116,6 +116,19 @@ https://github.com/NazzarenoGiannelli/tuiboard). Set it up for me from scratch:
116
116
  Confirm the directory and file names with me before writing any files.
117
117
  ```
118
118
 
119
+ ## First run
120
+
121
+ Launch `tuiboard` with nothing set up and it opens its own onboarding rather
122
+ than an error: point it at a folder and adopt the markdown files already in it,
123
+ or give a name and get a new board. Either way it writes the config for you and
124
+ opens the board. The same screen is behind the `+` in the top bar, so adding a
125
+ board later is the gesture you already learned.
126
+
127
+ New boards are created next to the boards you already have — usually a vault,
128
+ so they inherit whatever sync and versioning it has — falling back to
129
+ `~/.local/share/tuiboard/boards/` when there is nothing to learn from. The path
130
+ is always shown and editable before anything is written.
131
+
119
132
  ## Configure
120
133
 
121
134
  Copy `.tuiboard/config.example.yaml` to a config location and edit the
@@ -362,6 +375,7 @@ Launch `tuiboard` with no flag for the default dashboard (every enabled zone).
362
375
  | Flag | View | Use case |
363
376
  |---|---|---|
364
377
  | (none) | **Dashboard** — every enabled zone | Default; your configured layout |
378
+ | `--view=planner` | Today/Tomorrow alone, full width | A narrow vertical strip beside other work |
365
379
  | `--view=board` | Kanban + planner panel only | Focus mode, or a single WezTerm pane |
366
380
  | `--view=timeline` | Timeline fullscreen | Wall-mounted "what's now" |
367
381
  | `--view=agents` | Agent view fullscreen | Cross-machine session monitor |
@@ -373,7 +387,7 @@ The dashboard auto-collapses optional zones on narrow terminals:
373
387
  | ≥ 150 cols | planner + board + timeline + agents |
374
388
  | 120–149 | planner + board + agents |
375
389
  | 100–119 | planner + board |
376
- | < 100 | board only |
390
+ | < 100 | **single-pane**: one zone at a time, `h`/`l` to walk them, `Shift-Tab` to jump |
377
391
 
378
392
  `F1` / `F2` / `F3` toggles override the auto-collapse for the current
379
393
  session (until the next terminal resize).
@@ -389,6 +403,9 @@ session (until the next terminal resize).
389
403
  | `1`..`9` | Jump to board N |
390
404
  | `v` | Toggle Today/Tomorrow planner panel focus |
391
405
  | `Shift-Tab` | Cycle active zone (planner → board → timeline → agents) |
406
+ | `+` | New board — create one, or adopt markdown files you already have (also the `+` chip in the top bar) |
407
+ | `z` | Focus one pane (single-pane mode) — automatic below 100 columns |
408
+ | `h` / `l` | In single-pane, walk the ring: planner → each column → agenda → agents, wrapping |
392
409
  | `F1` / `F2` / `F3` | Toggle visibility of Planner / Timeline / Agents zones |
393
410
  | `z` | Zoom active zone to full screen |
394
411
  | `r` | Refresh everything — reload boards from disk, rescan agents, force-refetch the agenda calendar (bypasses the 30-min cache) |
@@ -469,6 +486,21 @@ whether it is already `done` (with `doneDate`). Today and Tomorrow keep
469
486
  completed tasks, as the panel does: a day's plan is a record of the day, not
470
487
  only of what is left.
471
488
 
489
+ ### `tuiboard board` — create, adopt, list
490
+
491
+ ```bash
492
+ tuiboard board add --path ~/vault/Work.md --name Work # adopt it if it exists, create it if not
493
+ tuiboard board add --path ~/vault/New.md --columns "Todo,Doing,Done"
494
+ tuiboard board scan ~/vault # which files there are boards
495
+ tuiboard board list # what is configured, and where new boards would go
496
+ ```
497
+
498
+ The same operations the `+` button performs in the dashboard, over the same
499
+ code. `add` writes the board file when it is missing and registers it in your
500
+ config either way — the config is edited by insertion, never rewritten, so
501
+ comments and every other setting survive untouched. A duplicate name or path
502
+ is refused rather than guessed at, and `--dry-run` reports without writing.
503
+
472
504
  ### `tuiboard task` — mutations
473
505
 
474
506
  ```bash
@@ -502,6 +534,10 @@ clobber an edit made in the TUI or another editor in the meantime.
502
534
 
503
535
  See [CHANGELOG.md](CHANGELOG.md) for the full release history.
504
536
 
537
+ - **v0.9** — tuiboard makes its own boards: a `+` that creates or adopts them,
538
+ onboarding on first run instead of an error, `tuiboard board` headless, and
539
+ single-pane mode so a narrow vertical panel shows one zone at a time instead
540
+ of clipped kanban columns.
505
541
  - **v0.8** — write to Google Calendar from the Agenda: create, edit, and delete
506
542
  events (opt-in), set their date and time in the modal, plus all-day events in
507
543
  the top strip, consistent `t`/`m` date shortcuts, and a boot splash.
package/bin/tuiboard.ts CHANGED
@@ -37,6 +37,10 @@ if (process.argv[2] === "task") {
37
37
  const { runTask } = await import("../src/cli/task.ts");
38
38
  process.exit(await runTask(process.argv.slice(3)));
39
39
  }
40
+ if (process.argv[2] === "board") {
41
+ const { runBoard } = await import("../src/cli/board.ts");
42
+ process.exit(await runBoard(process.argv.slice(3)));
43
+ }
40
44
  const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
41
45
 
42
46
  // Paint the splash from the (already-running) launcher and animate its booting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,8 @@
34
34
  "files": [
35
35
  "tsconfig.json",
36
36
  "src/",
37
+ "!src/**/*.test.ts",
38
+ "!src/scripts/",
37
39
  "bin/",
38
40
  ".tuiboard/config.example.yaml",
39
41
  "README.md",
package/src/app.tsx CHANGED
@@ -72,19 +72,16 @@ process.on("warning", (w: Error) => {
72
72
  // ─── Bootstrap ──────────────────────────────────────────────────────────────
73
73
 
74
74
  const config = loadConfig();
75
- if (config.boards.length === 0) {
76
- console.error(
77
- "No boards found. Create `.tuiboard/config.yaml` with a `boards:` list," +
78
- " or run from a directory containing markdown files with `- [ ]` tasks.",
79
- );
80
- process.exit(1);
81
- }
82
-
83
75
  const store = createTuiStore({ config });
84
76
 
85
- if (store.state.boards.length === 0) {
86
- console.error("All boards failed to load. Check paths in .tuiboard/config.yaml.");
87
- process.exit(1);
77
+ // No boards a fresh install, or a config whose files have all gone. Rather
78
+ // than printing an error and exiting, which sends the user off to read
79
+ // documentation about a file they have never seen, tuiboard opens on its own
80
+ // onboarding: point it at markdown you already have, or make a board here.
81
+ // The same screen the `+` chip opens, so the first run teaches the gesture.
82
+ const needsOnboarding = store.state.boards.length === 0;
83
+ if (needsOnboarding) {
84
+ store.openBoardNew(true);
88
85
  }
89
86
 
90
87
  process.on("SIGINT", () => {
@@ -110,11 +107,20 @@ function applyResponsiveLayout(): void {
110
107
  // Report which zones FIT at this width. The store ANDs this with each zone's
111
108
  // enabled flag and the user's desired visibility, so a disabled or
112
109
  // intentionally-hidden zone is never force-shown just because there's room.
113
- store.applyResponsiveFits({
114
- planner: width >= 100,
115
- timeline: width >= 150,
116
- agents: width >= 120,
117
- });
110
+ // `narrow` travels with the fits, in one call: below the planner's own
111
+ // breakpoint there is no room for two zones side by side, so rather than
112
+ // dropping every zone but the kanban — the least readable at that width, and
113
+ // the only one that cannot be hidden — the zones queue and are shown one at
114
+ // a time. Same threshold as the planner's, opposite effect: "does not fit"
115
+ // now means "not simultaneously", not "unreachable".
116
+ store.applyResponsiveFits(
117
+ {
118
+ planner: width >= 100,
119
+ timeline: width >= 150,
120
+ agents: width >= 120,
121
+ },
122
+ { narrow: width < 100 },
123
+ );
118
124
  }
119
125
  applyResponsiveLayout();
120
126
  process.stdout.on("resize", applyResponsiveLayout);
@@ -122,16 +128,29 @@ process.stdout.on("resize", applyResponsiveLayout);
122
128
  // Land on the Today/Tomorrow panel by default — for a daily-planning tool the
123
129
  // first question is "what's on my plate today", and that panel answers it. On
124
130
  // a narrow terminal where the panel auto-hides, fall back to the board.
125
- if (store.state.ui.visibleZones.planner) {
131
+ if (!needsOnboarding && store.state.ui.visibleZones.planner) {
126
132
  store.setActiveZone("planner");
127
133
  }
128
134
 
135
+
129
136
  const { view } = parseArgs(process.argv.slice(2));
137
+ // `--view=planner`: open on the Today/Tomorrow panel alone. The flag beats the
138
+ // `zones:` config — someone typing it is asking for that zone — and it is a
139
+ // starting state, not a cage: Shift-Tab still walks to the other zones.
140
+ if (view === "planner") {
141
+ store.setZoneVisible("planner", true);
142
+ store.setActiveZone("planner");
143
+ store.setZoomed(true);
144
+ }
130
145
 
131
146
  // ─── App shell ──────────────────────────────────────────────────────────────
132
147
 
133
148
  function rootViewFor(v: ViewKind | undefined, s: TuiStore) {
134
149
  switch (v) {
150
+ // `planner` is BoardOnly with the planner focused and single-pane forced:
151
+ // in that state BoardOnly already renders the panel alone, so a vertical
152
+ // strip needs a launch flag, not a new view.
153
+ case "planner": return <BoardOnly store={s} />;
135
154
  case "board": return <BoardOnly store={s} />;
136
155
  case "timeline": return <TimelineOnly store={s} />;
137
156
  case "agents": return <AgentsOnly store={s} />;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Register a board in the user's config.
3
+ *
4
+ * This is the only place in tuiboard that edits a file the user wrote by
5
+ * hand — one that carries comments, calendar credentials, resume commands and
6
+ * whatever formatting its author preferred. So it does NOT read the YAML,
7
+ * modify an object and serialize it back: `js-yaml` does not preserve
8
+ * comments, and that round trip would hand back a reformatted, stripped
9
+ * document every time a board is added.
10
+ *
11
+ * Instead the only mutation needed — appending an entry to `boards:` — is done
12
+ * as a text insertion. Everything else in the file survives because it is
13
+ * never touched, rather than because someone remembered to copy it across.
14
+ *
15
+ * The YAML parser is still used, twice, as a judge: once to refuse a file that
16
+ * does not parse (guessing where to insert into a broken document is how a
17
+ * config gets destroyed), and once on the result, to abandon the write if the
18
+ * board did not land where it should. A failed addition beats a corrupt file.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
22
+ import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
23
+ import * as YAML from "js-yaml";
24
+
25
+ import { findConfigPath } from "~/config/loader";
26
+ import { writeBoardFile } from "~/io/writer";
27
+
28
+ export interface BoardEntry {
29
+ /** Path to the board file. Relative paths are made absolute before writing. */
30
+ path: string;
31
+ /** Display name. Defaults to the filename without extension. */
32
+ name?: string;
33
+ }
34
+
35
+ export interface AddBoardResult {
36
+ /** The config file written. */
37
+ configPath: string;
38
+ /** True when the config did not exist and was created by this call. */
39
+ created: boolean;
40
+ /** The name the board was registered under. */
41
+ name: string;
42
+ }
43
+
44
+ interface RawBoards {
45
+ boards?: Array<string | { path?: string; name?: string }>;
46
+ }
47
+
48
+ export function addBoardToConfig(entry: BoardEntry): AddBoardResult {
49
+ const path = resolve(entry.path);
50
+ const name = (entry.name ?? basename(path, extname(path))).trim();
51
+ if (!name) throw new Error("a board needs a name");
52
+
53
+ const { path: configPath, exists } = findConfigPath();
54
+
55
+ if (!exists) {
56
+ mkdirSync(dirname(configPath), { recursive: true });
57
+ writeBoardFile(configPath, renderFreshConfig(path, name));
58
+ return { configPath, created: true, name };
59
+ }
60
+
61
+ const original = readFileSync(configPath, "utf-8");
62
+
63
+ let parsed: RawBoards;
64
+ try {
65
+ parsed = (YAML.load(original) ?? {}) as RawBoards;
66
+ } catch (e) {
67
+ throw new Error(`${configPath} is not valid YAML: ${(e as Error).message}`);
68
+ }
69
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
70
+ throw new Error(`${configPath} does not hold a YAML mapping`);
71
+ }
72
+
73
+ assertNotRegistered(parsed, configPath, path, name);
74
+
75
+ const updated = insertBoard(original, path, name);
76
+
77
+ // Judge the result before it reaches the disk.
78
+ let after: RawBoards;
79
+ try {
80
+ after = (YAML.load(updated) ?? {}) as RawBoards;
81
+ } catch (e) {
82
+ throw new Error(`refusing to write: the result would not parse (${(e as Error).message})`);
83
+ }
84
+ const landed = (after.boards ?? []).some(
85
+ (b) => typeof b === "object" && b !== null && resolve(String(b.path ?? "")) === path,
86
+ );
87
+ if (!landed) {
88
+ throw new Error("refusing to write: the board did not land in `boards:` as expected");
89
+ }
90
+
91
+ writeBoardFile(configPath, updated);
92
+ return { configPath, created: false, name };
93
+ }
94
+
95
+ /**
96
+ * Both checks protect the same thing: `tuiboard task --board <name>` resolves
97
+ * by name, and the bar widget calls it. Two boards sharing a name — or one
98
+ * file registered twice — would make that command ambiguous.
99
+ */
100
+ function assertNotRegistered(
101
+ parsed: RawBoards,
102
+ configPath: string,
103
+ path: string,
104
+ name: string,
105
+ ): void {
106
+ for (const b of parsed.boards ?? []) {
107
+ const existingPath = typeof b === "string" ? b : (b?.path ?? "");
108
+ const existingName =
109
+ typeof b === "string"
110
+ ? basename(b, extname(b))
111
+ : (b?.name ?? basename(existingPath, extname(existingPath)));
112
+
113
+ if (existingPath && resolve(existingPath) === path) {
114
+ throw new Error(`${path} is already registered in ${configPath}`);
115
+ }
116
+ if (existingName.toLowerCase() === name.toLowerCase()) {
117
+ throw new Error(`a board named "${existingName}" is already registered in ${configPath}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ /** A config file for someone who had none: only what is needed, plus a pointer. */
123
+ function renderFreshConfig(path: string, name: string): string {
124
+ return [
125
+ "# tuiboard config — https://github.com/NazzarenoGiannelli/tuiboard",
126
+ "# Boards are plain markdown files; add more with `+` in the board zone.",
127
+ "boards:",
128
+ ...renderEntry(path, name, " "),
129
+ "",
130
+ ].join("\n");
131
+ }
132
+
133
+ function renderEntry(path: string, name: string, indent: string): string[] {
134
+ return [`${indent}- path: ${quote(path)}`, `${indent} name: ${quote(name)}`];
135
+ }
136
+
137
+ /** Quote only when the value could be misread as YAML syntax. */
138
+ function quote(value: string): string {
139
+ return /^[A-Za-z0-9_][A-Za-z0-9 _.\-]*$/.test(value) ? value : JSON.stringify(value);
140
+ }
141
+
142
+ /**
143
+ * The three shapes a real config presents:
144
+ *
145
+ * boards: → block list; append after its last entry
146
+ * - path: …
147
+ * boards: [] → inline empty list; replace with a block list
148
+ * (no boards key) → prepend the key
149
+ *
150
+ * Anything else inline (`boards: [{path: …}]`) is refused: rewriting flow
151
+ * style safely means re-serializing, which is exactly what this module exists
152
+ * to avoid.
153
+ */
154
+ function insertBoard(original: string, path: string, name: string): string {
155
+ const lines = original.split("\n");
156
+ const keyIndex = lines.findIndex((l) => /^boards:\s*(#.*)?$/.test(l) || /^boards:\s*\S/.test(l));
157
+
158
+ if (keyIndex < 0) {
159
+ const block = ["boards:", ...renderEntry(path, name, " "), ""];
160
+ return [...block, ...lines].join("\n");
161
+ }
162
+
163
+ const value = lines[keyIndex]!.replace(/^boards:\s*/, "").replace(/\s*#.*$/, "").trim();
164
+
165
+ if (value === "[]") {
166
+ lines.splice(keyIndex, 1, "boards:", ...renderEntry(path, name, " "));
167
+ return lines.join("\n");
168
+ }
169
+ if (value !== "") {
170
+ throw new Error(
171
+ `refusing to edit \`boards: ${value}\` — rewrite it as a block list first`,
172
+ );
173
+ }
174
+
175
+ // Block list: walk to the last line belonging to it. Indented lines are part
176
+ // of the block; blank lines are only part of it if something indented
177
+ // follows, so a trailing blank line before the next key stays where it is.
178
+ let end = keyIndex;
179
+ let indent = " ";
180
+ for (let i = keyIndex + 1; i < lines.length; i++) {
181
+ const line = lines[i]!;
182
+ if (line.trim() === "") continue;
183
+ if (!/^\s/.test(line)) break;
184
+ const dash = line.match(/^(\s*)- /);
185
+ if (dash) indent = dash[1]!;
186
+ end = i;
187
+ }
188
+
189
+ lines.splice(end + 1, 0, ...renderEntry(path, name, indent));
190
+ return lines.join("\n");
191
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Write a new board file.
3
+ *
4
+ * The file is a plain Obsidian Kanban board: YAML frontmatter carrying
5
+ * `kanban-plugin: board`, then one `## Column` heading per column. Those two
6
+ * frontmatter lines are what make Obsidian render the file as a board instead
7
+ * of as a wall of text — which matters because these files are read on a
8
+ * phone as often as in this program.
9
+ *
10
+ * No `%% kanban:settings %%` trailer is written. Obsidian adds its own on the
11
+ * first setting change, and inventing one here would mean guessing at a
12
+ * format this project does not control.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
+
18
+ export interface CreateBoardOptions {
19
+ /** Column headings, in order. At least one. */
20
+ columns: readonly string[];
21
+ }
22
+
23
+ /** Columns offered when the caller has no opinion. */
24
+ export const DEFAULT_COLUMNS = ["Todo", "Doing", "Done"] as const;
25
+
26
+ export function createBoardFile(path: string, { columns }: CreateBoardOptions): void {
27
+ const names = columns.map((c) => c.trim()).filter(Boolean);
28
+ if (names.length === 0) {
29
+ // A board with no columns has nowhere to put a task, and the TUI cannot
30
+ // yet add one. Refusing here beats handing back something unusable.
31
+ throw new Error("a board needs at least one column");
32
+ }
33
+
34
+ // Never overwrite: the target may be a file someone else wrote, and the
35
+ // caller's next best move — adopting it instead — is only possible if it
36
+ // still exists.
37
+ if (existsSync(path)) {
38
+ throw new Error(`${path} already exists`);
39
+ }
40
+
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ writeFileSync(path, render(names), "utf-8");
43
+ }
44
+
45
+ /**
46
+ * The blank-line placement matches what Obsidian Kanban itself writes, so a
47
+ * board created here and one created there are the same document.
48
+ */
49
+ function render(columns: readonly string[]): string {
50
+ const frontmatter = ["---", "", "kanban-plugin: board", "", "---", ""].join("\n");
51
+ const body = columns.map((name) => `\n## ${name}\n`).join("");
52
+ // The trailing blank line is what `serializeBoard` produces for a board
53
+ // whose last column is empty. Matching it means a file created here and the
54
+ // same file after tuiboard writes to it are byte-identical — so a fresh
55
+ // board never shows up as a spurious diff in the vault's git history.
56
+ return `${frontmatter}${body}\n`;
57
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Find the boards already sitting in a directory.
3
+ *
4
+ * This is what the "I already have files" path of onboarding offers: point at
5
+ * a folder, and see which of its markdown files are actually task boards.
6
+ *
7
+ * The recognition rule is deliberately the same one `loadConfig()` uses for
8
+ * its zero-config fallback — a `.md` file containing a `- [ ]` or `- [x]`
9
+ * line. Two rules would mean a file adopted by one path and ignored by the
10
+ * other, so the loader delegates here rather than keeping its own copy.
11
+ */
12
+
13
+ import { readFileSync, readdirSync, statSync } from "node:fs";
14
+ import { basename, extname, join, resolve } from "node:path";
15
+
16
+ /** How much of a file is read to decide whether it is a board. */
17
+ const SNIFF_BYTES = 4096;
18
+
19
+ const RE_TASK = /^- \[[ xX]\] /m;
20
+ const RE_TASK_GLOBAL = /^- \[[ xX]\] /gm;
21
+ /** Obsidian Kanban's marker — what tuiboard itself writes into a new board. */
22
+ const RE_KANBAN = /^kanban-plugin:\s*board\s*$/m;
23
+
24
+ export interface BoardCandidate {
25
+ /** Absolute path to the markdown file. */
26
+ path: string;
27
+ /** Filename without extension — what the board would be called. */
28
+ suggestedName: string;
29
+ /** Tasks found in the file, open and done. */
30
+ taskCount: number;
31
+ /** True when this file is already registered as a board. */
32
+ alreadyInConfig: boolean;
33
+ }
34
+
35
+ export interface ScanOptions {
36
+ /** Board paths already registered, so candidates can be marked. */
37
+ existingPaths?: readonly string[];
38
+ }
39
+
40
+ /**
41
+ * True when the file looks like a task board.
42
+ *
43
+ * Two ways to qualify, and the second is not optional: a board tuiboard has
44
+ * just created holds no tasks yet, so a checkbox-only rule would make the
45
+ * program blind to its own output until someone typed into it. The Kanban
46
+ * frontmatter marker settles those.
47
+ */
48
+ export function isBoardFile(path: string): boolean {
49
+ if (extname(path).toLowerCase() !== ".md") return false;
50
+ try {
51
+ if (!statSync(path).isFile()) return false;
52
+ const head = readFileSync(path, "utf-8").slice(0, SNIFF_BYTES);
53
+ return RE_TASK.test(head) || RE_KANBAN.test(head);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * List the board files in `dir`, sorted by name.
61
+ *
62
+ * A missing or unreadable directory yields an empty list rather than an
63
+ * error: onboarding asks the user to type a path, and a typo should redraw
64
+ * the screen with "nothing here", not end the session.
65
+ */
66
+ export function scanDirectory(dir: string, { existingPaths = [] }: ScanOptions = {}): BoardCandidate[] {
67
+ let entries: string[];
68
+ try {
69
+ entries = readdirSync(dir);
70
+ } catch {
71
+ return [];
72
+ }
73
+
74
+ const known = new Set(existingPaths.map((p) => resolve(p)));
75
+
76
+ return entries
77
+ .map((name) => join(dir, name))
78
+ .filter(isBoardFile)
79
+ .sort()
80
+ .map((path) => ({
81
+ path,
82
+ suggestedName: basename(path, extname(path)),
83
+ taskCount: countTasks(path),
84
+ alreadyInConfig: known.has(resolve(path)),
85
+ }));
86
+ }
87
+
88
+ /** Tasks in the whole file — the sniff window is only for recognition. */
89
+ function countTasks(path: string): number {
90
+ try {
91
+ return readFileSync(path, "utf-8").match(RE_TASK_GLOBAL)?.length ?? 0;
92
+ } catch {
93
+ return 0;
94
+ }
95
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Where a new board file should be born.
3
+ *
4
+ * The answer that serves the user best is "next to the boards you already
5
+ * have": that is usually a synced, versioned folder — a vault — so a board
6
+ * created here inherits replication, git history and Obsidian rendering
7
+ * without anyone configuring anything.
8
+ *
9
+ * When there is nothing to learn from — no boards yet, or boards scattered
10
+ * across unrelated folders — the fallback is an XDG data directory the app
11
+ * owns. Someone who installed tuiboard five minutes ago and has no vault
12
+ * still gets a working board.
13
+ *
14
+ * This only ever produces a *proposal*. The path is shown and editable before
15
+ * anything is written, so a wrong guess costs a keystroke, not a lost file.
16
+ */
17
+
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+
21
+ import type { Config } from "~/config/loader";
22
+
23
+ /** The app-owned directory, honouring XDG_DATA_HOME when set. */
24
+ export function defaultBoardsDir(): string {
25
+ const xdg = process.env.XDG_DATA_HOME;
26
+ const base = xdg && xdg.trim() ? xdg : join(homedir(), ".local", "share");
27
+ return join(base, "tuiboard", "boards");
28
+ }
29
+
30
+ export function suggestBoardsDir(config: Pick<Config, "boards">): string {
31
+ const dirs = new Set((config.boards ?? []).map((b) => dirname(resolve(b.path))));
32
+ if (dirs.size === 1) return [...dirs][0]!;
33
+ return defaultBoardsDir();
34
+ }
package/src/cli/args.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * value flags) would warrant a real CLI library — YAGNI here.
5
5
  */
6
6
 
7
- export type ViewKind = "board" | "timeline" | "agents";
7
+ export type ViewKind = "board" | "planner" | "timeline" | "agents";
8
8
 
9
- const VALID_VIEWS: readonly ViewKind[] = ["board", "timeline", "agents"];
9
+ const VALID_VIEWS: readonly ViewKind[] = ["board", "planner", "timeline", "agents"];
10
10
 
11
11
  export interface ParsedArgs {
12
12
  /** Undefined means: render the default Dashboard (all 4 zones). */