tuiboard 0.8.2 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Side-effect module: paints the boot splash the moment it's imported.
3
+ *
4
+ * app.tsx imports this FIRST so the splash prints before `@opentui/solid`
5
+ * (and the ~600ms store build) load — ES modules run imported modules in
6
+ * source order, so a first-position side-effect import is the only way to
7
+ * paint before the heavy imports execute.
8
+ *
9
+ * When launched via the `tuiboard` bin, the launcher already printed the splash
10
+ * (and sets TUIBOARD_SPLASH_DONE), so this no-ops to avoid a double paint.
11
+ */
12
+
13
+ import pkg from "../../package.json";
14
+ import { printSplash, showCursor } from "./splash";
15
+
16
+ if (!process.env.TUIBOARD_SPLASH_DONE) printSplash(pkg.version);
17
+
18
+ // The splash hides the cursor; guarantee it's restored on every exit path of
19
+ // this process, so quitting never leaves the shell without a cursor. (OpenTUI
20
+ // also restores on clean exit; this is the belt-and-suspenders backstop.)
21
+ process.on("exit", showCursor);