tuiboard 0.8.2 → 0.8.5
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 +69 -0
- package/README.md +77 -0
- package/bin/tuiboard.ts +62 -5
- package/package.json +3 -1
- package/src/app.tsx +52 -0
- package/src/cli/headless.test.ts +202 -0
- package/src/cli/summary.ts +284 -0
- package/src/cli/task.ts +235 -0
- package/src/config/loader.ts +23 -0
- package/src/input/handleKey.ts +14 -0
- package/src/store/index.test.ts +1 -0
- package/src/ui/Modal.tsx +5 -2
- package/src/ui/splash-boot.ts +21 -0
- package/src/ui/splash.ts +133 -0
- package/src/views/Dashboard.tsx +35 -7
- package/tsconfig.json +27 -0
|
@@ -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,72 @@ 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.8.5] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`tuiboard summary` — JSON snapshot for status bars and scripts.** Totals, a
|
|
14
|
+
per-board breakdown, and `planner`: the same Today / Tomorrow / Overdue
|
|
15
|
+
aggregation the planner zone renders, built from `buildPlannerItems()` so a
|
|
16
|
+
bar widget and the dashboard can never disagree about what is due. `--pretty`
|
|
17
|
+
to read it, `--next N` to size (or drop) the per-board upcoming list.
|
|
18
|
+
- **`tuiboard task` — headless mutations.** `done`, `undone`, `defer` and `add`
|
|
19
|
+
against a board file, matched **by title rather than index** so a task that
|
|
20
|
+
moved is a miss instead of the wrong task. `--dry-run` reports without
|
|
21
|
+
writing; an ambiguous title is refused rather than guessed at; exit 3 means
|
|
22
|
+
the board changed on disk since it was read.
|
|
23
|
+
- **`undone` reopens a completed task**, dropping its `✅` date with the tick —
|
|
24
|
+
the exact inverse of `done`, and the same semantics as the TUI's Enter.
|
|
25
|
+
- **`defer` moves the date the planner actually reads** (`scheduled`, else
|
|
26
|
+
`due`, else adds a `scheduled`), so the row really moves. Defaults to
|
|
27
|
+
tomorrow; `--days N` or `--to YYYY-MM-DD` for anything else, `--days 0` to
|
|
28
|
+
pull a task back to today.
|
|
29
|
+
- **Planner entries in `summary` now report `done` and `doneDate`.** Today and
|
|
30
|
+
Tomorrow keep completed tasks — a day's plan is a record of the day — so
|
|
31
|
+
without this a consumer had no way to tell a ticked task from an open one.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **Node's warnings no longer scribble on the dashboard.** OpenTUI registers one
|
|
35
|
+
`selection` listener per `<scrollbox>`, and a full dashboard keeps more than
|
|
36
|
+
ten alive (one per board column, plus planner, timeline and agents), tripping
|
|
37
|
+
Node's default `MaxListeners` cap of 10 — usually when `Tab` mounted a new
|
|
38
|
+
board's columns. The warning went to stderr, which is the alternate screen the
|
|
39
|
+
renderer believes it owns: two lines scrolled the buffer and every repaint
|
|
40
|
+
after that landed rows off, so the layout appeared to break on a keypress. The
|
|
41
|
+
cap is now sized for the real number of zones, and any remaining warning is
|
|
42
|
+
filed in `~/.cache/tuiboard/warnings.log` instead of on the screen.
|
|
43
|
+
|
|
44
|
+
## [0.8.4] - 2026-07-31
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- **Copy a session's resume command (`c` in the Agents zone).** Select a Claude
|
|
48
|
+
Code session and press `c` to copy a one-paste command that `cd`s into its
|
|
49
|
+
directory and resumes it — `cd "<cwd>" && claude --resume <id>` by default — so
|
|
50
|
+
you can drop it into any tab or pane, on any machine layout, without depending
|
|
51
|
+
on WezTerm (which `Enter` requires). The format is configurable via
|
|
52
|
+
`copy_resume_command` (tokens `{cwd}` / `{sessionId}`); Nushell users can swap
|
|
53
|
+
`&&` for `;`. The session detail view (`o`) now shows this exact command.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- **Modals now appear in zoom mode.** Opening a modal (new task, schedule, time
|
|
57
|
+
block, assign, edit, delete, search, new event…) while a zone was zoomed (`z`)
|
|
58
|
+
set the modal state but rendered nothing — the zoomed layout had no Agenda slot
|
|
59
|
+
to host it, so the dialog was invisible and you typed blind. The modal now
|
|
60
|
+
floats as a centered overlay on top of the zoomed view; closing it returns you
|
|
61
|
+
to the zoomed view exactly as you left it.
|
|
62
|
+
|
|
63
|
+
## [0.8.3] - 2026-06-04
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
- **Boot splash.** Launching tuiboard now paints a `tuiboard` wordmark (FIGlet
|
|
67
|
+
"Rectangles", in the tool's light-yellow accent) the instant the process
|
|
68
|
+
starts, so the ~1s cold start (runtime + store build + first calendar/agents
|
|
69
|
+
read) isn't a blank terminal. The launcher animates the booting dots while the
|
|
70
|
+
dashboard process loads in parallel, then hands the screen over cleanly — no
|
|
71
|
+
startup time added. Set `TUIBOARD_NO_SPLASH=1` to disable; it also no-ops when
|
|
72
|
+
output isn't a TTY or the terminal is tiny.
|
|
73
|
+
|
|
8
74
|
## [0.8.2] - 2026-06-04
|
|
9
75
|
|
|
10
76
|
### Changed
|
|
@@ -163,6 +229,9 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
163
229
|
|
|
164
230
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
165
231
|
|
|
232
|
+
[0.8.5]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.5
|
|
233
|
+
[0.8.4]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.4
|
|
234
|
+
[0.8.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.3
|
|
166
235
|
[0.8.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.2
|
|
167
236
|
[0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
|
|
168
237
|
[0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
|
package/README.md
CHANGED
|
@@ -147,6 +147,12 @@ archive_column: Archive
|
|
|
147
147
|
# path, NOT a shell builtin or Windows App Execution Alias). Defaults to
|
|
148
148
|
# opening a WezTerm tab with `claude --resume <id>`. For a custom layout:
|
|
149
149
|
# resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
|
|
150
|
+
|
|
151
|
+
# Optional: the command `c` copies to the clipboard in the Agents zone — one
|
|
152
|
+
# paste that cd's into the session dir and resumes it, so you can open it
|
|
153
|
+
# yourself in any tab/pane (no WezTerm needed). {cwd}/{sessionId} substituted.
|
|
154
|
+
# Default: 'cd "{cwd}" && claude --resume {sessionId}'. Nushell users:
|
|
155
|
+
# copy_resume_command: 'cd "{cwd}"; claude --resume {sessionId}'
|
|
150
156
|
```
|
|
151
157
|
|
|
152
158
|
## Zones
|
|
@@ -397,6 +403,15 @@ session (until the next terminal resize).
|
|
|
397
403
|
| `j` / `k` | While armed: nudge the block ±15 min |
|
|
398
404
|
| `+` / `-` | While armed: resize the block's end ±15 min |
|
|
399
405
|
|
|
406
|
+
### Agents (agents zone)
|
|
407
|
+
|
|
408
|
+
| Key | Action |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `j` / `k` | Move the cursor down / up the session list |
|
|
411
|
+
| `Enter` | Open (resume) the selected session in a new WezTerm tab |
|
|
412
|
+
| `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`) |
|
|
413
|
+
| `o` | Session detail (cwd, branch, last prompts, resume command) |
|
|
414
|
+
|
|
400
415
|
### Task actions (work in board, planner, AND timeline zones)
|
|
401
416
|
|
|
402
417
|
| Key | Action |
|
|
@@ -433,10 +448,63 @@ session (until the next terminal resize).
|
|
|
433
448
|
| `?` | Help modal with the full reference |
|
|
434
449
|
| `q` · `Ctrl-C` | Quit |
|
|
435
450
|
|
|
451
|
+
## Headless commands
|
|
452
|
+
|
|
453
|
+
Two subcommands run without the TUI, for status bars, widgets and scripts.
|
|
454
|
+
Both reuse the same config loader and parser as the dashboard, so they can
|
|
455
|
+
never disagree with it about what is on your board.
|
|
456
|
+
|
|
457
|
+
### `tuiboard summary` — JSON snapshot
|
|
458
|
+
|
|
459
|
+
```bash
|
|
460
|
+
tuiboard summary # compact JSON on stdout
|
|
461
|
+
tuiboard summary --pretty # indented, for reading
|
|
462
|
+
tuiboard summary --next 8 # upcoming tasks per board (default 5, 0 = none)
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Returns totals, a per-board breakdown, and `planner` — the same Today /
|
|
466
|
+
Tomorrow / Overdue aggregation the planner zone renders, each entry carrying
|
|
467
|
+
its title, board, column, bucket, priority, dates, time block, assignee, and
|
|
468
|
+
whether it is already `done` (with `doneDate`). Today and Tomorrow keep
|
|
469
|
+
completed tasks, as the panel does: a day's plan is a record of the day, not
|
|
470
|
+
only of what is left.
|
|
471
|
+
|
|
472
|
+
### `tuiboard task` — mutations
|
|
473
|
+
|
|
474
|
+
```bash
|
|
475
|
+
tuiboard task done --board Personal --column Home --match "Bollette"
|
|
476
|
+
tuiboard task undone --board Personal --column Home --match "Bollette"
|
|
477
|
+
tuiboard task defer --board Personal --column Home --match "Bollette" [--days N | --to YYYY-MM-DD]
|
|
478
|
+
tuiboard task add --board Personal --column Home --text "Nuova task 🔺 ⏳ 2026-09-01"
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
`--dry-run` reports what would change and writes nothing. `defer` defaults to
|
|
482
|
+
one day and moves the date the planner actually reads (`scheduled`, else
|
|
483
|
+
`due`, else adds a `scheduled`), so the row really moves; `--days 0` pulls a
|
|
484
|
+
task back to today.
|
|
485
|
+
|
|
486
|
+
Tasks are matched **by title, not by index**: a position is only valid inside
|
|
487
|
+
one render pass, and a widget polling every couple of minutes holds a stale
|
|
488
|
+
snapshot — matching on text makes a moved task a miss rather than a mistake.
|
|
489
|
+
An ambiguous match is refused rather than guessed at.
|
|
490
|
+
|
|
491
|
+
| Exit | Meaning |
|
|
492
|
+
|---|---|
|
|
493
|
+
| `0` | Done (including "already done" / "already open" — both are no-ops) |
|
|
494
|
+
| `1` | No match, ambiguous match, or unknown board/column |
|
|
495
|
+
| `2` | Bad arguments |
|
|
496
|
+
| `3` | The board changed on disk since it was read — refresh and retry |
|
|
497
|
+
|
|
498
|
+
Exit 3 is the mtime watermark: a write is refused rather than allowed to
|
|
499
|
+
clobber an edit made in the TUI or another editor in the meantime.
|
|
500
|
+
|
|
436
501
|
## Status
|
|
437
502
|
|
|
438
503
|
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
439
504
|
|
|
505
|
+
- **v0.8** — write to Google Calendar from the Agenda: create, edit, and delete
|
|
506
|
+
events (opt-in), set their date and time in the modal, plus all-day events in
|
|
507
|
+
the top strip, consistent `t`/`m` date shortcuts, and a boot splash.
|
|
440
508
|
- **v0.7** — configurable zones: turn the planner, agenda, or agents view off
|
|
441
509
|
(or start it collapsed) via the `zones:` config, so tuiboard can be a pure
|
|
442
510
|
kanban, kanban + calendar, or any mix.
|
|
@@ -448,6 +516,15 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
|
448
516
|
responsive layout. Tested on Windows with WezTerm; Linux/macOS should
|
|
449
517
|
work via the same OpenTUI binaries (untested).
|
|
450
518
|
|
|
519
|
+
## Contributing
|
|
520
|
+
|
|
521
|
+
Contributions are welcome — bugs, docs, fixes, features, or just trying it on a
|
|
522
|
+
platform I haven't tested. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the
|
|
523
|
+
project layout, and the checks to run, and the [good first issues][gfi] for a
|
|
524
|
+
place to start.
|
|
525
|
+
|
|
526
|
+
[gfi]: https://github.com/NazzarenoGiannelli/tuiboard/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
|
|
527
|
+
|
|
451
528
|
## License
|
|
452
529
|
|
|
453
530
|
MIT — see [LICENSE](LICENSE).
|
package/bin/tuiboard.ts
CHANGED
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
* any CLI args, and inherit stdio so the TUI keeps the real terminal.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
15
17
|
import { dirname, join } from "node:path";
|
|
16
18
|
import { fileURLToPath } from "node:url";
|
|
17
19
|
|
|
20
|
+
import pkg from "../package.json";
|
|
21
|
+
import { animateBooting, printSplash, showCursor } from "../src/ui/splash.ts";
|
|
22
|
+
|
|
18
23
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
24
|
const appPath = join(here, "..", "src", "app.tsx");
|
|
20
25
|
|
|
@@ -24,12 +29,64 @@ if (process.argv[2] === "calendar-setup") {
|
|
|
24
29
|
const { runCalendarSetup } = await import("../src/calendar/setup.ts");
|
|
25
30
|
process.exit(await runCalendarSetup(process.argv.slice(3)));
|
|
26
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
|
+
}
|
|
27
40
|
const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
// Paint the splash from the (already-running) launcher and animate its booting
|
|
43
|
+
// dots while the child cold-starts. Using `spawn` (not `spawnSync`) keeps this
|
|
44
|
+
// process's event loop free to run the animation. We MUST stop animating the
|
|
45
|
+
// instant before the child enters OpenTUI's alternate screen, or our writes
|
|
46
|
+
// would land on the dashboard — so the child drops a "ready" flag file just
|
|
47
|
+
// before render() and we poll for it.
|
|
48
|
+
printSplash(pkg.version);
|
|
49
|
+
const stopAnim = animateBooting(pkg.version);
|
|
50
|
+
// The splash hides the cursor; make sure it comes back when the launcher exits
|
|
51
|
+
// (after the child has torn down), so the shell is never left cursor-less.
|
|
52
|
+
process.on("exit", showCursor);
|
|
53
|
+
const readyFlag = join(tmpdir(), `tuiboard-ready-${process.pid}`);
|
|
54
|
+
try { rmSync(readyFlag, { force: true }); } catch { /* ignore */ }
|
|
55
|
+
|
|
56
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
57
|
+
let safety: ReturnType<typeof setTimeout> | undefined;
|
|
58
|
+
let stopped = false;
|
|
59
|
+
const stopSplash = () => {
|
|
60
|
+
if (stopped) return;
|
|
61
|
+
stopped = true;
|
|
62
|
+
stopAnim();
|
|
63
|
+
if (poll) clearInterval(poll);
|
|
64
|
+
if (safety) clearTimeout(safety);
|
|
65
|
+
try { rmSync(readyFlag, { force: true }); } catch { /* ignore */ }
|
|
66
|
+
};
|
|
67
|
+
poll = setInterval(() => { if (existsSync(readyFlag)) stopSplash(); }, 30);
|
|
68
|
+
safety = setTimeout(stopSplash, 4000); // fallback if the child never signals
|
|
69
|
+
|
|
70
|
+
// Ctrl-C reaches the child directly (same process group); it cleans up and
|
|
71
|
+
// exits, then we mirror its code below. Ignore the signal here so the launcher
|
|
72
|
+
// doesn't die first and orphan the child mid-teardown.
|
|
73
|
+
process.on("SIGINT", () => {});
|
|
74
|
+
process.on("SIGTERM", () => {});
|
|
75
|
+
|
|
76
|
+
const child = spawn(
|
|
30
77
|
process.execPath, // the bun binary running this script
|
|
31
78
|
["--preload", preload, appPath, ...process.argv.slice(2)],
|
|
32
|
-
{
|
|
79
|
+
{
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
env: { ...process.env, TUIBOARD_SPLASH_DONE: "1", TUIBOARD_READY_FLAG: readyFlag },
|
|
82
|
+
},
|
|
33
83
|
);
|
|
34
|
-
|
|
35
|
-
|
|
84
|
+
child.on("exit", (code, signal) => {
|
|
85
|
+
stopSplash();
|
|
86
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
87
|
+
});
|
|
88
|
+
child.on("error", (err) => {
|
|
89
|
+
stopSplash();
|
|
90
|
+
console.error(String(err));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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,6 +32,7 @@
|
|
|
32
32
|
"tb": "./bin/tuiboard.ts"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
|
+
"tsconfig.json",
|
|
35
36
|
"src/",
|
|
36
37
|
"bin/",
|
|
37
38
|
".tuiboard/config.example.yaml",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"typecheck": "tsc --noEmit",
|
|
48
49
|
"test": "bun test",
|
|
49
50
|
"agents:check": "bun run src/scripts/agents-check.ts",
|
|
51
|
+
"summary": "bun run src/cli/summary.ts",
|
|
50
52
|
"prepublishOnly": "bun run typecheck && bun test"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
package/src/app.tsx
CHANGED
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
* root layout component changes.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
// FIRST import on purpose: paints the boot splash before the heavy imports
|
|
16
|
+
// (OpenTUI) and the ~600ms store build below run. See ui/splash-boot.ts.
|
|
17
|
+
import "~/ui/splash-boot";
|
|
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
|
+
|
|
15
24
|
import { createMemo } from "solid-js";
|
|
16
25
|
import { render, useKeyboard } from "@opentui/solid";
|
|
17
26
|
|
|
@@ -31,6 +40,35 @@ import { Dashboard } from "~/views/Dashboard";
|
|
|
31
40
|
import { TimelineOnly } from "~/views/TimelineOnly";
|
|
32
41
|
import { AgentsOnly } from "~/views/AgentsOnly";
|
|
33
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
|
+
|
|
34
72
|
// ─── Bootstrap ──────────────────────────────────────────────────────────────
|
|
35
73
|
|
|
36
74
|
const config = loadConfig();
|
|
@@ -150,4 +188,18 @@ function App() {
|
|
|
150
188
|
);
|
|
151
189
|
}
|
|
152
190
|
|
|
191
|
+
// Signal the launcher (if we were spawned by the `tuiboard` bin) that we're
|
|
192
|
+
// about to take the screen, so it stops animating the splash a beat before
|
|
193
|
+
// OpenTUI enters the alternate buffer — otherwise its writes would land on the
|
|
194
|
+
// dashboard. The short delay gives the launcher's poll a cycle to notice.
|
|
195
|
+
if (process.env.TUIBOARD_READY_FLAG) {
|
|
196
|
+
try {
|
|
197
|
+
const { writeFileSync } = await import("node:fs");
|
|
198
|
+
writeFileSync(process.env.TUIBOARD_READY_FLAG, "1");
|
|
199
|
+
await new Promise((r) => setTimeout(r, 70));
|
|
200
|
+
} catch {
|
|
201
|
+
// Cosmetic only — never block startup on the splash handshake.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
153
205
|
await render(() => <App />, { useMouse: true });
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless CLI — `tuiboard task` and `tuiboard summary`.
|
|
3
|
+
*
|
|
4
|
+
* These are the only commands that write to the user's real board files from
|
|
5
|
+
* outside the TUI (a bar widget, a cron job), so they are tested against a
|
|
6
|
+
* real board on disk rather than a parsed fixture: the round trip through
|
|
7
|
+
* parse → mutate → serialize → write is exactly what can lose data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
|
|
16
|
+
import { buildSummary } from "./summary";
|
|
17
|
+
import { runTask } from "./task";
|
|
18
|
+
|
|
19
|
+
const BOARD = `---
|
|
20
|
+
|
|
21
|
+
kanban-plugin: board
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Home
|
|
26
|
+
- [ ] Bollette ⏳ 2026-08-31
|
|
27
|
+
- [x] Spesa ⏳ 2026-08-31 ✅ 2026-08-31
|
|
28
|
+
- [ ] Chiamare idraulico 📅 2026-08-31
|
|
29
|
+
- [ ] Cambiare gomme
|
|
30
|
+
- [ ] Ambiguo uno
|
|
31
|
+
- [ ] Ambiguo due
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
let dir: string;
|
|
35
|
+
let boardPath: string;
|
|
36
|
+
let configPath: string;
|
|
37
|
+
let previousConfig: string | undefined;
|
|
38
|
+
|
|
39
|
+
/** Days from today as YYYY-MM-DD, in local time — matches the CLI's own clock. */
|
|
40
|
+
function iso(offsetDays: number): string {
|
|
41
|
+
const d = new Date();
|
|
42
|
+
d.setDate(d.getDate() + offsetDays);
|
|
43
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
44
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function board(): string {
|
|
48
|
+
return readFileSync(boardPath, "utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function line(title: string): string {
|
|
52
|
+
const hit = board()
|
|
53
|
+
.split("\n")
|
|
54
|
+
.find((l) => l.includes(title));
|
|
55
|
+
if (!hit) throw new Error(`no line matching "${title}" in board`);
|
|
56
|
+
return hit;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
dir = mkdtempSync(join(tmpdir(), "tuiboard-cli-"));
|
|
61
|
+
boardPath = join(dir, "Board.md");
|
|
62
|
+
configPath = join(dir, "config.yaml");
|
|
63
|
+
writeFileSync(boardPath, BOARD, "utf-8");
|
|
64
|
+
writeFileSync(configPath, `boards:\n - path: ${boardPath}\n name: Test\n`, "utf-8");
|
|
65
|
+
previousConfig = process.env.TUIBOARD_CONFIG;
|
|
66
|
+
process.env.TUIBOARD_CONFIG = configPath;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
if (previousConfig === undefined) delete process.env.TUIBOARD_CONFIG;
|
|
71
|
+
else process.env.TUIBOARD_CONFIG = previousConfig;
|
|
72
|
+
rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const args = (...rest: string[]) => ["--board", "Test", "--column", "Home", ...rest];
|
|
76
|
+
|
|
77
|
+
describe("tuiboard task done", () => {
|
|
78
|
+
it("ticks the task and stamps today's completion date", async () => {
|
|
79
|
+
expect(await runTask(["done", ...args("--match", "Bollette")])).toBe(0);
|
|
80
|
+
expect(line("Bollette")).toBe(`- [x] Bollette ⏳ 2026-08-31 ✅ ${iso(0)}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("leaves an already-done task untouched", async () => {
|
|
84
|
+
const before = line("Spesa");
|
|
85
|
+
expect(await runTask(["done", ...args("--match", "Spesa")])).toBe(0);
|
|
86
|
+
expect(line("Spesa")).toBe(before);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("tuiboard task undone", () => {
|
|
91
|
+
it("reopens a completed task and drops the completion date with it", async () => {
|
|
92
|
+
expect(await runTask(["undone", ...args("--match", "Spesa")])).toBe(0);
|
|
93
|
+
expect(line("Spesa")).toBe("- [ ] Spesa ⏳ 2026-08-31");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("is the exact inverse of done — round trip restores the line", async () => {
|
|
97
|
+
const before = line("Bollette");
|
|
98
|
+
expect(await runTask(["done", ...args("--match", "Bollette")])).toBe(0);
|
|
99
|
+
expect(await runTask(["undone", ...args("--match", "Bollette")])).toBe(0);
|
|
100
|
+
expect(line("Bollette")).toBe(before);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves an already-open task untouched", async () => {
|
|
104
|
+
const before = line("Bollette");
|
|
105
|
+
expect(await runTask(["undone", ...args("--match", "Bollette")])).toBe(0);
|
|
106
|
+
expect(line("Bollette")).toBe(before);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("tuiboard task defer", () => {
|
|
111
|
+
it("moves `scheduled` when the task has one", async () => {
|
|
112
|
+
expect(await runTask(["defer", ...args("--match", "Bollette")])).toBe(0);
|
|
113
|
+
expect(line("Bollette")).toBe(`- [ ] Bollette ⏳ ${iso(1)}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("moves `due` when that is the only date — the field the planner reads", async () => {
|
|
117
|
+
expect(await runTask(["defer", ...args("--match", "idraulico")])).toBe(0);
|
|
118
|
+
expect(line("idraulico")).toBe(`- [ ] Chiamare idraulico 📅 ${iso(1)}`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("schedules an undated task, which is what puts it on the agenda", async () => {
|
|
122
|
+
expect(await runTask(["defer", ...args("--match", "gomme")])).toBe(0);
|
|
123
|
+
expect(line("gomme")).toBe(`- [ ] Cambiare gomme ⏳ ${iso(1)}`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("--days 0 pulls a task back to today", async () => {
|
|
127
|
+
expect(await runTask(["defer", ...args("--match", "Bollette", "--days", "0")])).toBe(0);
|
|
128
|
+
expect(line("Bollette")).toBe(`- [ ] Bollette ⏳ ${iso(0)}`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("--to takes an explicit date", async () => {
|
|
132
|
+
expect(await runTask(["defer", ...args("--match", "Bollette", "--to", "2027-01-15")])).toBe(0);
|
|
133
|
+
expect(line("Bollette")).toBe("- [ ] Bollette ⏳ 2027-01-15");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects a malformed --to without touching the board", async () => {
|
|
137
|
+
const before = board();
|
|
138
|
+
expect(await runTask(["defer", ...args("--match", "Bollette", "--to", "15/01/2027")])).toBe(2);
|
|
139
|
+
expect(board()).toBe(before);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("tuiboard task — refusing to guess", () => {
|
|
144
|
+
it("writes nothing when the match is ambiguous", async () => {
|
|
145
|
+
const before = board();
|
|
146
|
+
expect(await runTask(["done", ...args("--match", "Ambiguo")])).toBe(1);
|
|
147
|
+
expect(board()).toBe(before);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("writes nothing when nothing matches", async () => {
|
|
151
|
+
const before = board();
|
|
152
|
+
expect(await runTask(["done", ...args("--match", "inesistente")])).toBe(1);
|
|
153
|
+
expect(board()).toBe(before);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("--dry-run reports success and leaves the file alone", async () => {
|
|
157
|
+
const before = board();
|
|
158
|
+
expect(await runTask(["done", ...args("--match", "Bollette", "--dry-run")])).toBe(0);
|
|
159
|
+
expect(board()).toBe(before);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("refuses the write when the board moved on since it was read", () => {
|
|
163
|
+
// What `runTask` maps to exit 3. Driven through the writer directly: the
|
|
164
|
+
// race it guards against — the file changing between the read and the
|
|
165
|
+
// write — cannot be staged from outside the function that spans it.
|
|
166
|
+
const stale = statMtime(boardPath);
|
|
167
|
+
const past = new Date(Date.now() - 60_000);
|
|
168
|
+
utimesSync(boardPath, past, past);
|
|
169
|
+
const before = board();
|
|
170
|
+
expect(() => writeBoardFile(boardPath, "clobbered", { expectedMtimeMs: stale }))
|
|
171
|
+
.toThrow(ConflictError);
|
|
172
|
+
expect(board()).toBe(before);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("tuiboard summary — planner entries", () => {
|
|
177
|
+
it("reports whether a Today entry is already ticked", async () => {
|
|
178
|
+
// The board's dates are fixed, so the summary is asked for that same day.
|
|
179
|
+
const s = buildSummary({ next: 0, today: "2026-08-31" });
|
|
180
|
+
const today = s.planner.today;
|
|
181
|
+
const bollette = today.find((e) => e.title === "Bollette");
|
|
182
|
+
const spesa = today.find((e) => e.title === "Spesa");
|
|
183
|
+
|
|
184
|
+
expect(bollette?.done).toBe(false);
|
|
185
|
+
expect(bollette?.doneDate).toBeUndefined();
|
|
186
|
+
expect(spesa?.done).toBe(true);
|
|
187
|
+
expect(spesa?.doneDate).toBe("2026-08-31");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("follows a task through done and back", async () => {
|
|
191
|
+
await runTask(["done", ...args("--match", "Bollette")]);
|
|
192
|
+
const done = buildSummary({ next: 0, today: "2026-08-31" })
|
|
193
|
+
.planner.today.find((e) => e.title === "Bollette");
|
|
194
|
+
expect(done?.done).toBe(true);
|
|
195
|
+
|
|
196
|
+
await runTask(["undone", ...args("--match", "Bollette")]);
|
|
197
|
+
const reopened = buildSummary({ next: 0, today: "2026-08-31" })
|
|
198
|
+
.planner.today.find((e) => e.title === "Bollette");
|
|
199
|
+
expect(reopened?.done).toBe(false);
|
|
200
|
+
expect(reopened?.doneDate).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|