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.
@@ -62,6 +62,15 @@ archive_column: Archive
62
62
  # - "{cwd}"
63
63
  # - "{sessionId}"
64
64
 
65
+ # Optional: the command copied to your clipboard by `c` in the Agents zone — one
66
+ # paste that cd's into the session's directory and resumes it, for when you want
67
+ # to open the session yourself in a new tab/pane anywhere (no WezTerm needed).
68
+ # The tokens {cwd} and {sessionId} are substituted. It's a plain string, so use
69
+ # whatever chaining your shell wants. Default (works in bash/zsh/pwsh/cmd):
70
+ # copy_resume_command: 'cd "{cwd}" && claude --resume {sessionId}'
71
+ # Nushell users typically want `;` instead of `&&`:
72
+ # copy_resume_command: 'cd "{cwd}"; claude --resume {sessionId}'
73
+
65
74
  # Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
66
75
  # Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
67
76
  # which opens the auth flow and prints the exact block to paste here. Bring your
package/CHANGELOG.md CHANGED
@@ -5,6 +5,105 @@ All notable changes to **tuiboard** are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
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
+
54
+ ## [0.8.5] - 2026-08-31
55
+
56
+ ### Added
57
+ - **`tuiboard summary` — JSON snapshot for status bars and scripts.** Totals, a
58
+ per-board breakdown, and `planner`: the same Today / Tomorrow / Overdue
59
+ aggregation the planner zone renders, built from `buildPlannerItems()` so a
60
+ bar widget and the dashboard can never disagree about what is due. `--pretty`
61
+ to read it, `--next N` to size (or drop) the per-board upcoming list.
62
+ - **`tuiboard task` — headless mutations.** `done`, `undone`, `defer` and `add`
63
+ against a board file, matched **by title rather than index** so a task that
64
+ moved is a miss instead of the wrong task. `--dry-run` reports without
65
+ writing; an ambiguous title is refused rather than guessed at; exit 3 means
66
+ the board changed on disk since it was read.
67
+ - **`undone` reopens a completed task**, dropping its `✅` date with the tick —
68
+ the exact inverse of `done`, and the same semantics as the TUI's Enter.
69
+ - **`defer` moves the date the planner actually reads** (`scheduled`, else
70
+ `due`, else adds a `scheduled`), so the row really moves. Defaults to
71
+ tomorrow; `--days N` or `--to YYYY-MM-DD` for anything else, `--days 0` to
72
+ pull a task back to today.
73
+ - **Planner entries in `summary` now report `done` and `doneDate`.** Today and
74
+ Tomorrow keep completed tasks — a day's plan is a record of the day — so
75
+ without this a consumer had no way to tell a ticked task from an open one.
76
+
77
+ ### Fixed
78
+ - **Node's warnings no longer scribble on the dashboard.** OpenTUI registers one
79
+ `selection` listener per `<scrollbox>`, and a full dashboard keeps more than
80
+ ten alive (one per board column, plus planner, timeline and agents), tripping
81
+ Node's default `MaxListeners` cap of 10 — usually when `Tab` mounted a new
82
+ board's columns. The warning went to stderr, which is the alternate screen the
83
+ renderer believes it owns: two lines scrolled the buffer and every repaint
84
+ after that landed rows off, so the layout appeared to break on a keypress. The
85
+ cap is now sized for the real number of zones, and any remaining warning is
86
+ filed in `~/.cache/tuiboard/warnings.log` instead of on the screen.
87
+
88
+ ## [0.8.4] - 2026-07-31
89
+
90
+ ### Added
91
+ - **Copy a session's resume command (`c` in the Agents zone).** Select a Claude
92
+ Code session and press `c` to copy a one-paste command that `cd`s into its
93
+ directory and resumes it — `cd "<cwd>" && claude --resume <id>` by default — so
94
+ you can drop it into any tab or pane, on any machine layout, without depending
95
+ on WezTerm (which `Enter` requires). The format is configurable via
96
+ `copy_resume_command` (tokens `{cwd}` / `{sessionId}`); Nushell users can swap
97
+ `&&` for `;`. The session detail view (`o`) now shows this exact command.
98
+
99
+ ### Fixed
100
+ - **Modals now appear in zoom mode.** Opening a modal (new task, schedule, time
101
+ block, assign, edit, delete, search, new event…) while a zone was zoomed (`z`)
102
+ set the modal state but rendered nothing — the zoomed layout had no Agenda slot
103
+ to host it, so the dialog was invisible and you typed blind. The modal now
104
+ floats as a centered overlay on top of the zoomed view; closing it returns you
105
+ to the zoomed view exactly as you left it.
106
+
8
107
  ## [0.8.3] - 2026-06-04
9
108
 
10
109
  ### Added
@@ -174,6 +273,9 @@ First public release on npm. This entry captures the full feature set at launch.
174
273
 
175
274
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
176
275
 
276
+ [0.9.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.9.0
277
+ [0.8.5]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.5
278
+ [0.8.4]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.4
177
279
  [0.8.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.3
178
280
  [0.8.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.2
179
281
  [0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
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
@@ -147,6 +160,12 @@ archive_column: Archive
147
160
  # path, NOT a shell builtin or Windows App Execution Alias). Defaults to
148
161
  # opening a WezTerm tab with `claude --resume <id>`. For a custom layout:
149
162
  # resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
163
+
164
+ # Optional: the command `c` copies to the clipboard in the Agents zone — one
165
+ # paste that cd's into the session dir and resumes it, so you can open it
166
+ # yourself in any tab/pane (no WezTerm needed). {cwd}/{sessionId} substituted.
167
+ # Default: 'cd "{cwd}" && claude --resume {sessionId}'. Nushell users:
168
+ # copy_resume_command: 'cd "{cwd}"; claude --resume {sessionId}'
150
169
  ```
151
170
 
152
171
  ## Zones
@@ -356,6 +375,7 @@ Launch `tuiboard` with no flag for the default dashboard (every enabled zone).
356
375
  | Flag | View | Use case |
357
376
  |---|---|---|
358
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 |
359
379
  | `--view=board` | Kanban + planner panel only | Focus mode, or a single WezTerm pane |
360
380
  | `--view=timeline` | Timeline fullscreen | Wall-mounted "what's now" |
361
381
  | `--view=agents` | Agent view fullscreen | Cross-machine session monitor |
@@ -367,7 +387,7 @@ The dashboard auto-collapses optional zones on narrow terminals:
367
387
  | ≥ 150 cols | planner + board + timeline + agents |
368
388
  | 120–149 | planner + board + agents |
369
389
  | 100–119 | planner + board |
370
- | < 100 | board only |
390
+ | < 100 | **single-pane**: one zone at a time, `h`/`l` to walk them, `Shift-Tab` to jump |
371
391
 
372
392
  `F1` / `F2` / `F3` toggles override the auto-collapse for the current
373
393
  session (until the next terminal resize).
@@ -383,6 +403,9 @@ session (until the next terminal resize).
383
403
  | `1`..`9` | Jump to board N |
384
404
  | `v` | Toggle Today/Tomorrow planner panel focus |
385
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 |
386
409
  | `F1` / `F2` / `F3` | Toggle visibility of Planner / Timeline / Agents zones |
387
410
  | `z` | Zoom active zone to full screen |
388
411
  | `r` | Refresh everything — reload boards from disk, rescan agents, force-refetch the agenda calendar (bypasses the 30-min cache) |
@@ -397,6 +420,15 @@ session (until the next terminal resize).
397
420
  | `j` / `k` | While armed: nudge the block ±15 min |
398
421
  | `+` / `-` | While armed: resize the block's end ±15 min |
399
422
 
423
+ ### Agents (agents zone)
424
+
425
+ | Key | Action |
426
+ |---|---|
427
+ | `j` / `k` | Move the cursor down / up the session list |
428
+ | `Enter` | Open (resume) the selected session in a new WezTerm tab |
429
+ | `c` | Copy a one-paste `cd … && claude --resume <id>` command for the selected session — drop it into any tab/pane to land in the right dir and resume (no WezTerm needed; format is `copy_resume_command`) |
430
+ | `o` | Session detail (cwd, branch, last prompts, resume command) |
431
+
400
432
  ### Task actions (work in board, planner, AND timeline zones)
401
433
 
402
434
  | Key | Action |
@@ -433,10 +465,79 @@ session (until the next terminal resize).
433
465
  | `?` | Help modal with the full reference |
434
466
  | `q` · `Ctrl-C` | Quit |
435
467
 
468
+ ## Headless commands
469
+
470
+ Two subcommands run without the TUI, for status bars, widgets and scripts.
471
+ Both reuse the same config loader and parser as the dashboard, so they can
472
+ never disagree with it about what is on your board.
473
+
474
+ ### `tuiboard summary` — JSON snapshot
475
+
476
+ ```bash
477
+ tuiboard summary # compact JSON on stdout
478
+ tuiboard summary --pretty # indented, for reading
479
+ tuiboard summary --next 8 # upcoming tasks per board (default 5, 0 = none)
480
+ ```
481
+
482
+ Returns totals, a per-board breakdown, and `planner` — the same Today /
483
+ Tomorrow / Overdue aggregation the planner zone renders, each entry carrying
484
+ its title, board, column, bucket, priority, dates, time block, assignee, and
485
+ whether it is already `done` (with `doneDate`). Today and Tomorrow keep
486
+ completed tasks, as the panel does: a day's plan is a record of the day, not
487
+ only of what is left.
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
+
504
+ ### `tuiboard task` — mutations
505
+
506
+ ```bash
507
+ tuiboard task done --board Personal --column Home --match "Bollette"
508
+ tuiboard task undone --board Personal --column Home --match "Bollette"
509
+ tuiboard task defer --board Personal --column Home --match "Bollette" [--days N | --to YYYY-MM-DD]
510
+ tuiboard task add --board Personal --column Home --text "Nuova task 🔺 ⏳ 2026-09-01"
511
+ ```
512
+
513
+ `--dry-run` reports what would change and writes nothing. `defer` defaults to
514
+ one day and moves the date the planner actually reads (`scheduled`, else
515
+ `due`, else adds a `scheduled`), so the row really moves; `--days 0` pulls a
516
+ task back to today.
517
+
518
+ Tasks are matched **by title, not by index**: a position is only valid inside
519
+ one render pass, and a widget polling every couple of minutes holds a stale
520
+ snapshot — matching on text makes a moved task a miss rather than a mistake.
521
+ An ambiguous match is refused rather than guessed at.
522
+
523
+ | Exit | Meaning |
524
+ |---|---|
525
+ | `0` | Done (including "already done" / "already open" — both are no-ops) |
526
+ | `1` | No match, ambiguous match, or unknown board/column |
527
+ | `2` | Bad arguments |
528
+ | `3` | The board changed on disk since it was read — refresh and retry |
529
+
530
+ Exit 3 is the mtime watermark: a write is refused rather than allowed to
531
+ clobber an edit made in the TUI or another editor in the meantime.
532
+
436
533
  ## Status
437
534
 
438
535
  See [CHANGELOG.md](CHANGELOG.md) for the full release history.
439
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.
440
541
  - **v0.8** — write to Google Calendar from the Agenda: create, edit, and delete
441
542
  events (opt-in), set their date and time in the modal, plus all-day events in
442
543
  the top strip, consistent `t`/`m` date shortcuts, and a boot splash.
@@ -451,6 +552,15 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history.
451
552
  responsive layout. Tested on Windows with WezTerm; Linux/macOS should
452
553
  work via the same OpenTUI binaries (untested).
453
554
 
555
+ ## Contributing
556
+
557
+ Contributions are welcome — bugs, docs, fixes, features, or just trying it on a
558
+ platform I haven't tested. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the
559
+ project layout, and the checks to run, and the [good first issues][gfi] for a
560
+ place to start.
561
+
562
+ [gfi]: https://github.com/NazzarenoGiannelli/tuiboard/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
563
+
454
564
  ## License
455
565
 
456
566
  MIT — see [LICENSE](LICENSE).
package/bin/tuiboard.ts CHANGED
@@ -29,6 +29,18 @@ if (process.argv[2] === "calendar-setup") {
29
29
  const { runCalendarSetup } = await import("../src/calendar/setup.ts");
30
30
  process.exit(await runCalendarSetup(process.argv.slice(3)));
31
31
  }
32
+ if (process.argv[2] === "summary") {
33
+ const { runSummary } = await import("../src/cli/summary.ts");
34
+ process.exit(await runSummary(process.argv.slice(3)));
35
+ }
36
+ if (process.argv[2] === "task") {
37
+ const { runTask } = await import("../src/cli/task.ts");
38
+ process.exit(await runTask(process.argv.slice(3)));
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
+ }
32
44
  const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
33
45
 
34
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.3",
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",
@@ -32,7 +32,10 @@
32
32
  "tb": "./bin/tuiboard.ts"
33
33
  },
34
34
  "files": [
35
+ "tsconfig.json",
35
36
  "src/",
37
+ "!src/**/*.test.ts",
38
+ "!src/scripts/",
36
39
  "bin/",
37
40
  ".tuiboard/config.example.yaml",
38
41
  "README.md",
@@ -47,6 +50,7 @@
47
50
  "typecheck": "tsc --noEmit",
48
51
  "test": "bun test",
49
52
  "agents:check": "bun run src/scripts/agents-check.ts",
53
+ "summary": "bun run src/cli/summary.ts",
50
54
  "prepublishOnly": "bun run typecheck && bun test"
51
55
  },
52
56
  "dependencies": {
package/src/app.tsx CHANGED
@@ -16,6 +16,11 @@
16
16
  // (OpenTUI) and the ~600ms store build below run. See ui/splash-boot.ts.
17
17
  import "~/ui/splash-boot";
18
18
 
19
+ import { EventEmitter } from "node:events";
20
+ import { appendFileSync, mkdirSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+
19
24
  import { createMemo } from "solid-js";
20
25
  import { render, useKeyboard } from "@opentui/solid";
21
26
 
@@ -35,22 +40,48 @@ import { Dashboard } from "~/views/Dashboard";
35
40
  import { TimelineOnly } from "~/views/TimelineOnly";
36
41
  import { AgentsOnly } from "~/views/AgentsOnly";
37
42
 
43
+ // ─── Keep Node's own output off the screen ──────────────────────────────────
44
+ //
45
+ // OpenTUI registers one "selection" listener on the renderer per <scrollbox>,
46
+ // and a dashboard keeps more than ten alive at once: one per board column,
47
+ // plus planner, timeline and agents. That is a legitimate count, not a leak,
48
+ // but it trips Node's default MaxListeners cap of 10 — and switching board
49
+ // with Tab crosses the threshold as the new columns mount.
50
+ EventEmitter.defaultMaxListeners = 64;
51
+
52
+ // Whatever still warns must never reach the terminal. Node writes warnings to
53
+ // stderr, which here means straight onto the alternate screen the renderer
54
+ // believes it owns: the two extra lines scroll the buffer, and from then on
55
+ // every absolutely-positioned repaint lands a couple of rows off — the layout
56
+ // appears to "break" on the next keypress. Removing the default handler stops
57
+ // Node printing them; ours files them where they can still be read.
58
+ process.removeAllListeners("warning");
59
+ process.on("warning", (w: Error) => {
60
+ try {
61
+ const dir = join(homedir(), ".cache", "tuiboard");
62
+ mkdirSync(dir, { recursive: true });
63
+ appendFileSync(
64
+ join(dir, "warnings.log"),
65
+ `${new Date().toISOString()} ${w.name}: ${w.message}\n${w.stack ?? ""}\n`,
66
+ );
67
+ } catch {
68
+ // A log we cannot write is not worth losing the screen over.
69
+ }
70
+ });
71
+
38
72
  // ─── Bootstrap ──────────────────────────────────────────────────────────────
39
73
 
40
74
  const config = loadConfig();
41
- if (config.boards.length === 0) {
42
- console.error(
43
- "No boards found. Create `.tuiboard/config.yaml` with a `boards:` list," +
44
- " or run from a directory containing markdown files with `- [ ]` tasks.",
45
- );
46
- process.exit(1);
47
- }
48
-
49
75
  const store = createTuiStore({ config });
50
76
 
51
- if (store.state.boards.length === 0) {
52
- console.error("All boards failed to load. Check paths in .tuiboard/config.yaml.");
53
- 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);
54
85
  }
55
86
 
56
87
  process.on("SIGINT", () => {
@@ -76,11 +107,20 @@ function applyResponsiveLayout(): void {
76
107
  // Report which zones FIT at this width. The store ANDs this with each zone's
77
108
  // enabled flag and the user's desired visibility, so a disabled or
78
109
  // intentionally-hidden zone is never force-shown just because there's room.
79
- store.applyResponsiveFits({
80
- planner: width >= 100,
81
- timeline: width >= 150,
82
- agents: width >= 120,
83
- });
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
+ );
84
124
  }
85
125
  applyResponsiveLayout();
86
126
  process.stdout.on("resize", applyResponsiveLayout);
@@ -88,16 +128,29 @@ process.stdout.on("resize", applyResponsiveLayout);
88
128
  // Land on the Today/Tomorrow panel by default — for a daily-planning tool the
89
129
  // first question is "what's on my plate today", and that panel answers it. On
90
130
  // a narrow terminal where the panel auto-hides, fall back to the board.
91
- if (store.state.ui.visibleZones.planner) {
131
+ if (!needsOnboarding && store.state.ui.visibleZones.planner) {
92
132
  store.setActiveZone("planner");
93
133
  }
94
134
 
135
+
95
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
+ }
96
145
 
97
146
  // ─── App shell ──────────────────────────────────────────────────────────────
98
147
 
99
148
  function rootViewFor(v: ViewKind | undefined, s: TuiStore) {
100
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} />;
101
154
  case "board": return <BoardOnly store={s} />;
102
155
  case "timeline": return <TimelineOnly store={s} />;
103
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
+ }