kandown 0.1.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/bin/tui.js ADDED
@@ -0,0 +1,1257 @@
1
+ // src/cli/tui.tsx
2
+ import { render } from "ink";
3
+
4
+ // src/cli/app.tsx
5
+ import { Box as Box4, Text as Text4 } from "ink";
6
+
7
+ // src/cli/screens/settings.tsx
8
+ import { useState, useEffect, useCallback } from "react";
9
+ import { Box, Text, useInput, useApp } from "ink";
10
+
11
+ // src/cli/lib/config.ts
12
+ import { readFileSync, writeFileSync, existsSync } from "fs";
13
+ import { join } from "path";
14
+ var DEFAULT_CONFIG = {
15
+ ui: { language: "en", theme: "auto", skin: "kandown", font: "inter" },
16
+ agent: { suggestFollowUp: false, maxSuggestions: 3 },
17
+ board: {
18
+ columns: ["Backlog", "Todo", "In Progress", "Review", "Done"],
19
+ defaultPriority: "P3",
20
+ defaultOwnerType: "human"
21
+ },
22
+ fields: {
23
+ priority: false,
24
+ assignee: false,
25
+ tags: false,
26
+ dueDate: false,
27
+ ownerType: false,
28
+ tools: false
29
+ },
30
+ notifications: {
31
+ browser: false,
32
+ sound: false,
33
+ soundId: "soft",
34
+ statusChanges: true,
35
+ taskEdits: true,
36
+ subtaskCompletions: true,
37
+ editDebounceMs: 2e3
38
+ }
39
+ };
40
+ function loadConfig(kandownDir) {
41
+ const configPath = join(kandownDir, "kandown.json");
42
+ if (!existsSync(configPath)) return structuredClone(DEFAULT_CONFIG);
43
+ try {
44
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
45
+ const merged = {
46
+ ui: { ...DEFAULT_CONFIG.ui, ...raw.ui },
47
+ agent: { ...DEFAULT_CONFIG.agent, ...raw.agent },
48
+ board: {
49
+ ...DEFAULT_CONFIG.board,
50
+ ...raw.board,
51
+ columns: Array.isArray(raw.board?.columns) && raw.board.columns.length > 0 ? raw.board.columns.filter((name) => typeof name === "string" && name.trim().length > 0) : DEFAULT_CONFIG.board.columns
52
+ },
53
+ fields: { ...DEFAULT_CONFIG.fields, ...raw.fields },
54
+ notifications: { ...DEFAULT_CONFIG.notifications, ...raw.notifications }
55
+ };
56
+ if (raw.agents) merged.agents = raw.agents;
57
+ return merged;
58
+ } catch {
59
+ return structuredClone(DEFAULT_CONFIG);
60
+ }
61
+ }
62
+ function saveConfig(kandownDir, config) {
63
+ const configPath = join(kandownDir, "kandown.json");
64
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
65
+ }
66
+ function getConfigValue(config, path) {
67
+ const parts = path.split(".");
68
+ let current = config;
69
+ for (const part of parts) {
70
+ if (current === null || current === void 0) return void 0;
71
+ current = current[part];
72
+ }
73
+ return current;
74
+ }
75
+ function setConfigValue(config, path, value) {
76
+ const result = structuredClone(config);
77
+ const parts = path.split(".");
78
+ let current = result;
79
+ for (let i = 0; i < parts.length - 1; i++) {
80
+ current = current[parts[i]];
81
+ }
82
+ current[parts[parts.length - 1]] = value;
83
+ return result;
84
+ }
85
+
86
+ // src/cli/screens/settings.tsx
87
+ import { jsx, jsxs } from "react/jsx-runtime";
88
+ var SETTINGS = [
89
+ // UI
90
+ {
91
+ key: "ui.language",
92
+ label: "Language",
93
+ section: "Appearance",
94
+ type: "select",
95
+ options: ["en", "fr", "es", "de", "pt", "ja", "zh", "ko", "it", "nl", "ru"]
96
+ },
97
+ {
98
+ key: "ui.theme",
99
+ label: "Mode",
100
+ section: "Appearance",
101
+ type: "select",
102
+ options: ["auto", "light", "dark"]
103
+ },
104
+ {
105
+ key: "ui.skin",
106
+ label: "Skin",
107
+ section: "Appearance",
108
+ type: "select",
109
+ options: ["kandown", "graphite", "sage", "cobalt", "rose"]
110
+ },
111
+ {
112
+ key: "ui.font",
113
+ label: "Font",
114
+ section: "Appearance",
115
+ type: "select",
116
+ options: ["inter", "system", "serif", "mono", "rounded"]
117
+ },
118
+ // Agent
119
+ {
120
+ key: "agent.suggestFollowUp",
121
+ label: "Suggest follow-up tasks",
122
+ section: "Agent",
123
+ type: "toggle"
124
+ },
125
+ {
126
+ key: "agent.maxSuggestions",
127
+ label: "Max suggestions",
128
+ section: "Agent",
129
+ type: "number",
130
+ min: 1,
131
+ max: 5
132
+ },
133
+ // Board
134
+ {
135
+ key: "board.defaultPriority",
136
+ label: "Default priority",
137
+ section: "Fields",
138
+ type: "select",
139
+ options: ["P1", "P2", "P3", "P4"]
140
+ },
141
+ {
142
+ key: "board.defaultOwnerType",
143
+ label: "Default owner",
144
+ section: "Fields",
145
+ type: "select",
146
+ options: ["human", "ai"]
147
+ },
148
+ // Fields
149
+ { key: "fields.priority", label: "Priority", section: "Fields", type: "toggle" },
150
+ { key: "fields.assignee", label: "Assignee", section: "Fields", type: "toggle" },
151
+ { key: "fields.tags", label: "Tags", section: "Fields", type: "toggle" },
152
+ { key: "fields.dueDate", label: "Due date", section: "Fields", type: "toggle" },
153
+ { key: "fields.ownerType", label: "Owner type", section: "Fields", type: "toggle" },
154
+ { key: "fields.tools", label: "Tools", section: "Fields", type: "toggle" },
155
+ // Notifications
156
+ { key: "notifications.browser", label: "Browser notifications", section: "Notifications", type: "toggle" },
157
+ { key: "notifications.statusChanges", label: "Status changes", section: "Notifications", type: "toggle" },
158
+ { key: "notifications.taskEdits", label: "Task edits", section: "Notifications", type: "toggle" },
159
+ { key: "notifications.subtaskCompletions", label: "Subtask completions", section: "Notifications", type: "toggle" },
160
+ { key: "notifications.sound", label: "Play sound", section: "Notifications", type: "toggle" },
161
+ {
162
+ key: "notifications.soundId",
163
+ label: "Sound",
164
+ section: "Notifications",
165
+ type: "select",
166
+ options: ["soft", "chime", "ping", "pop"]
167
+ }
168
+ ];
169
+ var SECTIONS = ["Appearance", "Agent", "Board", "Fields", "Notifications"];
170
+ var LABEL_WIDTH = 30;
171
+ var VALUE_WIDTH = 20;
172
+ var SECTION_ICONS = {
173
+ Appearance: "\u{1F3A8}",
174
+ Agent: "\u{1F916}",
175
+ Board: "\u{1F4CB}",
176
+ Fields: "\u{1F4DD}",
177
+ Notifications: "\u{1F514}"
178
+ };
179
+ function Settings({ kandownDir }) {
180
+ const { exit } = useApp();
181
+ const [config, setConfig] = useState(null);
182
+ const [focusIndex, setFocusIndex] = useState(0);
183
+ const [savedAt, setSavedAt] = useState(null);
184
+ useEffect(() => {
185
+ const loaded = loadConfig(kandownDir);
186
+ setConfig(loaded);
187
+ }, [kandownDir]);
188
+ const persistConfig = useCallback(
189
+ (newConfig) => {
190
+ setConfig(newConfig);
191
+ saveConfig(kandownDir, newConfig);
192
+ setSavedAt(Date.now());
193
+ },
194
+ [kandownDir]
195
+ );
196
+ useEffect(() => {
197
+ if (savedAt === null) return;
198
+ const timer = setTimeout(() => setSavedAt(null), 2e3);
199
+ return () => clearTimeout(timer);
200
+ }, [savedAt]);
201
+ useInput((input, key) => {
202
+ if (!config) return;
203
+ if (key.escape || input === "q") {
204
+ exit();
205
+ return;
206
+ }
207
+ if (key.upArrow) {
208
+ setFocusIndex((i) => Math.max(0, i - 1));
209
+ return;
210
+ }
211
+ if (key.downArrow) {
212
+ setFocusIndex((i) => Math.min(SETTINGS.length - 1, i + 1));
213
+ return;
214
+ }
215
+ const setting = SETTINGS[focusIndex];
216
+ if (!setting) return;
217
+ const currentValue = getConfigValue(config, setting.key);
218
+ if (setting.type === "toggle" && (input === " " || key.return)) {
219
+ persistConfig(setConfigValue(config, setting.key, !currentValue));
220
+ return;
221
+ }
222
+ if (setting.type === "select" && setting.options) {
223
+ const options = setting.options;
224
+ const currentIdx = options.indexOf(String(currentValue));
225
+ let newIdx = currentIdx;
226
+ if (key.leftArrow) {
227
+ newIdx = Math.max(0, currentIdx - 1);
228
+ } else if (key.rightArrow) {
229
+ newIdx = Math.min(options.length - 1, currentIdx + 1);
230
+ } else if (input === " " || key.return) {
231
+ newIdx = (currentIdx + 1) % options.length;
232
+ } else {
233
+ return;
234
+ }
235
+ const newValue = options[newIdx];
236
+ persistConfig(setConfigValue(config, setting.key, newValue));
237
+ return;
238
+ }
239
+ if (setting.type === "number") {
240
+ const num = Number(currentValue);
241
+ const min = setting.min ?? 0;
242
+ const max = setting.max ?? 99;
243
+ if (key.leftArrow) {
244
+ persistConfig(setConfigValue(config, setting.key, Math.max(min, num - 1)));
245
+ } else if (key.rightArrow) {
246
+ persistConfig(setConfigValue(config, setting.key, Math.min(max, num + 1)));
247
+ }
248
+ }
249
+ });
250
+ if (!config) {
251
+ return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Loading\u2026" }) });
252
+ }
253
+ const showSaved = savedAt !== null;
254
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
255
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [
256
+ /* @__PURE__ */ jsxs(Box, { children: [
257
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: " \u256D\u2500 " }),
258
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "white", children: "KANDOWN SETTINGS" }),
259
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: " \u2500\u256E" })
260
+ ] }),
261
+ /* @__PURE__ */ jsx(Box, { children: showSaved && /* @__PURE__ */ jsxs(Text, { color: "green", bold: true, children: [
262
+ "\u2713 saved",
263
+ " "
264
+ ] }) })
265
+ ] }),
266
+ SECTIONS.map((section) => {
267
+ const items = SETTINGS.filter((s) => s.section === section);
268
+ const icon = SECTION_ICONS[section] ?? "\u2022";
269
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
270
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { color: "cyan", bold: true, children: [
271
+ " ",
272
+ icon,
273
+ " ",
274
+ section
275
+ ] }) }),
276
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + "\u2500".repeat(LABEL_WIDTH + VALUE_WIDTH + 4) }) }),
277
+ items.map((setting) => {
278
+ const globalIdx = SETTINGS.indexOf(setting);
279
+ const focused = globalIdx === focusIndex;
280
+ const value = getConfigValue(config, setting.key);
281
+ return /* @__PURE__ */ jsx(
282
+ SettingRow,
283
+ {
284
+ setting,
285
+ value,
286
+ focused
287
+ },
288
+ setting.key
289
+ );
290
+ })
291
+ ] }, section);
292
+ }),
293
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate Space toggle \u2190\u2192 change Q quit" }) })
294
+ ] });
295
+ }
296
+ function SettingRow({ setting, value, focused }) {
297
+ const marker = focused ? "\u203A" : " ";
298
+ const markerColor = focused ? "yellow" : void 0;
299
+ const labelColor = focused ? "white" : "gray";
300
+ return /* @__PURE__ */ jsxs(Box, { children: [
301
+ /* @__PURE__ */ jsxs(Text, { color: markerColor, bold: focused, children: [
302
+ " ",
303
+ marker,
304
+ " "
305
+ ] }),
306
+ /* @__PURE__ */ jsx(Box, { width: LABEL_WIDTH, children: /* @__PURE__ */ jsx(Text, { color: labelColor, bold: focused, children: setting.label }) }),
307
+ /* @__PURE__ */ jsx(Box, { width: VALUE_WIDTH, justifyContent: "flex-end", children: /* @__PURE__ */ jsx(ValueDisplay, { setting, value, focused }) })
308
+ ] });
309
+ }
310
+ function ValueDisplay({ setting, value, focused }) {
311
+ if (setting.type === "toggle") {
312
+ const on = Boolean(value);
313
+ return /* @__PURE__ */ jsx(Text, { color: on ? "green" : "gray", bold: on, children: on ? "\u25CF ON" : "\u25CB OFF" });
314
+ }
315
+ if (setting.type === "select" && setting.options) {
316
+ const options = setting.options;
317
+ const idx = options.indexOf(String(value));
318
+ const atStart = idx <= 0;
319
+ const atEnd = idx >= options.length - 1;
320
+ const displayValue = String(value);
321
+ if (focused) {
322
+ return /* @__PURE__ */ jsxs(Box, { children: [
323
+ /* @__PURE__ */ jsx(Text, { color: atStart ? "gray" : "cyan", children: "\u25C2 " }),
324
+ /* @__PURE__ */ jsx(Text, { color: "white", bold: true, children: displayValue }),
325
+ /* @__PURE__ */ jsx(Text, { color: atEnd ? "gray" : "cyan", children: " \u25B8" })
326
+ ] });
327
+ }
328
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: displayValue });
329
+ }
330
+ if (setting.type === "number") {
331
+ const num = Number(value);
332
+ const atMin = num <= (setting.min ?? 0);
333
+ const atMax = num >= (setting.max ?? 99);
334
+ if (focused) {
335
+ return /* @__PURE__ */ jsxs(Box, { children: [
336
+ /* @__PURE__ */ jsx(Text, { color: atMin ? "gray" : "cyan", children: "\u25C2 " }),
337
+ /* @__PURE__ */ jsx(Text, { color: "white", bold: true, children: num }),
338
+ /* @__PURE__ */ jsx(Text, { color: atMax ? "gray" : "cyan", children: " \u25B8" })
339
+ ] });
340
+ }
341
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: num });
342
+ }
343
+ return /* @__PURE__ */ jsx(Text, { children: String(value) });
344
+ }
345
+
346
+ // src/cli/screens/board.tsx
347
+ import { useState as useState3, useEffect as useEffect2, useCallback as useCallback2 } from "react";
348
+ import { Box as Box3, Text as Text3, useInput as useInput3, useApp as useApp2 } from "ink";
349
+
350
+ // src/cli/lib/board-reader.ts
351
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
352
+ import { dirname, join as join2 } from "path";
353
+
354
+ // src/lib/types.ts
355
+ var DEFAULT_COLUMNS = ["Backlog", "Todo", "In Progress", "Review", "Done"];
356
+
357
+ // src/lib/parser.ts
358
+ function parseSimpleYaml(yaml) {
359
+ const obj = {};
360
+ if (!yaml || typeof yaml !== "string") return obj;
361
+ const lines = yaml.split("\n");
362
+ for (let i = 0; i < lines.length; i++) {
363
+ const line = lines[i] ?? "";
364
+ const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
365
+ if (!m) continue;
366
+ const key = m[1];
367
+ if (!key) continue;
368
+ let val = m[2]?.trim() ?? "";
369
+ if (val === "|") {
370
+ const block = [];
371
+ i++;
372
+ while (i < lines.length && (/^\s+/.test(lines[i] ?? "") || (lines[i] ?? "") === "")) {
373
+ block.push((lines[i] ?? "").replace(/^ /, ""));
374
+ i++;
375
+ }
376
+ i--;
377
+ obj[key] = block.join("\n").trimEnd();
378
+ continue;
379
+ }
380
+ if (typeof val !== "string") val = "";
381
+ if (val.startsWith("[") && val.endsWith("]")) {
382
+ const arr = val.slice(1, -1).split(",").map((s) => s && typeof s === "string" ? s.trim().replace(/^["']|["']$/g, "") : "").filter(Boolean);
383
+ obj[key] = arr;
384
+ } else {
385
+ obj[key] = typeof val === "string" ? val.replace(/^["']|["']$/g, "") : val;
386
+ }
387
+ }
388
+ return obj;
389
+ }
390
+ function parseTaskFile(md) {
391
+ if (!md || typeof md !== "string") {
392
+ return { frontmatter: { id: "", title: "" }, body: "" };
393
+ }
394
+ const lines = md.split("\n");
395
+ if (lines[0] && lines[0].trim() === "---") {
396
+ const fmLines = [];
397
+ let i = 1;
398
+ while (i < lines.length && lines[i].trim() !== "---") {
399
+ fmLines.push(lines[i]);
400
+ i++;
401
+ }
402
+ const body = lines.slice(i + 1).join("\n").trimStart();
403
+ const fm = parseSimpleYaml(fmLines.join("\n"));
404
+ return { frontmatter: fm, body };
405
+ }
406
+ return { frontmatter: { id: "", title: "" }, body: md };
407
+ }
408
+ function normalizeStatus(status) {
409
+ const value = typeof status === "string" ? status.trim() : "";
410
+ return value || "Backlog";
411
+ }
412
+ function normalizePriority(priority) {
413
+ if (typeof priority !== "string") return null;
414
+ const value = priority.toUpperCase();
415
+ return /^(P1|P2|P3|P4)$/.test(value) ? value : null;
416
+ }
417
+ function normalizeOwnerType(ownerType) {
418
+ if (typeof ownerType !== "string") return "";
419
+ const value = ownerType.toLowerCase();
420
+ return value === "human" || value === "ai" ? value : "";
421
+ }
422
+ function taskOrder(task) {
423
+ const value = task.frontmatter.order;
424
+ if (typeof value === "number" && Number.isFinite(value)) return value;
425
+ if (typeof value === "string" && value.trim()) {
426
+ const parsed = Number(value);
427
+ if (Number.isFinite(parsed)) return parsed;
428
+ }
429
+ return Number.MAX_SAFE_INTEGER;
430
+ }
431
+ function taskToBoardTask(task) {
432
+ const { frontmatter, body } = task;
433
+ const { subtasks } = extractSubtasks(body);
434
+ const done = subtasks.filter((s) => s.done).length;
435
+ const total = subtasks.length;
436
+ const status = normalizeStatus(frontmatter.status);
437
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0) : [];
438
+ return {
439
+ id: frontmatter.id || "",
440
+ title: frontmatter.title || frontmatter.id || "Untitled task",
441
+ checked: /done|termin|closed|complet/i.test(status),
442
+ tags,
443
+ assignee: typeof frontmatter.assignee === "string" && frontmatter.assignee ? frontmatter.assignee : null,
444
+ priority: normalizePriority(frontmatter.priority),
445
+ ownerType: normalizeOwnerType(frontmatter.ownerType),
446
+ progress: total > 0 ? { done, total } : null
447
+ };
448
+ }
449
+ function buildColumnsFromTasks(tasks, configuredColumns = DEFAULT_COLUMNS) {
450
+ const columnNames = configuredColumns.length > 0 ? configuredColumns : DEFAULT_COLUMNS;
451
+ const columnsByName = /* @__PURE__ */ new Map();
452
+ const configured = columnNames.map((name) => ({ name, tasks: [] }));
453
+ for (const column of configured) columnsByName.set(column.name.toLowerCase(), column);
454
+ const unknownColumns = [];
455
+ const sortedTasks = [...tasks].filter((task) => Boolean(task.frontmatter.id)).sort((a, b) => {
456
+ const byOrder = taskOrder(a) - taskOrder(b);
457
+ if (byOrder !== 0) return byOrder;
458
+ return a.frontmatter.id.localeCompare(b.frontmatter.id, void 0, { numeric: true });
459
+ });
460
+ for (const task of sortedTasks) {
461
+ const status = normalizeStatus(task.frontmatter.status);
462
+ let column = columnsByName.get(status.toLowerCase());
463
+ if (!column) {
464
+ column = { name: status, tasks: [] };
465
+ columnsByName.set(status.toLowerCase(), column);
466
+ unknownColumns.push(column);
467
+ }
468
+ column.tasks.push(taskToBoardTask(task));
469
+ }
470
+ return [...unknownColumns, ...configured];
471
+ }
472
+ function extractSubtasks(body) {
473
+ const subtasks = [];
474
+ if (!body || typeof body !== "string") return { subtasks, bodyWithoutSubtasks: body ?? "" };
475
+ const lines = body.split("\n");
476
+ const kept = [];
477
+ let inSubtaskSection = false;
478
+ for (const line of lines) {
479
+ if (/^#{1,6}\s+(subtasks?|sous[- ]t[âa]ches?|crit[èe]res?)/i.test(line)) {
480
+ inSubtaskSection = true;
481
+ kept.push(line);
482
+ continue;
483
+ }
484
+ if (/^#{1,6}\s+/.test(line) && inSubtaskSection) {
485
+ inSubtaskSection = false;
486
+ kept.push(line);
487
+ continue;
488
+ }
489
+ const m = line.match(/^\s*-\s+\[([ xX])\]\s+(.+)$/);
490
+ if (m && inSubtaskSection) {
491
+ const text = m[2]?.trim() ?? "";
492
+ subtasks.push({ done: (m[1]?.toLowerCase() ?? "") === "x", text });
493
+ continue;
494
+ }
495
+ const descMatch = line.match(/^\s*\[DESC\]\s*(.*)$/);
496
+ if (descMatch && subtasks.length > 0) {
497
+ subtasks[subtasks.length - 1].description = descMatch[1];
498
+ continue;
499
+ }
500
+ const reportMatch = line.match(/^\s*\[REPORT\]\s*(.*)$/);
501
+ if (reportMatch && subtasks.length > 0) {
502
+ subtasks[subtasks.length - 1].report = reportMatch[1];
503
+ continue;
504
+ }
505
+ kept.push(line);
506
+ }
507
+ return { subtasks, bodyWithoutSubtasks: kept.join("\n") };
508
+ }
509
+
510
+ // src/lib/serializer.ts
511
+ function serializeTaskFile(frontmatter, body) {
512
+ const lines = ["---"];
513
+ if (frontmatter && typeof frontmatter === "object") {
514
+ for (const [k, v] of Object.entries(frontmatter)) {
515
+ if (v === null || v === void 0 || v === "") continue;
516
+ if (Array.isArray(v)) {
517
+ if (v.length === 0) continue;
518
+ lines.push(`${k}: [${v.join(", ")}]`);
519
+ } else if (typeof v === "string" && v.includes("\n")) {
520
+ lines.push(`${k}: |`);
521
+ lines.push(...v.split("\n").map((line) => ` ${line}`));
522
+ } else if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
523
+ lines.push(`${k}: ${v}`);
524
+ }
525
+ }
526
+ }
527
+ lines.push("---");
528
+ lines.push("");
529
+ lines.push((body ?? "").trim());
530
+ lines.push("");
531
+ return lines.join("\n");
532
+ }
533
+
534
+ // src/cli/lib/board-reader.ts
535
+ function getProjectRoot(kandownDir) {
536
+ return dirname(kandownDir);
537
+ }
538
+ function listTaskIds(kandownDir) {
539
+ const tasksDir = join2(kandownDir, "tasks");
540
+ if (!existsSync2(tasksDir)) return [];
541
+ return readdirSync(tasksDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
542
+ }
543
+ function readBoard(kandownDir) {
544
+ const config = loadConfig(kandownDir);
545
+ const tasks = listTaskIds(kandownDir).map((id) => {
546
+ const task = readTask(kandownDir, id);
547
+ return {
548
+ ...task,
549
+ frontmatter: {
550
+ ...task.frontmatter,
551
+ id: task.frontmatter.id || id,
552
+ status: task.frontmatter.status || "Backlog"
553
+ }
554
+ };
555
+ });
556
+ return {
557
+ frontmatter: null,
558
+ title: "Project Kanban",
559
+ columns: buildColumnsFromTasks(tasks, config.board.columns)
560
+ };
561
+ }
562
+ function readTask(kandownDir, taskId) {
563
+ const taskPath = join2(kandownDir, "tasks", `${taskId}.md`);
564
+ if (!existsSync2(taskPath)) {
565
+ return {
566
+ frontmatter: { id: taskId, title: `Task ${taskId}`, status: "Backlog" },
567
+ body: ""
568
+ };
569
+ }
570
+ const content = readFileSync2(taskPath, "utf8");
571
+ const parsed = parseTaskFile(content);
572
+ return {
573
+ ...parsed,
574
+ frontmatter: {
575
+ ...parsed.frontmatter,
576
+ id: parsed.frontmatter.id || taskId,
577
+ status: parsed.frontmatter.status || "Backlog"
578
+ }
579
+ };
580
+ }
581
+ function readAgentDoc(kandownDir) {
582
+ const root = getProjectRoot(kandownDir);
583
+ const candidates = [
584
+ join2(root, "AGENT_KANDOWN_COMPACT.md"),
585
+ join2(root, "AGENT_KANDOWN.md"),
586
+ join2(kandownDir, "AGENT.md")
587
+ ];
588
+ for (const candidate of candidates) {
589
+ if (existsSync2(candidate)) {
590
+ return readFileSync2(candidate, "utf8");
591
+ }
592
+ }
593
+ return "";
594
+ }
595
+ function moveTaskToColumn(kandownDir, taskId, targetColumn) {
596
+ const taskPath = join2(kandownDir, "tasks", `${taskId}.md`);
597
+ if (!existsSync2(taskPath)) return false;
598
+ const parsed = readTask(kandownDir, taskId);
599
+ writeFileSync2(taskPath, serializeTaskFile({
600
+ ...parsed.frontmatter,
601
+ id: taskId,
602
+ status: targetColumn
603
+ }, parsed.body), "utf8");
604
+ return true;
605
+ }
606
+
607
+ // src/cli/lib/agents.ts
608
+ import { execFileSync } from "child_process";
609
+ var AGENTS = [
610
+ {
611
+ id: "claude",
612
+ name: "Claude Code",
613
+ bin: "claude",
614
+ description: "Anthropic Claude (interactive session)",
615
+ interactive: true,
616
+ buildCommand: ({ systemPrompt, taskPrompt }) => {
617
+ const combined = `${systemPrompt}
618
+
619
+ ---
620
+
621
+ ${taskPrompt}`;
622
+ return ["claude", combined];
623
+ }
624
+ },
625
+ {
626
+ id: "codex",
627
+ name: "OpenAI Codex",
628
+ bin: "codex",
629
+ description: "OpenAI Codex CLI",
630
+ interactive: true,
631
+ buildCommand: ({ systemPrompt, taskPrompt }) => {
632
+ const combined = `${systemPrompt}
633
+
634
+ ---
635
+
636
+ ${taskPrompt}`;
637
+ return ["codex", combined];
638
+ }
639
+ },
640
+ {
641
+ id: "gemini",
642
+ name: "Gemini CLI",
643
+ bin: "gemini",
644
+ description: "Google Gemini CLI",
645
+ interactive: true,
646
+ buildCommand: ({ systemPrompt, taskPrompt }) => {
647
+ const combined = `${systemPrompt}
648
+
649
+ ---
650
+
651
+ ${taskPrompt}`;
652
+ return ["gemini", "-p", combined];
653
+ }
654
+ },
655
+ {
656
+ id: "goose",
657
+ name: "Goose",
658
+ bin: "goose",
659
+ description: "Block open-source AI agent",
660
+ interactive: false,
661
+ buildCommand: ({ systemPrompt, taskPrompt }) => {
662
+ const combined = `${systemPrompt}
663
+
664
+ ---
665
+
666
+ ${taskPrompt}`;
667
+ return ["goose", "run", "--text", combined];
668
+ }
669
+ },
670
+ {
671
+ id: "aider",
672
+ name: "Aider",
673
+ bin: "aider",
674
+ description: "Git-aware AI pair programmer",
675
+ interactive: true,
676
+ buildCommand: ({ systemPrompt, taskPrompt }) => {
677
+ const combined = `${systemPrompt}
678
+
679
+ ---
680
+
681
+ ${taskPrompt}`;
682
+ return ["aider", "--message", combined];
683
+ }
684
+ },
685
+ {
686
+ id: "opencode",
687
+ name: "OpenCode",
688
+ bin: "opencode",
689
+ description: "SST AI coding TUI",
690
+ interactive: true,
691
+ buildCommand: ({ taskPrompt }) => {
692
+ return ["opencode"];
693
+ }
694
+ }
695
+ ];
696
+ var installCache = /* @__PURE__ */ new Map();
697
+ function isAgentInstalled(bin) {
698
+ if (installCache.has(bin)) return installCache.get(bin);
699
+ try {
700
+ execFileSync("which", [bin], { stdio: "ignore" });
701
+ installCache.set(bin, true);
702
+ return true;
703
+ } catch {
704
+ installCache.set(bin, false);
705
+ return false;
706
+ }
707
+ }
708
+ function detectInstalledAgents() {
709
+ return AGENTS.filter((agent) => isAgentInstalled(agent.bin));
710
+ }
711
+ function getAgentById(id) {
712
+ return AGENTS.find((a) => a.id === id);
713
+ }
714
+ function buildPrompt(agentDoc, taskContent, taskId, kandownDir) {
715
+ const systemPrompt = agentDoc.trim();
716
+ const taskPrompt = [
717
+ `## Your Task: ${taskId}`,
718
+ "",
719
+ taskContent.trim(),
720
+ "",
721
+ "---",
722
+ "",
723
+ `**Start working on task ${taskId} now.**`,
724
+ "",
725
+ `The kandown directory is at: \`${kandownDir}\``,
726
+ "",
727
+ "Before anything else:",
728
+ `1. Set task ${taskId} frontmatter status to "In Progress" (it may already be there \u2014 that's fine)`,
729
+ "2. Work through each subtask, checking them off and adding reports as you go",
730
+ '3. When done, write the completion report and set the task status to "Done"'
731
+ ].join("\n");
732
+ return { systemPrompt, taskPrompt };
733
+ }
734
+
735
+ // src/cli/lib/launcher.ts
736
+ import { execSync, spawn } from "child_process";
737
+ import { writeFileSync as writeFileSync3 } from "fs";
738
+ import { join as join3 } from "path";
739
+ import { tmpdir } from "os";
740
+ function isInTmux() {
741
+ return !!process.env.TMUX;
742
+ }
743
+ function launchAgent(opts) {
744
+ const { taskId, agentId, kandownDir, onBeforeExec } = opts;
745
+ const agentDef = getAgentById(agentId);
746
+ if (!agentDef) {
747
+ throw new Error(`Unknown agent: ${agentId}`);
748
+ }
749
+ const task = readTask(kandownDir, taskId);
750
+ const agentDoc = readAgentDoc(kandownDir);
751
+ const taskFileContent = [
752
+ `---`,
753
+ `id: ${task.frontmatter.id}`,
754
+ `title: ${task.frontmatter.title}`,
755
+ `status: ${task.frontmatter.status ?? "unknown"}`,
756
+ `---`,
757
+ "",
758
+ task.body.trim()
759
+ ].join("\n");
760
+ const { systemPrompt, taskPrompt } = buildPrompt(agentDoc, taskFileContent, taskId, kandownDir);
761
+ moveTaskToColumn(kandownDir, taskId, "In Progress");
762
+ const contextFile = join3(tmpdir(), `kandown-${taskId}-context.md`);
763
+ writeFileSync3(contextFile, `${systemPrompt}
764
+
765
+ ---
766
+
767
+ ${taskPrompt}`, "utf8");
768
+ const launchOpts = { systemPrompt, taskPrompt, kandownDir, taskId };
769
+ const [binary, ...args] = agentDef.buildCommand(launchOpts);
770
+ if (!binary) {
771
+ throw new Error(`Agent ${agentId} returned an empty command`);
772
+ }
773
+ if (isInTmux()) {
774
+ const shellCmd = buildShellCmd(binary, args);
775
+ execSync(`tmux split-window -h -p 50 ${shellescape(shellCmd)}`, {
776
+ stdio: "inherit"
777
+ });
778
+ } else {
779
+ onBeforeExec?.();
780
+ const child = spawn(binary, args, {
781
+ stdio: "inherit",
782
+ env: {
783
+ ...process.env,
784
+ // 📖 Expose the context file path so agents that support env vars can use it
785
+ KANDOWN_CONTEXT_FILE: contextFile,
786
+ KANDOWN_TASK_ID: taskId,
787
+ KANDOWN_DIR: kandownDir
788
+ }
789
+ });
790
+ child.on("exit", (code) => {
791
+ process.exit(code ?? 0);
792
+ });
793
+ }
794
+ }
795
+ function buildShellCmd(binary, args) {
796
+ const parts = [binary, ...args].map(shellescape);
797
+ return parts.join(" ");
798
+ }
799
+ function shellescape(str) {
800
+ return `'${str.replace(/'/g, "'\\''")}'`;
801
+ }
802
+
803
+ // src/cli/screens/agent-picker.tsx
804
+ import { useState as useState2 } from "react";
805
+ import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
806
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
807
+ function AgentPicker({ agents, taskId, onSelect, onCancel }) {
808
+ const [cursor, setCursor] = useState2(0);
809
+ useInput2((input, key) => {
810
+ if (key.escape || input === "q") {
811
+ onCancel();
812
+ return;
813
+ }
814
+ if (key.downArrow || input === "j") {
815
+ setCursor((c) => Math.min(c + 1, agents.length - 1));
816
+ return;
817
+ }
818
+ if (key.upArrow || input === "k") {
819
+ setCursor((c) => Math.max(c - 1, 0));
820
+ return;
821
+ }
822
+ if (key.return) {
823
+ const agent = agents[cursor];
824
+ if (agent) onSelect(agent.id);
825
+ return;
826
+ }
827
+ const num = parseInt(input, 10);
828
+ if (!isNaN(num) && num >= 1 && num <= agents.length) {
829
+ const agent = agents[num - 1];
830
+ if (agent) onSelect(agent.id);
831
+ }
832
+ });
833
+ const maxNameLen = Math.max(...agents.map((a) => a.name.length));
834
+ const boxWidth = Math.min(60, Math.max(40, maxNameLen + 30));
835
+ return /* @__PURE__ */ jsxs2(
836
+ Box2,
837
+ {
838
+ flexDirection: "column",
839
+ borderStyle: "round",
840
+ borderColor: "cyan",
841
+ paddingX: 2,
842
+ paddingY: 1,
843
+ width: boxWidth,
844
+ children: [
845
+ /* @__PURE__ */ jsxs2(Box2, { marginBottom: 1, children: [
846
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "SELECT AGENT" }),
847
+ /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
848
+ " ",
849
+ "for ",
850
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: taskId })
851
+ ] })
852
+ ] }),
853
+ agents.map((agent, idx) => {
854
+ const isFocused = idx === cursor;
855
+ const numHint = idx < 9 ? `${idx + 1} ` : " ";
856
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
857
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: numHint }),
858
+ /* @__PURE__ */ jsxs2(Text2, { color: isFocused ? "black" : void 0, backgroundColor: isFocused ? "cyan" : void 0, children: [
859
+ isFocused ? "\u203A" : " ",
860
+ " ",
861
+ /* @__PURE__ */ jsx2(Text2, { bold: isFocused, children: agent.name }),
862
+ " ",
863
+ /* @__PURE__ */ jsx2(Text2, { dimColor: !isFocused, children: agent.description })
864
+ ] })
865
+ ] }, agent.id);
866
+ }),
867
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: "gray", dimColor: true, children: [
868
+ "\u2191\u2193 or 1\u2013",
869
+ agents.length,
870
+ " select Enter launch Esc cancel"
871
+ ] }) })
872
+ ]
873
+ }
874
+ );
875
+ }
876
+
877
+ // src/cli/screens/board.tsx
878
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
879
+ function truncate(str, maxLen) {
880
+ if (str.length <= maxLen) return str;
881
+ return str.slice(0, maxLen - 1) + "\u2026";
882
+ }
883
+ function pad(str, len) {
884
+ const t = truncate(str, len);
885
+ return t + " ".repeat(Math.max(0, len - t.length));
886
+ }
887
+ function termWidth() {
888
+ return process.stdout.columns || 80;
889
+ }
890
+ function calcColWidth(numCols) {
891
+ const available = termWidth() - (numCols - 1);
892
+ return Math.max(12, Math.floor(available / numCols));
893
+ }
894
+ var RE_HEADER = /^#{1,3}\s/;
895
+ var RE_SUBTASK = /^\s*-\s+\[([ xX])\]/;
896
+ var RE_DONE_SUBTASK = /^\s*-\s+\[x\]/i;
897
+ function TaskRow({
898
+ task,
899
+ focused,
900
+ colWidth
901
+ }) {
902
+ const cursor = focused ? "\u25B8" : " ";
903
+ const check = task.checked ? "\u2713" : "\u25CB";
904
+ const idStr = task.id;
905
+ const available = colWidth - 4 - idStr.length - 1;
906
+ const titleStr = truncate(task.title, Math.max(4, available));
907
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
908
+ /* @__PURE__ */ jsxs3(Text3, { color: focused ? "cyan" : void 0, bold: focused, children: [
909
+ cursor,
910
+ " "
911
+ ] }),
912
+ /* @__PURE__ */ jsxs3(Text3, { color: task.checked ? "green" : focused ? "white" : "gray", children: [
913
+ check,
914
+ " "
915
+ ] }),
916
+ /* @__PURE__ */ jsx3(Text3, { color: focused ? "cyan" : "yellow", bold: focused, children: idStr }),
917
+ /* @__PURE__ */ jsxs3(Text3, { color: focused ? "white" : "gray", children: [
918
+ " ",
919
+ titleStr
920
+ ] })
921
+ ] });
922
+ }
923
+ function KanbanColumn({
924
+ name,
925
+ tasks,
926
+ focusedRow,
927
+ isFocused,
928
+ colWidth
929
+ }) {
930
+ const headerBg = isFocused ? "cyan" : void 0;
931
+ const headerColor = isFocused ? "black" : "cyan";
932
+ const countStr = tasks.length > 0 ? ` (${tasks.length})` : "";
933
+ const headerText = truncate(`${name}${countStr}`, colWidth);
934
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", width: colWidth, marginRight: 1, children: [
935
+ /* @__PURE__ */ jsx3(Box3, { backgroundColor: headerBg, children: /* @__PURE__ */ jsx3(Text3, { color: headerColor, bold: true, children: pad(headerText, colWidth) }) }),
936
+ /* @__PURE__ */ jsx3(Text3, { color: isFocused ? "cyan" : "gray", children: "\u2500".repeat(colWidth) }),
937
+ tasks.length === 0 ? /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
938
+ " ".repeat(2),
939
+ "(empty)"
940
+ ] }) : tasks.map((task, idx) => /* @__PURE__ */ jsx3(
941
+ TaskRow,
942
+ {
943
+ task,
944
+ focused: isFocused && idx === focusedRow,
945
+ colWidth
946
+ },
947
+ task.id
948
+ ))
949
+ ] });
950
+ }
951
+ function BoardHeader({ title, inTmux }) {
952
+ const tmuxHint = inTmux ? " tmux" : "";
953
+ return /* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, justifyContent: "space-between", children: [
954
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, color: "cyan", children: [
955
+ " ",
956
+ "KANDOWN",
957
+ tmuxHint,
958
+ " ",
959
+ title
960
+ ] }),
961
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", dimColor: true, children: "h/l cols j/k tasks Enter detail a agent r reload q quit" })
962
+ ] });
963
+ }
964
+ function StatusBar({ message, task }) {
965
+ if (message) {
966
+ return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: message }) });
967
+ }
968
+ if (!task) return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "gray", children: " " }) });
969
+ return /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
970
+ task.id.replace(/^t/, ""),
971
+ task.progress ? ` (${task.progress.done}/${task.progress.total})` : "",
972
+ " ",
973
+ task.checked ? "\u2713 done" : "\u25CB open"
974
+ ] }) });
975
+ }
976
+ function TaskDetail({
977
+ task,
978
+ taskId,
979
+ scrollOffset
980
+ }) {
981
+ const fm = task.frontmatter;
982
+ const bodyLines = task.body.split("\n");
983
+ const maxVisible = (process.stdout.rows || 24) - 10;
984
+ const visibleLines = bodyLines.slice(scrollOffset, scrollOffset + maxVisible);
985
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 2, children: [
986
+ /* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, children: [
987
+ /* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: taskId }),
988
+ /* @__PURE__ */ jsxs3(Text3, { color: "white", bold: true, children: [
989
+ " ",
990
+ fm.title
991
+ ] })
992
+ ] }),
993
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
994
+ "status: ",
995
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: fm.status ?? "\u2014" }),
996
+ fm.priority ? ` priority: ${fm.priority}` : "",
997
+ fm.assignee ? ` assignee: ${fm.assignee}` : "",
998
+ fm.due ? ` due: ${fm.due}` : ""
999
+ ] }) }),
1000
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "\u2500".repeat(termWidth() - 4) }),
1001
+ visibleLines.map((line, idx) => {
1002
+ const isHeader = RE_HEADER.test(line);
1003
+ const isSubtask = RE_SUBTASK.test(line);
1004
+ const isDone = RE_DONE_SUBTASK.test(line);
1005
+ return /* @__PURE__ */ jsx3(
1006
+ Text3,
1007
+ {
1008
+ color: isHeader ? "cyan" : isDone ? "green" : isSubtask ? "white" : "gray",
1009
+ bold: isHeader,
1010
+ children: line || " "
1011
+ },
1012
+ scrollOffset + idx
1013
+ );
1014
+ }),
1015
+ bodyLines.length > maxVisible && /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
1016
+ " ",
1017
+ "\u2191\u2193 scroll (",
1018
+ scrollOffset + 1,
1019
+ "\u2013",
1020
+ Math.min(scrollOffset + maxVisible, bodyLines.length),
1021
+ "/",
1022
+ bodyLines.length,
1023
+ " lines)"
1024
+ ] })
1025
+ ] });
1026
+ }
1027
+ function Board({ kandownDir }) {
1028
+ const { exit } = useApp2();
1029
+ const [board, setBoard] = useState3(null);
1030
+ const [colIndex, setColIndex] = useState3(0);
1031
+ const [rowIndex, setRowIndex] = useState3(0);
1032
+ const [mode, setMode] = useState3("browse");
1033
+ const [detailTask, setDetailTask] = useState3(null);
1034
+ const [detailTaskId, setDetailTaskId] = useState3("");
1035
+ const [detailScroll, setDetailScroll] = useState3(0);
1036
+ const [installedAgents, setInstalledAgents] = useState3([]);
1037
+ const [statusMsg, setStatusMsg] = useState3("");
1038
+ const inTmux = isInTmux();
1039
+ useEffect2(() => {
1040
+ const loaded = readBoard(kandownDir);
1041
+ setBoard(loaded);
1042
+ setInstalledAgents(detectInstalledAgents());
1043
+ }, [kandownDir]);
1044
+ const reloadBoard = useCallback2(() => {
1045
+ const loaded = readBoard(kandownDir);
1046
+ setBoard(loaded);
1047
+ setStatusMsg("Board reloaded");
1048
+ setTimeout(() => setStatusMsg(""), 1500);
1049
+ }, [kandownDir]);
1050
+ const getFocusedTask = useCallback2(() => {
1051
+ if (!board) return null;
1052
+ const col = board.columns[colIndex];
1053
+ if (!col || col.tasks.length === 0) return null;
1054
+ return col.tasks[Math.min(rowIndex, col.tasks.length - 1)] ?? null;
1055
+ }, [board, colIndex, rowIndex]);
1056
+ const openDetail = useCallback2((taskId) => {
1057
+ const task = readTask(kandownDir, taskId);
1058
+ setDetailTask(task);
1059
+ setDetailTaskId(taskId);
1060
+ setDetailScroll(0);
1061
+ setMode("detail");
1062
+ }, [kandownDir]);
1063
+ const handleAgentSelect = useCallback2((agentId) => {
1064
+ const task = getFocusedTask();
1065
+ const taskId = mode === "detail" ? detailTaskId : task?.id;
1066
+ if (!taskId) return;
1067
+ setMode("browse");
1068
+ setStatusMsg(`Launching ${agentId} for ${taskId}\u2026`);
1069
+ setTimeout(() => {
1070
+ try {
1071
+ launchAgent({
1072
+ taskId,
1073
+ agentId,
1074
+ kandownDir,
1075
+ onBeforeExec: () => exit()
1076
+ });
1077
+ reloadBoard();
1078
+ setStatusMsg(`${agentId} launched in tmux pane`);
1079
+ setTimeout(() => setStatusMsg(""), 3e3);
1080
+ } catch (err) {
1081
+ setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
1082
+ setTimeout(() => setStatusMsg(""), 4e3);
1083
+ }
1084
+ }, 50);
1085
+ }, [mode, detailTaskId, getFocusedTask, kandownDir, exit, reloadBoard]);
1086
+ useInput3((input, key) => {
1087
+ if (mode === "browse") {
1088
+ if (input === "q" || key.escape) {
1089
+ exit();
1090
+ return;
1091
+ }
1092
+ if (input === "r") {
1093
+ reloadBoard();
1094
+ return;
1095
+ }
1096
+ if (input === "l" || key.rightArrow) {
1097
+ const maxCol = (board?.columns.length ?? 1) - 1;
1098
+ setColIndex((c) => Math.min(c + 1, maxCol));
1099
+ setRowIndex(0);
1100
+ return;
1101
+ }
1102
+ if (input === "h" || key.leftArrow) {
1103
+ setColIndex((c) => Math.max(c - 1, 0));
1104
+ setRowIndex(0);
1105
+ return;
1106
+ }
1107
+ if (input === "j" || key.downArrow) {
1108
+ const col = board?.columns[colIndex];
1109
+ const max = Math.max(0, (col?.tasks.length ?? 1) - 1);
1110
+ setRowIndex((r) => Math.min(r + 1, max));
1111
+ return;
1112
+ }
1113
+ if (input === "k" || key.upArrow) {
1114
+ setRowIndex((r) => Math.max(r - 1, 0));
1115
+ return;
1116
+ }
1117
+ if (key.return) {
1118
+ const task = getFocusedTask();
1119
+ if (task) openDetail(task.id);
1120
+ return;
1121
+ }
1122
+ if (input === "a") {
1123
+ if (installedAgents.length === 0) {
1124
+ setStatusMsg("No AI agents found in PATH (install claude, codex, aider, goose\u2026)");
1125
+ setTimeout(() => setStatusMsg(""), 3e3);
1126
+ return;
1127
+ }
1128
+ const task = getFocusedTask();
1129
+ if (!task) return;
1130
+ setMode("agent-picker");
1131
+ return;
1132
+ }
1133
+ }
1134
+ if (mode === "detail") {
1135
+ if (key.escape || input === "q") {
1136
+ setMode("browse");
1137
+ return;
1138
+ }
1139
+ if (input === "j" || key.downArrow) {
1140
+ setDetailScroll((s) => s + 1);
1141
+ return;
1142
+ }
1143
+ if (input === "k" || key.upArrow) {
1144
+ setDetailScroll((s) => Math.max(0, s - 1));
1145
+ return;
1146
+ }
1147
+ if (input === "a") {
1148
+ if (installedAgents.length === 0) {
1149
+ setStatusMsg("No AI agents found in PATH");
1150
+ setTimeout(() => setStatusMsg(""), 3e3);
1151
+ return;
1152
+ }
1153
+ setMode("agent-picker");
1154
+ return;
1155
+ }
1156
+ }
1157
+ });
1158
+ if (!board) {
1159
+ return /* @__PURE__ */ jsx3(Box3, { padding: 2, children: /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "Loading board\u2026" }) });
1160
+ }
1161
+ if (board.columns.length === 0) {
1162
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", padding: 2, children: [
1163
+ /* @__PURE__ */ jsxs3(Text3, { color: "red", bold: true, children: [
1164
+ "No board found at ",
1165
+ kandownDir
1166
+ ] }),
1167
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
1168
+ "Run ",
1169
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "kandown init" }),
1170
+ " to set up kandown in this project."
1171
+ ] })
1172
+ ] });
1173
+ }
1174
+ const colWidth = calcColWidth(board.columns.length);
1175
+ const focusedTask = getFocusedTask();
1176
+ if (mode === "agent-picker") {
1177
+ const taskId = detailTaskId || focusedTask?.id || "";
1178
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1179
+ /* @__PURE__ */ jsx3(BoardHeader, { title: board.title, inTmux }),
1180
+ /* @__PURE__ */ jsx3(
1181
+ AgentPicker,
1182
+ {
1183
+ agents: installedAgents,
1184
+ taskId,
1185
+ onSelect: handleAgentSelect,
1186
+ onCancel: () => setMode(detailTaskId ? "detail" : "browse")
1187
+ }
1188
+ )
1189
+ ] });
1190
+ }
1191
+ if (mode === "detail" && detailTask) {
1192
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1193
+ /* @__PURE__ */ jsxs3(Box3, { marginBottom: 1, justifyContent: "space-between", children: [
1194
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "Esc back a launch agent j/k scroll" }),
1195
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
1196
+ "KANDOWN ",
1197
+ board.title
1198
+ ] })
1199
+ ] }),
1200
+ /* @__PURE__ */ jsx3(TaskDetail, { task: detailTask, taskId: detailTaskId, scrollOffset: detailScroll }),
1201
+ statusMsg && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: statusMsg }) })
1202
+ ] });
1203
+ }
1204
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1205
+ /* @__PURE__ */ jsx3(BoardHeader, { title: board.title, inTmux }),
1206
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "row", children: board.columns.map((col, cIdx) => /* @__PURE__ */ jsx3(
1207
+ KanbanColumn,
1208
+ {
1209
+ name: col.name,
1210
+ tasks: col.tasks,
1211
+ focusedRow: cIdx === colIndex ? rowIndex : -1,
1212
+ isFocused: cIdx === colIndex,
1213
+ colWidth
1214
+ },
1215
+ col.name
1216
+ )) }),
1217
+ /* @__PURE__ */ jsx3(StatusBar, { message: statusMsg, task: focusedTask })
1218
+ ] });
1219
+ }
1220
+
1221
+ // src/cli/app.tsx
1222
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1223
+ function App({ screen, kandownDir }) {
1224
+ switch (screen) {
1225
+ case "settings":
1226
+ return /* @__PURE__ */ jsx4(Settings, { kandownDir });
1227
+ case "board":
1228
+ return /* @__PURE__ */ jsx4(Board, { kandownDir });
1229
+ default:
1230
+ return /* @__PURE__ */ jsx4(Box4, { padding: 2, children: /* @__PURE__ */ jsxs4(Text4, { color: "red", bold: true, children: [
1231
+ "Unknown screen: ",
1232
+ screen
1233
+ ] }) });
1234
+ }
1235
+ }
1236
+
1237
+ // src/cli/tui.tsx
1238
+ import { jsx as jsx5 } from "react/jsx-runtime";
1239
+ async function run(screen, kandownDir) {
1240
+ if (!process.stdin.isTTY) {
1241
+ throw new Error(
1242
+ "kandown TUI requires an interactive terminal. Run this command directly in your terminal."
1243
+ );
1244
+ }
1245
+ process.stdout.write("\x1B[?1049h\x1B[H");
1246
+ const instance = render(/* @__PURE__ */ jsx5(App, { screen, kandownDir }), {
1247
+ exitOnCtrlC: true
1248
+ });
1249
+ try {
1250
+ await instance.waitUntilExit();
1251
+ } finally {
1252
+ process.stdout.write("\x1B[?1049l");
1253
+ }
1254
+ }
1255
+ export {
1256
+ run
1257
+ };