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 +45 -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 +128 -0
- 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 +1 -1
- 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/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 |
|
|
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.
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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). */
|