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.
@@ -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",
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
+ }
@@ -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
+ }
@@ -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
  };
@@ -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];
@@ -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 — press Enter in the agents list to open this in WezTerm:
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
- claude --resume {s().sessionId}
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>
@@ -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 zone = () => props.store.state.ui.activeZone;
64
+ const ui = () => props.store.state.ui;
65
+ const zone = () => ui().activeZone;
60
66
 
61
67
  return (
62
- <Show when={zone() === "timeline"} fallback={
63
- <Show when={zone() === "agents"} fallback={<BoardOnly store={props.store} />}>
64
- <AgentsOnly store={props.store} />
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
- <TimelineOnly store={props.store} />
68
- </Show>
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
+ }