tuiboard 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tuiboard/config.example.yaml +9 -0
- package/CHANGELOG.md +102 -0
- package/README.md +111 -1
- package/bin/tuiboard.ts +12 -0
- package/package.json +5 -1
- package/src/app.tsx +70 -17
- package/src/boards/config-writer.ts +191 -0
- package/src/boards/create.ts +57 -0
- package/src/boards/scan.ts +95 -0
- package/src/boards/suggest.ts +34 -0
- package/src/cli/args.ts +2 -2
- package/src/cli/board.ts +151 -0
- package/src/cli/summary.ts +284 -0
- package/src/cli/task.ts +235 -0
- package/src/config/loader.ts +60 -13
- package/src/input/handleKey.ts +72 -1
- package/src/io/watcher.ts +11 -0
- package/src/parser/markdown.ts +10 -0
- package/src/store/index.ts +379 -12
- package/src/ui/BoardView.tsx +8 -6
- package/src/ui/Chrome.tsx +65 -9
- package/src/ui/Modal.tsx +133 -2
- package/src/ui/PlannerPanel.tsx +1 -1
- package/src/ui/pane-ring.ts +73 -0
- package/src/views/BoardOnly.tsx +3 -2
- package/src/views/Dashboard.tsx +36 -8
- package/tsconfig.json +27 -0
- package/src/cli/args.test.ts +0 -40
- package/src/scripts/agents-check.ts +0 -24
- package/src/scripts/parse-check.ts +0 -124
- package/src/scripts/roundtrip-check.ts +0 -79
- package/src/store/agents.test.ts +0 -181
- package/src/store/index.test.ts +0 -224
- package/src/store/parsers.test.ts +0 -64
- package/src/store/timeline.test.ts +0 -332
- package/src/ui/board-scroll.test.ts +0 -63
- package/src/ui/glyphs.test.ts +0 -31
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
|
@@ -18,6 +18,8 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
19
19
|
import * as YAML from "js-yaml";
|
|
20
20
|
|
|
21
|
+
import { isBoardFile } from "~/boards/scan";
|
|
22
|
+
|
|
21
23
|
export interface BoardConfig {
|
|
22
24
|
/** Path to the .md file, absolute or relative to the config directory. */
|
|
23
25
|
path: string;
|
|
@@ -43,6 +45,15 @@ export interface Config {
|
|
|
43
45
|
* When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
|
|
44
46
|
*/
|
|
45
47
|
resumeCommand?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Template for the shell command copied to the clipboard by `c` in the agents
|
|
50
|
+
* zone — one paste that `cd`s into the session's directory and resumes it.
|
|
51
|
+
* The tokens `{cwd}` and `{sessionId}` are substituted. Default:
|
|
52
|
+
* cd "{cwd}" && claude --resume {sessionId}
|
|
53
|
+
* `&&` works in bash/zsh/pwsh/cmd; Nushell users may prefer
|
|
54
|
+
* cd "{cwd}"; claude --resume {sessionId}
|
|
55
|
+
*/
|
|
56
|
+
copyResumeCommand: string;
|
|
46
57
|
/**
|
|
47
58
|
* Optional read-only calendar feeds merged into the Agenda (timeline) zone.
|
|
48
59
|
* Paths support `~` and are resolved against the config dir if relative.
|
|
@@ -102,10 +113,18 @@ export interface CalendarsConfig {
|
|
|
102
113
|
microsoft?: MicrosoftCalendarConfig;
|
|
103
114
|
}
|
|
104
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Default clipboard template for `c` in the agents zone: `cd` + resume in one
|
|
118
|
+
* paste. `&&` chains in bash/zsh/pwsh/cmd (Nushell users override with `;`).
|
|
119
|
+
*/
|
|
120
|
+
export const DEFAULT_COPY_RESUME_COMMAND =
|
|
121
|
+
'cd "{cwd}" && claude --resume {sessionId}';
|
|
122
|
+
|
|
105
123
|
export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
|
|
106
124
|
assignees: [],
|
|
107
125
|
doneColumn: "Done",
|
|
108
126
|
archiveColumn: "Archive",
|
|
127
|
+
copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
|
|
109
128
|
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
110
129
|
};
|
|
111
130
|
|
|
@@ -123,6 +142,35 @@ export interface LoadConfigOptions {
|
|
|
123
142
|
startDir?: string;
|
|
124
143
|
}
|
|
125
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Where the config lives, or where it would be created.
|
|
147
|
+
*
|
|
148
|
+
* Same resolution order as `loadConfig`, but it also answers for the case
|
|
149
|
+
* loadConfig cannot: no config anywhere. Writers need a target path even when
|
|
150
|
+
* nothing exists yet, and the user-global location is the one that works from
|
|
151
|
+
* any directory.
|
|
152
|
+
*/
|
|
153
|
+
export function findConfigPath({ startDir }: LoadConfigOptions = {}): {
|
|
154
|
+
path: string;
|
|
155
|
+
exists: boolean;
|
|
156
|
+
} {
|
|
157
|
+
// $TUIBOARD_CONFIG comes first and is absolute about it: when it is set,
|
|
158
|
+
// that file IS the config, whether or not it exists yet. Falling through to
|
|
159
|
+
// the home config when the named file is missing is how a caller aiming at a
|
|
160
|
+
// scratch path ends up writing to the user's real one.
|
|
161
|
+
const env = process.env.TUIBOARD_CONFIG;
|
|
162
|
+
if (env) {
|
|
163
|
+
const abs = resolve(env);
|
|
164
|
+
return { path: abs, exists: existsSync(abs) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const start = resolve(startDir ?? process.cwd());
|
|
168
|
+
const found = findConfigFile(start) ?? findGlobalConfigFile();
|
|
169
|
+
if (found) return { path: found.path, exists: true };
|
|
170
|
+
|
|
171
|
+
return { path: join(homedir(), ".config", "tuiboard", "config.yaml"), exists: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
126
174
|
export function loadConfig({ startDir }: LoadConfigOptions = {}): Config {
|
|
127
175
|
const start = resolve(startDir ?? process.cwd());
|
|
128
176
|
|
|
@@ -149,6 +197,7 @@ interface RawConfig {
|
|
|
149
197
|
done_column: string;
|
|
150
198
|
archive_column: string;
|
|
151
199
|
resume_command: string[];
|
|
200
|
+
copy_resume_command: string;
|
|
152
201
|
calendars: {
|
|
153
202
|
google?: {
|
|
154
203
|
enabled?: boolean;
|
|
@@ -293,26 +342,24 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
293
342
|
Array.isArray(raw.resume_command) && raw.resume_command.length > 0
|
|
294
343
|
? raw.resume_command.map(String)
|
|
295
344
|
: undefined,
|
|
345
|
+
copyResumeCommand:
|
|
346
|
+
typeof raw.copy_resume_command === "string" &&
|
|
347
|
+
raw.copy_resume_command.trim().length > 0
|
|
348
|
+
? raw.copy_resume_command
|
|
349
|
+
: DEFAULT_COPY_RESUME_COMMAND,
|
|
296
350
|
calendars: normalizeCalendars(raw.calendars, root),
|
|
297
351
|
zones: normalizeZones(raw.zones),
|
|
298
352
|
};
|
|
299
353
|
}
|
|
300
354
|
|
|
301
355
|
function scanFallbackBoards(dir: string): BoardConfig[] {
|
|
356
|
+
// Delegates to the same recogniser the onboarding screen uses. Two rules
|
|
357
|
+
// would mean a file adopted by one path and ignored by the other.
|
|
302
358
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
.filter(
|
|
306
|
-
.
|
|
307
|
-
.filter((p) => {
|
|
308
|
-
try {
|
|
309
|
-
if (!statSync(p).isFile()) return false;
|
|
310
|
-
const head = readFileSync(p, "utf-8").slice(0, 4096);
|
|
311
|
-
return /^- \[[ xX]\] /m.test(head);
|
|
312
|
-
} catch {
|
|
313
|
-
return false;
|
|
314
|
-
}
|
|
315
|
-
})
|
|
359
|
+
return readdirSync(dir)
|
|
360
|
+
.map((name) => join(dir, name))
|
|
361
|
+
.filter(isBoardFile)
|
|
362
|
+
.sort()
|
|
316
363
|
.map((path) => ({ path }));
|
|
317
364
|
} catch {
|
|
318
365
|
return [];
|
package/src/input/handleKey.ts
CHANGED
|
@@ -46,9 +46,38 @@ export function handleKey(
|
|
|
46
46
|
// Modal dispatcher first — most keys go to the modal's <input>.
|
|
47
47
|
if (ui.modal) {
|
|
48
48
|
if (key.name === "escape") {
|
|
49
|
+
// The board wizard owns its own dismissal: on first run there is no
|
|
50
|
+
// board behind it, so Escape must not leave the user on a blank screen.
|
|
51
|
+
if (ui.modal.kind === "board-new") {
|
|
52
|
+
const b = store.state.ui.boardNew;
|
|
53
|
+
if (b?.step === "pick") { store.boardNewChooseMode("adopt"); return; }
|
|
54
|
+
store.closeBoardNew();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
49
57
|
store.closeModal();
|
|
50
58
|
return;
|
|
51
59
|
}
|
|
60
|
+
if (ui.modal.kind === "board-new") {
|
|
61
|
+
const b = store.state.ui.boardNew;
|
|
62
|
+
if (!b) return;
|
|
63
|
+
// Steps with a text field let the <input> have every key: only the list
|
|
64
|
+
// steps are driven from here.
|
|
65
|
+
if (b.step === "mode") {
|
|
66
|
+
if (key.name === "j" || key.name === "down") { store.boardNewMove(1); return; }
|
|
67
|
+
if (key.name === "k" || key.name === "up") { store.boardNewMove(-1); return; }
|
|
68
|
+
if (key.name === "enter" || key.name === "return") {
|
|
69
|
+
store.boardNewChooseMode(b.sel === 0 ? "create" : "adopt");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (b.step === "pick") {
|
|
74
|
+
if (key.name === "j" || key.name === "down") { store.boardNewMove(1); return; }
|
|
75
|
+
if (key.name === "k" || key.name === "up") { store.boardNewMove(-1); return; }
|
|
76
|
+
if (key.name === "space") { store.boardNewToggle(); return; }
|
|
77
|
+
if (key.name === "enter" || key.name === "return") { store.boardNewConfirmPick(); return; }
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
52
81
|
if (ui.modal.kind === "confirm-delete") {
|
|
53
82
|
if (key.name === "y" || key.name === "enter" || key.name === "return") {
|
|
54
83
|
// Delete the whole multi-selection if any, else just the cursor task.
|
|
@@ -165,6 +194,13 @@ export function handleKey(
|
|
|
165
194
|
return;
|
|
166
195
|
}
|
|
167
196
|
|
|
197
|
+
// New board — the `+` chip in the top bar, and its key. Free at this level:
|
|
198
|
+
// `+` is otherwise only used inside the timeline's duration sub-mode.
|
|
199
|
+
if (key.name === "+" || key.sequence === "+" || (key.name === "=" && key.shift)) {
|
|
200
|
+
store.openBoardNew();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
// Cycle boards
|
|
169
205
|
if (key.name === "tab") {
|
|
170
206
|
store.setActiveBoard(ui.activeBoardIndex + 1);
|
|
@@ -295,9 +331,16 @@ function handlePlannerZone(
|
|
|
295
331
|
return;
|
|
296
332
|
}
|
|
297
333
|
if (key.name === "l" || key.name === "right") {
|
|
334
|
+
if (store.stepPane(1)) return;
|
|
298
335
|
store.setActiveZone("board");
|
|
299
336
|
return;
|
|
300
337
|
}
|
|
338
|
+
if (key.name === "h" || key.name === "left") {
|
|
339
|
+
// Nothing to the planner's left at full width; in single-pane it wraps to
|
|
340
|
+
// the last pane of the ring.
|
|
341
|
+
if (store.stepPane(-1)) return;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
301
344
|
|
|
302
345
|
// Task actions on the planner cursor's target (works cross-board).
|
|
303
346
|
if (target) {
|
|
@@ -406,9 +449,14 @@ function handleTimelineZone(
|
|
|
406
449
|
return;
|
|
407
450
|
}
|
|
408
451
|
if (key.name === "h" || key.name === "left") {
|
|
452
|
+
if (store.stepPane(-1)) return;
|
|
409
453
|
store.setActiveZone("board");
|
|
410
454
|
return;
|
|
411
455
|
}
|
|
456
|
+
if (key.name === "l" || key.name === "right") {
|
|
457
|
+
if (store.stepPane(1)) return;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
412
460
|
// Enter on a timeline block bounces the kanban cursor to its source task.
|
|
413
461
|
if ((key.name === "enter" || key.name === "return") && target) {
|
|
414
462
|
jumpToKanban(store, target.ref);
|
|
@@ -432,6 +480,20 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
|
|
|
432
480
|
// Open (resume) the selected session in a new WezTerm tab.
|
|
433
481
|
const target = sessions[ui.row];
|
|
434
482
|
if (target) void openSessionInWezterm(store, target.cwd, target.sessionId);
|
|
483
|
+
} else if (key.name === "c") {
|
|
484
|
+
// Copy a one-paste "cd + resume" command for the selected session, so you
|
|
485
|
+
// can drop it into any tab/pane anywhere and land in the right directory
|
|
486
|
+
// resuming the right session (no WezTerm dependency, unlike Enter).
|
|
487
|
+
const target = sessions[ui.row];
|
|
488
|
+
if (target) {
|
|
489
|
+
const cmd = store.config.copyResumeCommand
|
|
490
|
+
.replaceAll("{cwd}", target.cwd)
|
|
491
|
+
.replaceAll("{sessionId}", target.sessionId);
|
|
492
|
+
copyToClipboard(cmd).then(
|
|
493
|
+
() => store.flashBanner("info", `📋 Copied resume command (${target.sessionId.slice(0, 8)})`),
|
|
494
|
+
(err) => store.flashBanner("error", `Copy failed: ${err}`),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
435
497
|
} else if (key.name === "o") {
|
|
436
498
|
// Inspect the session in the detail modal.
|
|
437
499
|
const target = sessions[ui.row];
|
|
@@ -442,7 +504,10 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
|
|
|
442
504
|
);
|
|
443
505
|
}
|
|
444
506
|
} else if (key.name === "h" || key.name === "left") {
|
|
507
|
+
if (store.stepPane(-1)) return;
|
|
445
508
|
store.setActiveZone("board");
|
|
509
|
+
} else if (key.name === "l" || key.name === "right") {
|
|
510
|
+
store.stepPane(1);
|
|
446
511
|
}
|
|
447
512
|
}
|
|
448
513
|
|
|
@@ -483,7 +548,7 @@ function handleBoardZone(
|
|
|
483
548
|
const openTasks = store.applyBoardFilter(allTasks.filter((t) => !t.done));
|
|
484
549
|
// Visible task list mirrors what the column renders: in zoom mode the
|
|
485
550
|
// user can navigate into done tasks too; otherwise only open.
|
|
486
|
-
const visibleTasks =
|
|
551
|
+
const visibleTasks = store.singlePane()
|
|
487
552
|
? [...openTasks, ...allTasks.filter((t) => t.done)]
|
|
488
553
|
: openTasks;
|
|
489
554
|
|
|
@@ -549,7 +614,12 @@ function handleBoardZone(
|
|
|
549
614
|
}
|
|
550
615
|
}
|
|
551
616
|
|
|
617
|
+
// In single-pane the zones are a ring, not a layout: h/l walk it one pane at
|
|
618
|
+
// a time — column, column, agenda, agents, planner — and wrap. stepPane
|
|
619
|
+
// returns false when it does not apply, and the side-by-side behaviour below
|
|
620
|
+
// takes over unchanged.
|
|
552
621
|
if (key.name === "h" || key.name === "left") {
|
|
622
|
+
if (store.stepPane(-1)) return;
|
|
553
623
|
// Step left over rendered columns. Hidden columns (Done / Archive) are
|
|
554
624
|
// never displayed, so navigating onto one would strand the cursor on an
|
|
555
625
|
// unrendered, unscrollable column.
|
|
@@ -562,6 +632,7 @@ function handleBoardZone(
|
|
|
562
632
|
return;
|
|
563
633
|
}
|
|
564
634
|
if (key.name === "l" || key.name === "right") {
|
|
635
|
+
if (store.stepPane(1)) return;
|
|
565
636
|
const next = adjacentVisibleColumn(store, board, ui.col, +1);
|
|
566
637
|
if (next !== undefined) {
|
|
567
638
|
store.setCursor(next, 0);
|
package/src/io/watcher.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface BoardWatcher {
|
|
|
22
22
|
onChange: (listener: ChangeListener) => () => void;
|
|
23
23
|
/** Mark the next change event for `filepath` as a self-write, to be ignored. */
|
|
24
24
|
markSelfWrite: (filepath: string) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Track one more file, after start(). A board adopted while tuiboard is
|
|
27
|
+
* running would otherwise stay deaf to external edits until the next
|
|
28
|
+
* launch — broken in the quietest possible way.
|
|
29
|
+
*/
|
|
30
|
+
watch: (filepath: string) => void;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface WatcherOptions {
|
|
@@ -76,6 +82,11 @@ export function createBoardWatcher(
|
|
|
76
82
|
listeners.add(listener);
|
|
77
83
|
return () => listeners.delete(listener);
|
|
78
84
|
},
|
|
85
|
+
watch(filepath) {
|
|
86
|
+
if (filepaths.includes(filepath)) return;
|
|
87
|
+
filepaths.push(filepath);
|
|
88
|
+
watcher?.add(filepath);
|
|
89
|
+
},
|
|
79
90
|
markSelfWrite(filepath) {
|
|
80
91
|
selfWrites.add(filepath);
|
|
81
92
|
// Guard against the watcher missing the event — clear after a short delay.
|
package/src/parser/markdown.ts
CHANGED
|
@@ -103,6 +103,16 @@ export function parseBoard(
|
|
|
103
103
|
const trailer = lines.slice(trailerStart).join(lineEnding);
|
|
104
104
|
const bodyLines = lines.slice(0, trailerStart);
|
|
105
105
|
|
|
106
|
+
// Splitting "a\nb\n" yields ["a", "b", ""]: that last empty element is the
|
|
107
|
+
// file's terminating newline, not a blank line. Keeping it would make the
|
|
108
|
+
// serializer — which emits one line ending per child — write it back as a
|
|
109
|
+
// real blank line AND terminate the file, growing the board by one blank
|
|
110
|
+
// line on every single save. Boards carrying a `%% kanban:settings %%`
|
|
111
|
+
// trailer never showed this: the trailer absorbs the final newline verbatim.
|
|
112
|
+
if (trailerStart === lines.length && bodyLines.length > 0 && bodyLines.at(-1) === "") {
|
|
113
|
+
bodyLines.pop();
|
|
114
|
+
}
|
|
115
|
+
|
|
106
116
|
// 3. Walk lines top-down. Build columns; *every line* before the first
|
|
107
117
|
// column is preamble, every line after a heading is a child of that
|
|
108
118
|
// column (task / section-break / blank / raw). Nothing is silently
|