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.
- package/.tuiboard/config.example.yaml +9 -0
- package/CHANGELOG.md +102 -0
- package/README.md +111 -1
- package/bin/tuiboard.ts +12 -0
- package/package.json +5 -1
- package/src/app.tsx +70 -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/cli/summary.ts +284 -0
- package/src/cli/task.ts +235 -0
- package/src/config/loader.ts +60 -13
- package/src/input/handleKey.ts +72 -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 +133 -2
- 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 +36 -8
- package/tsconfig.json +27 -0
- package/src/cli/args.test.ts +0 -40
- 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 -224
- 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
|
@@ -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 |
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|