tuiboard 0.8.3 → 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 +57 -0
- package/README.md +74 -0
- package/bin/tuiboard.ts +8 -0
- package/package.json +3 -1
- package/src/app.tsx +34 -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/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,61 @@ 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
|
+
|
|
8
63
|
## [0.8.3] - 2026-06-04
|
|
9
64
|
|
|
10
65
|
### Added
|
|
@@ -174,6 +229,8 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
174
229
|
|
|
175
230
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
176
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
|
|
177
234
|
[0.8.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.3
|
|
178
235
|
[0.8.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.2
|
|
179
236
|
[0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
|
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,6 +448,56 @@ 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.
|
|
@@ -451,6 +516,15 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
|
451
516
|
responsive layout. Tested on Windows with WezTerm; Linux/macOS should
|
|
452
517
|
work via the same OpenTUI binaries (untested).
|
|
453
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
|
+
|
|
454
528
|
## License
|
|
455
529
|
|
|
456
530
|
MIT — see [LICENSE](LICENSE).
|
package/bin/tuiboard.ts
CHANGED
|
@@ -29,6 +29,14 @@ 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
|
+
}
|
|
32
40
|
const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
|
|
33
41
|
|
|
34
42
|
// 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
|
+
"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
|
@@ -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,6 +40,35 @@ 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();
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless JSON snapshot of the configured boards.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* tuiboard summary # compact JSON on stdout
|
|
6
|
+
* tuiboard summary --pretty # indented, for humans
|
|
7
|
+
* tuiboard summary --next 8 # how many upcoming tasks per board (default 5)
|
|
8
|
+
*
|
|
9
|
+
* Written for status bars, widgets and scripts: no TUI, no OpenTUI preload.
|
|
10
|
+
* It deliberately reuses `loadConfig` and `parseBoard` rather than re-reading
|
|
11
|
+
* the markdown itself, so these numbers can never drift from what the
|
|
12
|
+
* dashboard shows — the parser stays the single source of truth.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
import { isHiddenColumn, loadConfig } from "~/config/loader";
|
|
18
|
+
import { isTask, parseBoard } from "~/parser/markdown";
|
|
19
|
+
import { buildPlannerItems, type PlannerSection } from "~/store/planner-panel";
|
|
20
|
+
import type { Board, PriorityLevel, Task } from "~/types";
|
|
21
|
+
|
|
22
|
+
/** Lower sorts first, so "highest" leads an ascending sort. */
|
|
23
|
+
const PRIORITY_RANK: Record<PriorityLevel, number> = {
|
|
24
|
+
highest: 0,
|
|
25
|
+
high: 1,
|
|
26
|
+
medium: 2,
|
|
27
|
+
low: 3,
|
|
28
|
+
lowest: 4,
|
|
29
|
+
none: 5,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface SummaryTask {
|
|
33
|
+
title: string;
|
|
34
|
+
board: string;
|
|
35
|
+
column: string;
|
|
36
|
+
priority: PriorityLevel;
|
|
37
|
+
due?: string;
|
|
38
|
+
scheduled?: string;
|
|
39
|
+
assignee?: string;
|
|
40
|
+
tags: string[];
|
|
41
|
+
/** Negative when overdue, 0 today, positive in the future, null when undated. */
|
|
42
|
+
daysUntil: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SummaryColumn {
|
|
46
|
+
name: string;
|
|
47
|
+
open: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SummaryBoard {
|
|
51
|
+
name: string;
|
|
52
|
+
path: string;
|
|
53
|
+
open: number;
|
|
54
|
+
done: number;
|
|
55
|
+
overdue: number;
|
|
56
|
+
today: number;
|
|
57
|
+
columns: SummaryColumn[];
|
|
58
|
+
next: SummaryTask[];
|
|
59
|
+
/** Parser complaints, so a malformed board is visible instead of silently empty. */
|
|
60
|
+
diagnostics: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One row of the Today/Tomorrow planner, flattened for consumers. */
|
|
64
|
+
export interface PlannerEntry {
|
|
65
|
+
title: string;
|
|
66
|
+
board: string;
|
|
67
|
+
column: string;
|
|
68
|
+
/** "agenda" = time-blocked, "priority" = unscheduled priority, "rest" = everything else. */
|
|
69
|
+
bucket: string;
|
|
70
|
+
priority: PriorityLevel;
|
|
71
|
+
due?: string;
|
|
72
|
+
scheduled?: string;
|
|
73
|
+
timeBlock?: string;
|
|
74
|
+
assignee?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Whether the task is already ticked. Today/Tomorrow keep completed tasks —
|
|
77
|
+
* the day's plan is a record of the day, not just of what is left — so a
|
|
78
|
+
* consumer that omits this renders a done task identically to an open one.
|
|
79
|
+
*/
|
|
80
|
+
done: boolean;
|
|
81
|
+
/** Completion date, when the task carries one (✅ YYYY-MM-DD). */
|
|
82
|
+
doneDate?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Summary {
|
|
86
|
+
generatedAt: string;
|
|
87
|
+
totals: { open: number; done: number; overdue: number; today: number };
|
|
88
|
+
boards: SummaryBoard[];
|
|
89
|
+
/**
|
|
90
|
+
* The same Today/Tomorrow aggregation the TUI renders in its left column.
|
|
91
|
+
* Built with buildPlannerItems() rather than re-derived here, so a bar
|
|
92
|
+
* widget and the dashboard can never disagree about what is due.
|
|
93
|
+
*/
|
|
94
|
+
planner: Record<PlannerSection, PlannerEntry[]>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Local calendar date as YYYY-MM-DD — never UTC, or "today" flips at the wrong hour. */
|
|
98
|
+
function localToday(now = new Date()): string {
|
|
99
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
100
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whole days between two YYYY-MM-DD dates, midday-anchored to dodge DST. */
|
|
104
|
+
function daysBetween(from: string, to: string): number {
|
|
105
|
+
const at = (iso: string) => {
|
|
106
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
107
|
+
return new Date(y!, (m ?? 1) - 1, d ?? 1, 12, 0, 0).getTime();
|
|
108
|
+
};
|
|
109
|
+
return Math.round((at(to) - at(from)) / 86_400_000);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** "09:30-11:00" from minutes-since-midnight, or undefined when unblocked. */
|
|
113
|
+
function formatTimeBlock(tb: Task["timeBlock"]): string | undefined {
|
|
114
|
+
if (!tb) return undefined;
|
|
115
|
+
const hhmm = (m: number) =>
|
|
116
|
+
String(Math.floor(m / 60)).padStart(2, "0") + ":" + String(m % 60).padStart(2, "0");
|
|
117
|
+
return hhmm(tb.startMin) + "-" + hhmm(tb.endMin);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The date a task is judged by: an explicit due date wins over a scheduled one. */
|
|
121
|
+
function effectiveDate(task: Task): string | undefined {
|
|
122
|
+
return task.due ?? task.scheduled;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildSummary(options: { next?: number; today?: string } = {}): Summary {
|
|
126
|
+
const nextCount = options.next ?? 5;
|
|
127
|
+
const today = options.today ?? localToday();
|
|
128
|
+
const config = loadConfig();
|
|
129
|
+
|
|
130
|
+
const boards: SummaryBoard[] = [];
|
|
131
|
+
const parsed: Board[] = [];
|
|
132
|
+
const totals = { open: 0, done: 0, overdue: 0, today: 0 };
|
|
133
|
+
|
|
134
|
+
for (const ref of config.boards) {
|
|
135
|
+
let content: string;
|
|
136
|
+
try {
|
|
137
|
+
content = readFileSync(ref.path, "utf-8");
|
|
138
|
+
} catch {
|
|
139
|
+
// A board listed in config but missing on disk is worth surfacing, not
|
|
140
|
+
// crashing over: a widget polling every 30s shouldn't die on a moved file.
|
|
141
|
+
boards.push({
|
|
142
|
+
name: ref.name ?? ref.path,
|
|
143
|
+
path: ref.path,
|
|
144
|
+
open: 0,
|
|
145
|
+
done: 0,
|
|
146
|
+
overdue: 0,
|
|
147
|
+
today: 0,
|
|
148
|
+
columns: [],
|
|
149
|
+
next: [],
|
|
150
|
+
diagnostics: -1,
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { board, diagnostics } = parseBoard(content, { filepath: ref.path });
|
|
156
|
+
const boardName = ref.name ?? board.name;
|
|
157
|
+
parsed.push(board);
|
|
158
|
+
|
|
159
|
+
const columns: SummaryColumn[] = [];
|
|
160
|
+
const openTasks: SummaryTask[] = [];
|
|
161
|
+
let open = 0;
|
|
162
|
+
let done = 0;
|
|
163
|
+
let overdue = 0;
|
|
164
|
+
let dueToday = 0;
|
|
165
|
+
|
|
166
|
+
for (const column of board.columns) {
|
|
167
|
+
const hidden = isHiddenColumn(config, column.name);
|
|
168
|
+
let columnOpen = 0;
|
|
169
|
+
|
|
170
|
+
for (const child of column.children) {
|
|
171
|
+
if (!isTask(child)) continue;
|
|
172
|
+
const task = child;
|
|
173
|
+
|
|
174
|
+
// Anything parked in Done/Archive counts as done wherever its checkbox
|
|
175
|
+
// sits — the column is the workflow truth, not the `- [x]` marker.
|
|
176
|
+
if (hidden || task.done) {
|
|
177
|
+
done++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
open++;
|
|
182
|
+
columnOpen++;
|
|
183
|
+
|
|
184
|
+
const date = effectiveDate(task);
|
|
185
|
+
const daysUntil = date ? daysBetween(today, date) : null;
|
|
186
|
+
if (daysUntil !== null && daysUntil < 0) overdue++;
|
|
187
|
+
if (daysUntil === 0) dueToday++;
|
|
188
|
+
|
|
189
|
+
openTasks.push({
|
|
190
|
+
title: task.displayTitle,
|
|
191
|
+
board: boardName,
|
|
192
|
+
column: column.name,
|
|
193
|
+
priority: task.priority,
|
|
194
|
+
due: task.due,
|
|
195
|
+
scheduled: task.scheduled,
|
|
196
|
+
assignee: task.assignee,
|
|
197
|
+
tags: task.tags,
|
|
198
|
+
daysUntil,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!hidden) columns.push({ name: column.name, open: columnOpen });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Soonest first; undated tasks last; ties broken by priority then title so
|
|
206
|
+
// the order is stable between polls and the widget doesn't flicker.
|
|
207
|
+
openTasks.sort((a, b) => {
|
|
208
|
+
const ad = a.daysUntil ?? Number.POSITIVE_INFINITY;
|
|
209
|
+
const bd = b.daysUntil ?? Number.POSITIVE_INFINITY;
|
|
210
|
+
if (ad !== bd) return ad - bd;
|
|
211
|
+
const ap = PRIORITY_RANK[a.priority];
|
|
212
|
+
const bp = PRIORITY_RANK[b.priority];
|
|
213
|
+
if (ap !== bp) return ap - bp;
|
|
214
|
+
return a.title.localeCompare(b.title);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
totals.open += open;
|
|
218
|
+
totals.done += done;
|
|
219
|
+
totals.overdue += overdue;
|
|
220
|
+
totals.today += dueToday;
|
|
221
|
+
|
|
222
|
+
boards.push({
|
|
223
|
+
name: boardName,
|
|
224
|
+
path: ref.path,
|
|
225
|
+
open,
|
|
226
|
+
done,
|
|
227
|
+
overdue,
|
|
228
|
+
today: dueToday,
|
|
229
|
+
columns,
|
|
230
|
+
next: openTasks.slice(0, nextCount),
|
|
231
|
+
diagnostics: diagnostics.length,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const planner: Record<PlannerSection, PlannerEntry[]> = {
|
|
236
|
+
overdue: [],
|
|
237
|
+
today: [],
|
|
238
|
+
tomorrow: [],
|
|
239
|
+
};
|
|
240
|
+
for (const item of buildPlannerItems(parsed)) {
|
|
241
|
+
planner[item.section].push({
|
|
242
|
+
title: item.task.displayTitle,
|
|
243
|
+
board: item.boardName,
|
|
244
|
+
column: item.columnName,
|
|
245
|
+
bucket: item.bucket,
|
|
246
|
+
priority: item.task.priority,
|
|
247
|
+
due: item.task.due,
|
|
248
|
+
scheduled: item.task.scheduled,
|
|
249
|
+
timeBlock: formatTimeBlock(item.task.timeBlock),
|
|
250
|
+
assignee: item.task.assignee,
|
|
251
|
+
done: item.task.done,
|
|
252
|
+
doneDate: item.task.doneDate,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { generatedAt: new Date().toISOString(), totals, boards, planner };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function runSummary(argv: readonly string[]): Promise<number> {
|
|
260
|
+
let pretty = false;
|
|
261
|
+
let next: number | undefined;
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < argv.length; i++) {
|
|
264
|
+
const arg = argv[i]!;
|
|
265
|
+
if (arg === "--pretty") pretty = true;
|
|
266
|
+
else if (arg === "--next") next = Number(argv[++i]);
|
|
267
|
+
else if (arg.startsWith("--next=")) next = Number(arg.slice("--next=".length));
|
|
268
|
+
else {
|
|
269
|
+
console.error(`tuiboard summary: unknown argument "${arg}"`);
|
|
270
|
+
return 2;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (next !== undefined && (!Number.isFinite(next) || next < 0)) {
|
|
275
|
+
console.error("tuiboard summary: --next needs a non-negative number");
|
|
276
|
+
return 2;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const summary = buildSummary({ next });
|
|
280
|
+
process.stdout.write(
|
|
281
|
+
(pretty ? JSON.stringify(summary, null, 2) : JSON.stringify(summary)) + "\n",
|
|
282
|
+
);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
package/src/cli/task.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless mutations on a board, for scripts and widgets.
|
|
3
|
+
*
|
|
4
|
+
* tuiboard task done --board <name> --column <col> --match <text>
|
|
5
|
+
* tuiboard task undone --board <name> --column <col> --match <text>
|
|
6
|
+
* tuiboard task add --board <name> --column <col> --text <text>
|
|
7
|
+
* tuiboard task defer --board <name> --column <col> --match <text> [--days N|--to DATE]
|
|
8
|
+
* ... --dry-run report what would change, write nothing
|
|
9
|
+
*
|
|
10
|
+
* Why match by text and not by index: a task's id is `${column}:${position}`,
|
|
11
|
+
* which is only valid inside one render pass. Anything outside the TUI — a
|
|
12
|
+
* widget polling every two minutes, a cron job — holds a stale snapshot, and
|
|
13
|
+
* acting on a stale index silently hits the wrong task. Matching on the
|
|
14
|
+
* rendered title instead makes a moved task a miss rather than a mistake.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency is handled by writeBoardFile()'s mtime watermark: if the file
|
|
17
|
+
* changed since we read it, it throws ConflictError and we refuse the write.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
import { loadConfig } from "~/config/loader";
|
|
23
|
+
import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
|
|
24
|
+
import { isTask, parseBoard } from "~/parser/markdown";
|
|
25
|
+
import { serializeBoard } from "~/parser/serialize";
|
|
26
|
+
import type { Board, Column, Task } from "~/types";
|
|
27
|
+
|
|
28
|
+
interface Args {
|
|
29
|
+
board?: string;
|
|
30
|
+
column?: string;
|
|
31
|
+
match?: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
days?: number;
|
|
34
|
+
to?: string;
|
|
35
|
+
dryRun: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parse(argv: readonly string[]): Args {
|
|
39
|
+
const a: Args = { dryRun: false };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const arg = argv[i]!;
|
|
42
|
+
const take = () => argv[++i];
|
|
43
|
+
if (arg === "--board") a.board = take();
|
|
44
|
+
else if (arg === "--column") a.column = take();
|
|
45
|
+
else if (arg === "--match") a.match = take();
|
|
46
|
+
else if (arg === "--text") a.text = take();
|
|
47
|
+
else if (arg === "--days") a.days = Number(take());
|
|
48
|
+
else if (arg === "--to") a.to = take();
|
|
49
|
+
else if (arg === "--dry-run") a.dryRun = true;
|
|
50
|
+
else if (arg.startsWith("--board=")) a.board = arg.slice(8);
|
|
51
|
+
else if (arg.startsWith("--column=")) a.column = arg.slice(9);
|
|
52
|
+
else if (arg.startsWith("--match=")) a.match = arg.slice(8);
|
|
53
|
+
else if (arg.startsWith("--text=")) a.text = arg.slice(7);
|
|
54
|
+
else if (arg.startsWith("--days=")) a.days = Number(arg.slice(7));
|
|
55
|
+
else if (arg.startsWith("--to=")) a.to = arg.slice(5);
|
|
56
|
+
else throw new Error(`unknown argument "${arg}"`);
|
|
57
|
+
}
|
|
58
|
+
return a;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isoToday(): string {
|
|
62
|
+
const d = new Date();
|
|
63
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
64
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isoShift(days: number): string {
|
|
68
|
+
const d = new Date();
|
|
69
|
+
d.setDate(d.getDate() + days);
|
|
70
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
71
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve a board by configured name, then by path suffix. */
|
|
75
|
+
function resolveBoard(needle: string) {
|
|
76
|
+
const cfg = loadConfig();
|
|
77
|
+
const lower = needle.toLowerCase();
|
|
78
|
+
const hit =
|
|
79
|
+
cfg.boards.find((b) => (b.name ?? "").toLowerCase() === lower) ??
|
|
80
|
+
cfg.boards.find((b) => b.path.toLowerCase().endsWith(lower));
|
|
81
|
+
if (!hit) {
|
|
82
|
+
const names = cfg.boards.map((b) => b.name ?? b.path).join(", ");
|
|
83
|
+
throw new Error(`board "${needle}" not found. Configured: ${names || "(none)"}`);
|
|
84
|
+
}
|
|
85
|
+
return hit;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findColumn(board: Board, name: string): Column {
|
|
89
|
+
const lower = name.toLowerCase();
|
|
90
|
+
const col = board.columns.find((c) => c.name.toLowerCase() === lower);
|
|
91
|
+
if (!col) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`column "${name}" not found. Available: ${board.columns.map((c) => c.name).join(", ")}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return col;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Exactly one match or nothing: an ambiguous title must not be guessed at. */
|
|
100
|
+
function findTask(col: Column, needle: string): Task {
|
|
101
|
+
const lower = needle.toLowerCase();
|
|
102
|
+
const tasks = col.children.filter(isTask);
|
|
103
|
+
let hits = tasks.filter((t) => t.displayTitle.toLowerCase() === lower);
|
|
104
|
+
if (hits.length === 0) {
|
|
105
|
+
hits = tasks.filter((t) => t.displayTitle.toLowerCase().includes(lower));
|
|
106
|
+
}
|
|
107
|
+
if (hits.length === 0) throw new Error(`no task in "${col.name}" matching "${needle}"`);
|
|
108
|
+
if (hits.length > 1) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`"${needle}" matches ${hits.length} tasks in "${col.name}"; be more specific`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return hits[0]!;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runTask(argv: readonly string[]): Promise<number> {
|
|
117
|
+
const sub = argv[0];
|
|
118
|
+
if (sub !== "done" && sub !== "undone" && sub !== "add" && sub !== "defer") {
|
|
119
|
+
console.error(
|
|
120
|
+
"usage: tuiboard task <done|undone|add|defer> --board <b> --column <c> [--match|--text] <s>",
|
|
121
|
+
);
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let a: Args;
|
|
126
|
+
try {
|
|
127
|
+
a = parse(argv.slice(1));
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error(`tuiboard task: ${(e as Error).message}`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!a.board || !a.column) {
|
|
134
|
+
console.error("tuiboard task: --board and --column are required");
|
|
135
|
+
return 2;
|
|
136
|
+
}
|
|
137
|
+
if ((sub === "done" || sub === "undone" || sub === "defer") && !a.match) {
|
|
138
|
+
console.error(`tuiboard task ${sub}: --match is required`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
if (sub === "defer" && a.days !== undefined && !Number.isFinite(a.days)) {
|
|
142
|
+
console.error("tuiboard task defer: --days must be a number");
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
if (sub === "defer" && a.to !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(a.to)) {
|
|
146
|
+
console.error("tuiboard task defer: --to must be YYYY-MM-DD");
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
if (sub === "add" && !a.text) {
|
|
150
|
+
console.error("tuiboard task add: --text is required");
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const ref = resolveBoard(a.board);
|
|
156
|
+
const mtime = statMtime(ref.path);
|
|
157
|
+
const { board } = parseBoard(readFileSync(ref.path, "utf-8"), { filepath: ref.path });
|
|
158
|
+
const col = findColumn(board, a.column);
|
|
159
|
+
|
|
160
|
+
let summary: string;
|
|
161
|
+
|
|
162
|
+
if (sub === "done") {
|
|
163
|
+
const task = findTask(col, a.match!);
|
|
164
|
+
if (task.done) {
|
|
165
|
+
console.log(`already done: ${task.displayTitle}`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
// Same semantics as the TUI's toggleDone: keep an existing completion
|
|
169
|
+
// date if one was already recorded, otherwise stamp today.
|
|
170
|
+
task.done = true;
|
|
171
|
+
task.dirty = true;
|
|
172
|
+
task.doneDate = task.doneDate ?? isoToday();
|
|
173
|
+
summary = `done: ${task.displayTitle}`;
|
|
174
|
+
} else if (sub === "undone") {
|
|
175
|
+
// The counterpart of `done`: ticking a task by mistake, or reopening one
|
|
176
|
+
// that turned out not to be finished, must not require the TUI.
|
|
177
|
+
// The completion date goes with it — a task that is open has not been
|
|
178
|
+
// completed on any date, and a stale ✅ would outlive the tick.
|
|
179
|
+
const task = findTask(col, a.match!);
|
|
180
|
+
if (!task.done) {
|
|
181
|
+
console.log(`already open: ${task.displayTitle}`);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
task.done = false;
|
|
185
|
+
task.doneDate = undefined;
|
|
186
|
+
task.dirty = true;
|
|
187
|
+
summary = `reopened: ${task.displayTitle}`;
|
|
188
|
+
} else if (sub === "defer") {
|
|
189
|
+
const task = findTask(col, a.match!);
|
|
190
|
+
const target = a.to ?? isoShift(a.days ?? 1);
|
|
191
|
+
|
|
192
|
+
// Move the field the planner actually reads. buildPlannerItems() buckets
|
|
193
|
+
// on `scheduled ?? due`, so shifting `due` on a task that also carries a
|
|
194
|
+
// `scheduled` date would write a change that never moves the row.
|
|
195
|
+
// A task with neither date gets a scheduled one, which is what puts it
|
|
196
|
+
// on the agenda in the first place.
|
|
197
|
+
if (task.scheduled !== undefined) task.scheduled = target;
|
|
198
|
+
else if (task.due !== undefined) task.due = target;
|
|
199
|
+
else task.scheduled = target;
|
|
200
|
+
|
|
201
|
+
task.dirty = true;
|
|
202
|
+
summary = `deferred to ${target}: ${task.displayTitle}`;
|
|
203
|
+
} else {
|
|
204
|
+
const task: Task = {
|
|
205
|
+
id: `${board.columns.indexOf(col)}:${col.children.filter(isTask).length}`,
|
|
206
|
+
done: false,
|
|
207
|
+
rawBody: a.text!,
|
|
208
|
+
rawLine: `- [ ] ${a.text!}`,
|
|
209
|
+
dirty: true,
|
|
210
|
+
displayTitle: a.text!,
|
|
211
|
+
tags: [],
|
|
212
|
+
wikilinks: [],
|
|
213
|
+
priority: "none",
|
|
214
|
+
};
|
|
215
|
+
col.children.push(task);
|
|
216
|
+
summary = `added to ${col.name}: ${a.text}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (a.dryRun) {
|
|
220
|
+
console.log(`[dry-run] ${summary}`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
writeBoardFile(ref.path, serializeBoard(board), { expectedMtimeMs: mtime });
|
|
225
|
+
console.log(summary);
|
|
226
|
+
return 0;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (e instanceof ConflictError) {
|
|
229
|
+
console.error(`tuiboard task: the board changed on disk — refresh and retry.`);
|
|
230
|
+
return 3;
|
|
231
|
+
}
|
|
232
|
+
console.error(`tuiboard task: ${(e as Error).message}`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -43,6 +43,15 @@ export interface Config {
|
|
|
43
43
|
* When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
|
|
44
44
|
*/
|
|
45
45
|
resumeCommand?: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Template for the shell command copied to the clipboard by `c` in the agents
|
|
48
|
+
* zone — one paste that `cd`s into the session's directory and resumes it.
|
|
49
|
+
* The tokens `{cwd}` and `{sessionId}` are substituted. Default:
|
|
50
|
+
* cd "{cwd}" && claude --resume {sessionId}
|
|
51
|
+
* `&&` works in bash/zsh/pwsh/cmd; Nushell users may prefer
|
|
52
|
+
* cd "{cwd}"; claude --resume {sessionId}
|
|
53
|
+
*/
|
|
54
|
+
copyResumeCommand: string;
|
|
46
55
|
/**
|
|
47
56
|
* Optional read-only calendar feeds merged into the Agenda (timeline) zone.
|
|
48
57
|
* Paths support `~` and are resolved against the config dir if relative.
|
|
@@ -102,10 +111,18 @@ export interface CalendarsConfig {
|
|
|
102
111
|
microsoft?: MicrosoftCalendarConfig;
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Default clipboard template for `c` in the agents zone: `cd` + resume in one
|
|
116
|
+
* paste. `&&` chains in bash/zsh/pwsh/cmd (Nushell users override with `;`).
|
|
117
|
+
*/
|
|
118
|
+
export const DEFAULT_COPY_RESUME_COMMAND =
|
|
119
|
+
'cd "{cwd}" && claude --resume {sessionId}';
|
|
120
|
+
|
|
105
121
|
export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
|
|
106
122
|
assignees: [],
|
|
107
123
|
doneColumn: "Done",
|
|
108
124
|
archiveColumn: "Archive",
|
|
125
|
+
copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
|
|
109
126
|
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
110
127
|
};
|
|
111
128
|
|
|
@@ -149,6 +166,7 @@ interface RawConfig {
|
|
|
149
166
|
done_column: string;
|
|
150
167
|
archive_column: string;
|
|
151
168
|
resume_command: string[];
|
|
169
|
+
copy_resume_command: string;
|
|
152
170
|
calendars: {
|
|
153
171
|
google?: {
|
|
154
172
|
enabled?: boolean;
|
|
@@ -293,6 +311,11 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
293
311
|
Array.isArray(raw.resume_command) && raw.resume_command.length > 0
|
|
294
312
|
? raw.resume_command.map(String)
|
|
295
313
|
: undefined,
|
|
314
|
+
copyResumeCommand:
|
|
315
|
+
typeof raw.copy_resume_command === "string" &&
|
|
316
|
+
raw.copy_resume_command.trim().length > 0
|
|
317
|
+
? raw.copy_resume_command
|
|
318
|
+
: DEFAULT_COPY_RESUME_COMMAND,
|
|
296
319
|
calendars: normalizeCalendars(raw.calendars, root),
|
|
297
320
|
zones: normalizeZones(raw.zones),
|
|
298
321
|
};
|
package/src/input/handleKey.ts
CHANGED
|
@@ -432,6 +432,20 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
|
|
|
432
432
|
// Open (resume) the selected session in a new WezTerm tab.
|
|
433
433
|
const target = sessions[ui.row];
|
|
434
434
|
if (target) void openSessionInWezterm(store, target.cwd, target.sessionId);
|
|
435
|
+
} else if (key.name === "c") {
|
|
436
|
+
// Copy a one-paste "cd + resume" command for the selected session, so you
|
|
437
|
+
// can drop it into any tab/pane anywhere and land in the right directory
|
|
438
|
+
// resuming the right session (no WezTerm dependency, unlike Enter).
|
|
439
|
+
const target = sessions[ui.row];
|
|
440
|
+
if (target) {
|
|
441
|
+
const cmd = store.config.copyResumeCommand
|
|
442
|
+
.replaceAll("{cwd}", target.cwd)
|
|
443
|
+
.replaceAll("{sessionId}", target.sessionId);
|
|
444
|
+
copyToClipboard(cmd).then(
|
|
445
|
+
() => store.flashBanner("info", `📋 Copied resume command (${target.sessionId.slice(0, 8)})`),
|
|
446
|
+
(err) => store.flashBanner("error", `Copy failed: ${err}`),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
435
449
|
} else if (key.name === "o") {
|
|
436
450
|
// Inspect the session in the detail modal.
|
|
437
451
|
const target = sessions[ui.row];
|
package/src/store/index.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ function emptyConfig(overrides: Partial<Config> = {}): Config {
|
|
|
18
18
|
assignees: [],
|
|
19
19
|
doneColumn: "Done",
|
|
20
20
|
archiveColumn: "Archive",
|
|
21
|
+
copyResumeCommand: 'cd "{cwd}" && claude --resume {sessionId}',
|
|
21
22
|
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
22
23
|
...overrides,
|
|
23
24
|
};
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -772,12 +772,14 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
|
|
|
772
772
|
<box style={{ height: 1 }} />
|
|
773
773
|
<text>
|
|
774
774
|
<span style={{ fg: T.textDim }}>
|
|
775
|
-
resume —
|
|
775
|
+
resume — Enter opens this in WezTerm; c copies this command to paste anywhere:
|
|
776
776
|
</span>
|
|
777
777
|
</text>
|
|
778
778
|
<text wrapMode="word">
|
|
779
779
|
<span style={{ fg: T.scheduled }}>
|
|
780
|
-
|
|
780
|
+
{props.store.config.copyResumeCommand
|
|
781
|
+
.replaceAll("{cwd}", s().cwd)
|
|
782
|
+
.replaceAll("{sessionId}", s().sessionId)}
|
|
781
783
|
</span>
|
|
782
784
|
</text>
|
|
783
785
|
</box>
|
|
@@ -845,6 +847,7 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
845
847
|
<span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
|
|
846
848
|
<span style={{ fg: T.textDim }}>{"\nAgents zone\n"}</span>
|
|
847
849
|
<span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
|
|
850
|
+
<span style={{ fg: T.text }}>{" c Copy a 'cd + claude --resume' command for the session\n"}</span>
|
|
848
851
|
<span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
|
|
849
852
|
<span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
|
|
850
853
|
<span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\n"}</span>
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -54,18 +54,46 @@ export function Dashboard(props: { store: TuiStore }) {
|
|
|
54
54
|
* Board + planner share BoardOnly because both live in the top-left
|
|
55
55
|
* zone of the normal layout and BoardOnly already respects ui.zoomed
|
|
56
56
|
* to render only the active panel between them.
|
|
57
|
+
*
|
|
58
|
+
* Modals: the normal layout drops them into the Agenda's slot, which doesn't
|
|
59
|
+
* exist while zoomed — so here the modal floats as a centered absolute overlay
|
|
60
|
+
* on top of the zoomed view. The user stays zoomed; close the modal and the
|
|
61
|
+
* zoomed view is exactly as they left it (no exit-zoom / re-zoom flip).
|
|
57
62
|
*/
|
|
58
63
|
function ZoomedLayout(props: { store: TuiStore }) {
|
|
59
|
-
const
|
|
64
|
+
const ui = () => props.store.state.ui;
|
|
65
|
+
const zone = () => ui().activeZone;
|
|
60
66
|
|
|
61
67
|
return (
|
|
62
|
-
|
|
63
|
-
<Show when={zone() === "
|
|
64
|
-
<
|
|
68
|
+
<>
|
|
69
|
+
<Show when={zone() === "timeline"} fallback={
|
|
70
|
+
<Show when={zone() === "agents"} fallback={<BoardOnly store={props.store} />}>
|
|
71
|
+
<AgentsOnly store={props.store} />
|
|
72
|
+
</Show>
|
|
73
|
+
}>
|
|
74
|
+
<TimelineOnly store={props.store} />
|
|
65
75
|
</Show>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
{/* Modal overlay — only while zoomed AND a modal is open. Absolute + high
|
|
77
|
+
zIndex so it paints over the zoomed view; centered; transparent
|
|
78
|
+
backdrop so the board stays visible behind the (opaque) modal panel. */}
|
|
79
|
+
<Show when={ui().modal}>
|
|
80
|
+
<box
|
|
81
|
+
style={{
|
|
82
|
+
position: "absolute",
|
|
83
|
+
top: 0,
|
|
84
|
+
left: 0,
|
|
85
|
+
right: 0,
|
|
86
|
+
bottom: 0,
|
|
87
|
+
zIndex: 100,
|
|
88
|
+
flexDirection: "column",
|
|
89
|
+
alignItems: "center",
|
|
90
|
+
justifyContent: "center",
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<ModalLayer store={props.store} />
|
|
94
|
+
</box>
|
|
95
|
+
</Show>
|
|
96
|
+
</>
|
|
69
97
|
);
|
|
70
98
|
}
|
|
71
99
|
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
"types": ["bun-types"],
|
|
8
|
+
|
|
9
|
+
"jsx": "preserve",
|
|
10
|
+
"jsxImportSource": "@opentui/solid",
|
|
11
|
+
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"esModuleInterop": true,
|
|
19
|
+
"resolveJsonModule": true,
|
|
20
|
+
|
|
21
|
+
"baseUrl": ".",
|
|
22
|
+
"paths": {
|
|
23
|
+
"~/*": ["src/*"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"include": ["src/**/*"]
|
|
27
|
+
}
|