kandown 0.17.0 → 0.17.2

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 CHANGED
@@ -53985,8 +53985,26 @@ var import_react38 = __toESM(require_react(), 1);
53985
53985
  var import_react34 = __toESM(require_react(), 1);
53986
53986
 
53987
53987
  // src/cli/lib/config.ts
53988
- import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2 } from "fs";
53988
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
53989
53989
  import { join } from "path";
53990
+
53991
+ // src/cli/lib/atomic-write.ts
53992
+ import { renameSync, unlinkSync, writeFileSync } from "fs";
53993
+ function atomicWriteFileSync(path, content) {
53994
+ const tmp = `${path}.${process.pid}.tmp`;
53995
+ try {
53996
+ writeFileSync(tmp, content, "utf8");
53997
+ renameSync(tmp, path);
53998
+ } catch (e) {
53999
+ try {
54000
+ unlinkSync(tmp);
54001
+ } catch {
54002
+ }
54003
+ throw e;
54004
+ }
54005
+ }
54006
+
54007
+ // src/cli/lib/config.ts
53990
54008
  var DEFAULT_CONFIG = {
53991
54009
  ui: { language: "en", theme: "auto", skin: "kandown", font: "inter" },
53992
54010
  agent: { suggestFollowUp: false, maxSuggestions: 3 },
@@ -54051,7 +54069,7 @@ function loadConfig(kandownDir) {
54051
54069
  }
54052
54070
  function saveConfig(kandownDir, config) {
54053
54071
  const configPath = join(kandownDir, "kandown.json");
54054
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
54072
+ atomicWriteFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
54055
54073
  }
54056
54074
  function getConfigValue(config, path) {
54057
54075
  const parts = path.split(".");
@@ -54359,7 +54377,7 @@ function ValueDisplay({ setting, value, focused }) {
54359
54377
  var import_react37 = __toESM(require_react(), 1);
54360
54378
 
54361
54379
  // src/cli/lib/board-reader.ts
54362
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
54380
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3 } from "fs";
54363
54381
  import { dirname, join as join2 } from "path";
54364
54382
 
54365
54383
  // src/lib/types.ts
@@ -54635,11 +54653,11 @@ function moveTaskToColumn(kandownDir, taskId, targetColumn) {
54635
54653
  if (!existsSync3(taskPath)) return false;
54636
54654
  try {
54637
54655
  const parsed = readTask(kandownDir, taskId);
54638
- writeFileSync2(taskPath, serializeTaskFile({
54656
+ atomicWriteFileSync(taskPath, serializeTaskFile({
54639
54657
  ...parsed.frontmatter,
54640
54658
  id: taskId,
54641
54659
  status: targetColumn
54642
- }, parsed.body), "utf8");
54660
+ }, parsed.body));
54643
54661
  return true;
54644
54662
  } catch (e) {
54645
54663
  console.error(`[kandown] Failed to move task ${taskId} to ${targetColumn}:`, e.message);
@@ -54648,9 +54666,9 @@ function moveTaskToColumn(kandownDir, taskId, targetColumn) {
54648
54666
  }
54649
54667
 
54650
54668
  // src/cli/lib/daemon.ts
54651
- import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
54669
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
54652
54670
  import { dirname as dirname2, join as join3 } from "path";
54653
- import { spawn } from "child_process";
54671
+ import { execFileSync as execFileSync2, spawn } from "child_process";
54654
54672
  import { createConnection } from "net";
54655
54673
  function metadataPath(kandownDir) {
54656
54674
  return join3(kandownDir, "daemon.json");
@@ -54660,13 +54678,14 @@ function isRecord(value) {
54660
54678
  }
54661
54679
  function parseMetadata(value) {
54662
54680
  if (!isRecord(value)) return null;
54663
- const { pid, port, url, kandownDir, startedAt, version } = value;
54681
+ const { pid, port, url, kandownDir, startedAt, version, token } = value;
54664
54682
  if (typeof pid !== "number" || !Number.isInteger(pid)) return null;
54665
54683
  if (typeof port !== "number" || !Number.isInteger(port)) return null;
54666
54684
  if (typeof url !== "string" || typeof kandownDir !== "string") return null;
54667
54685
  if (typeof startedAt !== "string") return null;
54668
54686
  if (version !== null && typeof version !== "string" && version !== void 0) return null;
54669
- return { pid, port, url, kandownDir, startedAt, version: version ?? null };
54687
+ if (token !== null && typeof token !== "string" && token !== void 0) return null;
54688
+ return { pid, port, url, kandownDir, startedAt, version: version ?? null, token: typeof token === "string" ? token : null };
54670
54689
  }
54671
54690
  function readDaemonMetadata(kandownDir) {
54672
54691
  const path = metadataPath(kandownDir);
@@ -54680,7 +54699,7 @@ function readDaemonMetadata(kandownDir) {
54680
54699
  function removeDaemonMetadata(kandownDir) {
54681
54700
  try {
54682
54701
  const path = metadataPath(kandownDir);
54683
- if (existsSync4(path)) unlinkSync(path);
54702
+ if (existsSync4(path)) unlinkSync2(path);
54684
54703
  } catch {
54685
54704
  }
54686
54705
  }
@@ -54776,20 +54795,45 @@ async function startProjectDaemon(kandownDir, preferredPort) {
54776
54795
  child.unref();
54777
54796
  return waitForDaemon(kandownDir);
54778
54797
  }
54798
+ async function isOwnedKandownDaemon(pid, port, kandownDir) {
54799
+ const remote = await fetchDaemonInfo(port);
54800
+ if (remote) return remote.pid === pid && remote.kandownDir === kandownDir;
54801
+ try {
54802
+ const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], {
54803
+ encoding: "utf8",
54804
+ timeout: 2e3
54805
+ }).trim();
54806
+ return /kandown/.test(cmd) && cmd.includes(kandownDir);
54807
+ } catch {
54808
+ return false;
54809
+ }
54810
+ }
54779
54811
  async function stopProjectDaemon(kandownDir) {
54780
- const status = await getDaemonStatus(kandownDir);
54781
- if (!status.running || !status.metadata) {
54812
+ const metadata = readDaemonMetadata(kandownDir);
54813
+ if (!metadata) return false;
54814
+ const pid = metadata.pid;
54815
+ if (!isProcessAlive(pid)) {
54816
+ removeDaemonMetadata(kandownDir);
54817
+ return false;
54818
+ }
54819
+ if (!await isOwnedKandownDaemon(pid, metadata.port, kandownDir)) {
54782
54820
  removeDaemonMetadata(kandownDir);
54783
54821
  return false;
54784
54822
  }
54785
54823
  try {
54786
- process.kill(status.metadata.pid, "SIGTERM");
54824
+ process.kill(pid, "SIGTERM");
54787
54825
  } catch {
54788
54826
  }
54789
54827
  const started = Date.now();
54790
- while (Date.now() - started < 2500 && isProcessAlive(status.metadata.pid)) {
54828
+ while (Date.now() - started < 2500 && isProcessAlive(pid)) {
54791
54829
  await new Promise((resolve3) => setTimeout(resolve3, 100));
54792
54830
  }
54831
+ if (isProcessAlive(pid)) {
54832
+ try {
54833
+ process.kill(pid, "SIGKILL");
54834
+ } catch {
54835
+ }
54836
+ }
54793
54837
  removeDaemonMetadata(kandownDir);
54794
54838
  return true;
54795
54839
  }
@@ -56700,7 +56744,7 @@ function createWatcher() {
56700
56744
  }
56701
56745
 
56702
56746
  // src/cli/lib/agents.ts
56703
- import { execFileSync as execFileSync2 } from "child_process";
56747
+ import { execFileSync as execFileSync3 } from "child_process";
56704
56748
  var AGENTS = [
56705
56749
  {
56706
56750
  id: "claude",
@@ -56797,7 +56841,7 @@ var installCache = /* @__PURE__ */ new Map();
56797
56841
  function isAgentInstalled(bin) {
56798
56842
  if (installCache.has(bin)) return installCache.get(bin);
56799
56843
  try {
56800
- execFileSync2("which", [bin], { stdio: "ignore" });
56844
+ execFileSync3("which", [bin], { stdio: "ignore" });
56801
56845
  installCache.set(bin, true);
56802
56846
  return true;
56803
56847
  } catch {
@@ -56834,7 +56878,7 @@ function buildPrompt(agentDoc, taskContent, taskId, kandownDir) {
56834
56878
 
56835
56879
  // src/cli/lib/launcher.ts
56836
56880
  import { execSync, spawn as spawn2 } from "child_process";
56837
- import { writeFileSync as writeFileSync3 } from "fs";
56881
+ import { writeFileSync as writeFileSync2 } from "fs";
56838
56882
  import { join as join7 } from "path";
56839
56883
  import { tmpdir } from "os";
56840
56884
  function isInTmux() {
@@ -56870,7 +56914,7 @@ function launchAgent(opts) {
56870
56914
  }
56871
56915
  const contextFile = join7(tmpdir(), `kandown-${taskId}-context.md`);
56872
56916
  try {
56873
- writeFileSync3(contextFile, `${systemPrompt}
56917
+ writeFileSync2(contextFile, `${systemPrompt}
56874
56918
 
56875
56919
  ---
56876
56920
 
@@ -57224,6 +57268,24 @@ function MovePlaceholder({ name, focused, colWidth }) {
57224
57268
  }
57225
57269
  ) });
57226
57270
  }
57271
+ function computeScrollIdx(tasks, focusedRow, contextMenuRow, maxTasksHeight) {
57272
+ if (focusedRow < 0) return 0;
57273
+ const reserveBottom = focusedRow < tasks.length - 1 ? 1 : 0;
57274
+ let currentScroll = 0;
57275
+ while (currentScroll < focusedRow) {
57276
+ const hasTopIndicator = currentScroll > 0;
57277
+ const adjustedMaxHeight = maxTasksHeight - (hasTopIndicator ? 1 : 0) - reserveBottom;
57278
+ let h = 0;
57279
+ for (let k = currentScroll; k <= focusedRow; k++) {
57280
+ h += getTitleCategory(tasks[k].title) !== null ? 3 : 1;
57281
+ if (contextMenuRow === k) h += MENU_HEIGHT;
57282
+ if (k < focusedRow) h += 1;
57283
+ }
57284
+ if (h <= adjustedMaxHeight) break;
57285
+ currentScroll++;
57286
+ }
57287
+ return currentScroll;
57288
+ }
57227
57289
  function KanbanColumn({
57228
57290
  name,
57229
57291
  tasks,
@@ -57234,14 +57296,48 @@ function KanbanColumn({
57234
57296
  contextMenuCursor,
57235
57297
  showMoveTarget,
57236
57298
  isMoveFocused,
57237
- draggedTaskId
57299
+ draggedTaskId,
57300
+ maxTasksHeight
57238
57301
  }) {
57239
57302
  const accent = columnAccentColor(name);
57240
57303
  const headerBg = isFocused ? accent : void 0;
57241
57304
  const headerColor = isFocused ? "black" : accent;
57242
57305
  const countStr = tasks.length > 0 ? ` (${tasks.length})` : "";
57306
+ const scrollIdx = isFocused ? computeScrollIdx(tasks, focusedRow, contextMenuRow ?? -1, maxTasksHeight) : 0;
57307
+ let accumulatedHeight = 0;
57308
+ let endIdx = scrollIdx;
57309
+ const hasTopIndicator = scrollIdx > 0;
57310
+ const topIndicatorHeight = hasTopIndicator ? 1 : 0;
57311
+ while (endIdx < tasks.length) {
57312
+ const hasCategory = getTitleCategory(tasks[endIdx].title) !== null;
57313
+ let taskHeight = hasCategory ? 3 : 1;
57314
+ if (contextMenuRow === endIdx) taskHeight += MENU_HEIGHT;
57315
+ const sepHeight = endIdx < tasks.length - 1 ? 1 : 0;
57316
+ const hasBottomIndicator = endIdx < tasks.length - 1;
57317
+ const bottomIndicatorHeight = hasBottomIndicator ? 1 : 0;
57318
+ const currentMax = maxTasksHeight - topIndicatorHeight - bottomIndicatorHeight;
57319
+ if (accumulatedHeight + taskHeight + sepHeight > currentMax) {
57320
+ if (endIdx === scrollIdx) {
57321
+ endIdx++;
57322
+ }
57323
+ break;
57324
+ }
57325
+ accumulatedHeight += taskHeight + sepHeight;
57326
+ endIdx++;
57327
+ }
57243
57328
  const rows = [];
57244
- tasks.forEach((task, idx) => {
57329
+ if (hasTopIndicator) {
57330
+ rows.push(
57331
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Text, { color: "cyan", dimColor: true, children: [
57332
+ " ".repeat(2),
57333
+ "\u25B2 ",
57334
+ scrollIdx,
57335
+ " more"
57336
+ ] }, "scroll-up")
57337
+ );
57338
+ }
57339
+ for (let idx = scrollIdx; idx < endIdx; idx++) {
57340
+ const task = tasks[idx];
57245
57341
  const hasCategory = getTitleCategory(task.title) !== null;
57246
57342
  rows.push(
57247
57343
  hasCategory ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CategoryTaskRow, { task, focused: !!(isFocused && idx === focusedRow), dragging: task.id === draggedTaskId, colWidth }, task.id) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SingleTaskRow, { task, focused: !!(isFocused && idx === focusedRow), dragging: task.id === draggedTaskId, colWidth }, task.id)
@@ -57258,12 +57354,22 @@ function KanbanColumn({
57258
57354
  )
57259
57355
  );
57260
57356
  }
57261
- if (idx < tasks.length - 1) {
57357
+ if (idx < endIdx - 1) {
57262
57358
  rows.push(
57263
57359
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: isFocused ? "cyan" : "gray", dimColor: true, children: "\u2500".repeat(colWidth) }, `sep-${task.id}`)
57264
57360
  );
57265
57361
  }
57266
- });
57362
+ }
57363
+ if (endIdx < tasks.length) {
57364
+ rows.push(
57365
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Text, { color: "cyan", dimColor: true, children: [
57366
+ " ".repeat(2),
57367
+ "\u25BC ",
57368
+ tasks.length - endIdx,
57369
+ " more"
57370
+ ] }, "scroll-down")
57371
+ );
57372
+ }
57267
57373
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Box_default, { flexDirection: "column", width: colWidth, marginRight: 1, children: [
57268
57374
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Box_default, { backgroundColor: headerBg, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: headerColor, bold: true, children: pad(`${name}${countStr}`, colWidth) }) }),
57269
57375
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: isFocused ? "cyan" : "gray", children: "\u2500".repeat(colWidth) }),
@@ -57380,6 +57486,22 @@ function Board({ kandownDir, version }) {
57380
57486
  const [rowIndex, setRowIndex] = (0, import_react37.useState)(0);
57381
57487
  const [mode, setMode] = (0, import_react37.useState)("browse");
57382
57488
  const [statusMsg, setStatusMsg] = (0, import_react37.useState)("");
57489
+ const statusTimerRef = (0, import_react37.useRef)(null);
57490
+ const showStatus = (0, import_react37.useCallback)((msg, ms = 2e3) => {
57491
+ setStatusMsg(msg);
57492
+ if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
57493
+ if (msg) {
57494
+ statusTimerRef.current = setTimeout(() => {
57495
+ setStatusMsg("");
57496
+ statusTimerRef.current = null;
57497
+ }, ms);
57498
+ }
57499
+ }, []);
57500
+ (0, import_react37.useEffect)(() => {
57501
+ return () => {
57502
+ if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
57503
+ };
57504
+ }, []);
57383
57505
  const [boardError, setBoardError] = (0, import_react37.useState)(null);
57384
57506
  const [detailTask, setDetailTask] = (0, import_react37.useState)(null);
57385
57507
  const [detailTaskId, setDetailTaskId] = (0, import_react37.useState)("");
@@ -57395,45 +57517,73 @@ function Board({ kandownDir, version }) {
57395
57517
  const [preferredDaemonPort, setPreferredDaemonPort] = (0, import_react37.useState)(null);
57396
57518
  const [installedAgents, setInstalledAgents] = (0, import_react37.useState)([]);
57397
57519
  const inTmux = isInTmux();
57398
- const layoutRef = (0, import_react37.useRef)({
57399
- colStarts: [],
57400
- colWidth: 0
57401
- });
57402
- const updateLayout = (0, import_react37.useCallback)((b) => {
57403
- if (!b) return;
57404
- const cw = calcColWidth(b.columns.length);
57405
- const starts = [];
57406
- let x = 1;
57407
- for (let i = 0; i < b.columns.length; i++) {
57408
- starts.push(x);
57409
- x += cw + 1;
57410
- }
57411
- layoutRef.current = { colStarts: starts, colWidth: cw };
57412
- }, []);
57413
57520
  const columnAtX = (0, import_react37.useCallback)((x) => {
57414
- const layout = layoutRef.current;
57415
- for (let c = 0; c < layout.colStarts.length; c++) {
57416
- const start = layout.colStarts[c];
57417
- if (x >= start && x < start + layout.colWidth) return c;
57521
+ if (!board) return -1;
57522
+ const numCols = board.columns.length;
57523
+ const cw = calcColWidth(numCols);
57524
+ let startX = 1;
57525
+ for (let c = 0; c < numCols; c++) {
57526
+ if (x >= startX && x < startX + cw) return c;
57527
+ startX += cw + 1;
57418
57528
  }
57419
57529
  return -1;
57420
- }, []);
57530
+ }, [board]);
57421
57531
  const taskHitAt = (0, import_react37.useCallback)((x, y) => {
57422
57532
  if (!board) return null;
57423
57533
  const clickedCol = columnAtX(x);
57424
57534
  if (clickedCol < 0) return null;
57425
57535
  const col = board.columns[clickedCol];
57426
- if (!col) return null;
57427
- const taskIdx = y - TASKS_START_Y;
57428
- const task = taskIdx >= 0 ? col.tasks[taskIdx] : void 0;
57429
- if (!task) return null;
57430
- return { taskId: task.id, colIndex: clickedCol, rowIndex: taskIdx, startX: x, startY: y };
57431
- }, [board, columnAtX]);
57536
+ if (!col || col.tasks.length === 0) return null;
57537
+ const maxTasksHeight = Math.max(5, (process.stdout.rows || 24) - TASKS_START_Y - 3 - (mode === "move-target" || mode === "dragging" ? 2 : 0));
57538
+ const isFocused = clickedCol === colIndex;
57539
+ const focusedRow = isFocused ? rowIndex : -1;
57540
+ const contextMenuRowVal = mode === "context-menu" && isFocused ? ctxMenuRow : -1;
57541
+ const scrollIdx = isFocused ? computeScrollIdx(col.tasks, focusedRow, contextMenuRowVal, maxTasksHeight) : 0;
57542
+ const hasTopIndicator = scrollIdx > 0;
57543
+ let currentY = TASKS_START_Y;
57544
+ if (hasTopIndicator) {
57545
+ if (y === currentY) return null;
57546
+ currentY += 1;
57547
+ }
57548
+ let endIdx = scrollIdx;
57549
+ let accumulatedHeight = 0;
57550
+ const topIndicatorHeight = hasTopIndicator ? 1 : 0;
57551
+ while (endIdx < col.tasks.length) {
57552
+ const hasCategory = getTitleCategory(col.tasks[endIdx].title) !== null;
57553
+ const taskHeight = hasCategory ? 3 : 1;
57554
+ const sepHeight = endIdx < col.tasks.length - 1 ? 1 : 0;
57555
+ const hasBottomIndicator = endIdx < col.tasks.length - 1;
57556
+ const bottomIndicatorHeight = hasBottomIndicator ? 1 : 0;
57557
+ const currentMax = maxTasksHeight - topIndicatorHeight - bottomIndicatorHeight;
57558
+ if (accumulatedHeight + taskHeight + sepHeight > currentMax) {
57559
+ if (endIdx === scrollIdx) {
57560
+ endIdx++;
57561
+ }
57562
+ break;
57563
+ }
57564
+ if (y >= currentY && y < currentY + taskHeight) {
57565
+ return { taskId: col.tasks[endIdx].id, colIndex: clickedCol, rowIndex: endIdx, startX: x, startY: y };
57566
+ }
57567
+ currentY += taskHeight;
57568
+ if (contextMenuRowVal === endIdx) {
57569
+ if (y >= currentY && y < currentY + MENU_HEIGHT) {
57570
+ return { taskId: col.tasks[endIdx].id, colIndex: clickedCol, rowIndex: endIdx, startX: x, startY: y, isMenu: true };
57571
+ }
57572
+ currentY += MENU_HEIGHT;
57573
+ }
57574
+ if (endIdx < col.tasks.length - 1) {
57575
+ if (y === currentY) return null;
57576
+ currentY += 1;
57577
+ }
57578
+ accumulatedHeight += taskHeight + sepHeight;
57579
+ endIdx++;
57580
+ }
57581
+ return null;
57582
+ }, [board, colIndex, rowIndex, mode, ctxMenuRow, columnAtX]);
57432
57583
  const loadBoardInto = (0, import_react37.useCallback)(() => {
57433
57584
  try {
57434
57585
  const loaded = readBoard(kandownDir);
57435
57586
  setBoard(loaded);
57436
- updateLayout(loaded);
57437
57587
  setBoardError(null);
57438
57588
  return loaded;
57439
57589
  } catch (e) {
@@ -57442,7 +57592,7 @@ function Board({ kandownDir, version }) {
57442
57592
  setStatusMsg("");
57443
57593
  return null;
57444
57594
  }
57445
- }, [kandownDir, updateLayout]);
57595
+ }, [kandownDir]);
57446
57596
  (0, import_react37.useEffect)(() => {
57447
57597
  loadBoardInto();
57448
57598
  setInstalledAgents(detectInstalledAgents());
@@ -57454,8 +57604,7 @@ function Board({ kandownDir, version }) {
57454
57604
  });
57455
57605
  watcher.on("newTaskDetected", (taskId) => {
57456
57606
  loadBoardInto();
57457
- setStatusMsg(`New task: ${taskId}`);
57458
- setTimeout(() => setStatusMsg(""), 2e3);
57607
+ showStatus(`New task: ${taskId}`, 2e3);
57459
57608
  });
57460
57609
  watcher.on("configChanged", () => {
57461
57610
  loadBoardInto();
@@ -57464,14 +57613,13 @@ function Board({ kandownDir, version }) {
57464
57613
  return () => {
57465
57614
  watcher.stop();
57466
57615
  };
57467
- }, [kandownDir, loadBoardInto]);
57616
+ }, [kandownDir, loadBoardInto, showStatus]);
57468
57617
  const reloadBoard = (0, import_react37.useCallback)(() => {
57469
57618
  const loaded = loadBoardInto();
57470
57619
  if (loaded) {
57471
- setStatusMsg("Board reloaded");
57472
- setTimeout(() => setStatusMsg(""), 1500);
57620
+ showStatus("Board reloaded", 1500);
57473
57621
  }
57474
- }, [loadBoardInto]);
57622
+ }, [loadBoardInto, showStatus]);
57475
57623
  const refreshDaemonStatus = (0, import_react37.useCallback)(async () => {
57476
57624
  const next = await getDaemonStatus(kandownDir);
57477
57625
  setDaemonStatus(next);
@@ -57494,20 +57642,19 @@ function Board({ kandownDir, version }) {
57494
57642
  await stopProjectDaemon(kandownDir);
57495
57643
  const next = await getDaemonStatus(kandownDir);
57496
57644
  setDaemonStatus(next);
57497
- setStatusMsg("Web daemon stopped");
57645
+ showStatus("Web daemon stopped", 2500);
57498
57646
  } else {
57499
57647
  const next = await startProjectDaemon(kandownDir, preferredDaemonPort);
57500
57648
  setDaemonStatus(next);
57501
57649
  if (next.running && next.metadata) setPreferredDaemonPort(next.metadata.port);
57502
- setStatusMsg(next.running ? "Web daemon started" : "Web daemon failed to start");
57650
+ showStatus(next.running ? "Web daemon started" : "Web daemon failed to start", 2500);
57503
57651
  }
57504
57652
  } catch (error) {
57505
- setStatusMsg(`Daemon error: ${error instanceof Error ? error.message : String(error)}`);
57653
+ showStatus(`Daemon error: ${error instanceof Error ? error.message : String(error)}`, 2500);
57506
57654
  } finally {
57507
57655
  setDaemonBusy(false);
57508
- setTimeout(() => setStatusMsg(""), 2500);
57509
57656
  }
57510
- }, [daemonBusy, kandownDir, preferredDaemonPort]);
57657
+ }, [daemonBusy, kandownDir, preferredDaemonPort, showStatus]);
57511
57658
  const tryMoveWithGate = (0, import_react37.useCallback)((taskId, targetCol) => {
57512
57659
  if (!board) return false;
57513
57660
  const cfg = loadConfig(kandownDir);
@@ -57533,36 +57680,35 @@ function Board({ kandownDir, version }) {
57533
57680
  }
57534
57681
  if (blocked.length > 0) {
57535
57682
  const list = blocked.length === 1 ? blocked[0] : `${blocked.slice(0, -1).join(", ")} and ${blocked[blocked.length - 1]}`;
57536
- setStatusMsg(`Blocked: ${taskId} \u2190 ${list}`);
57537
- setTimeout(() => setStatusMsg(""), 3500);
57683
+ showStatus(`Blocked: ${taskId} \u2190 ${list}`, 3500);
57538
57684
  return false;
57539
57685
  }
57540
57686
  return true;
57541
- }, [board, kandownDir]);
57687
+ }, [board, kandownDir, showStatus]);
57542
57688
  const sendTaskToAgentHook = (0, import_react37.useCallback)(async (taskId) => {
57543
57689
  const status = await getDaemonStatus(kandownDir);
57544
57690
  if (!status.running || !status.metadata) {
57545
- setStatusMsg("Web daemon not running (press d to start)");
57546
- setTimeout(() => setStatusMsg(""), 2500);
57691
+ showStatus("Web daemon not running (press d to start)", 2500);
57547
57692
  return;
57548
57693
  }
57549
57694
  try {
57550
57695
  const res = await fetch(`http://127.0.0.1:${status.metadata.port}/api/tasks/${encodeURIComponent(taskId)}/agent`, {
57551
57696
  method: "POST",
57697
+ // 📖 The daemon requires its per-instance token (M5); the TUI reads it
57698
+ // from daemon.json via getDaemonStatus.
57699
+ headers: status.metadata.token ? { "X-Kandown-Token": status.metadata.token } : {},
57552
57700
  signal: AbortSignal.timeout(8e3)
57553
57701
  });
57554
57702
  if (res.ok) {
57555
- setStatusMsg(`Sent ${taskId} to agent hook`);
57703
+ showStatus(`Sent ${taskId} to agent hook`, 2e3);
57556
57704
  } else {
57557
57705
  const body = await res.text().catch(() => "");
57558
- setStatusMsg(`Agent hook: ${res.status}${body ? " \u2014 " + body.slice(0, 60) : ""}`);
57706
+ showStatus(`Agent hook: ${res.status}${body ? " \u2014 " + body.slice(0, 60) : ""}`, 3e3);
57559
57707
  }
57560
57708
  } catch (error) {
57561
- setStatusMsg(`Agent hook failed: ${error instanceof Error ? error.message : String(error)}`);
57562
- } finally {
57563
- setTimeout(() => setStatusMsg(""), 3e3);
57709
+ showStatus(`Agent hook failed: ${error instanceof Error ? error.message : String(error)}`, 3e3);
57564
57710
  }
57565
- }, [kandownDir]);
57711
+ }, [kandownDir, showStatus]);
57566
57712
  const getFocusedTask = (0, import_react37.useCallback)(() => {
57567
57713
  if (!board) return null;
57568
57714
  const col = board.columns[colIndex];
@@ -57577,10 +57723,9 @@ function Board({ kandownDir, version }) {
57577
57723
  setDetailScroll(0);
57578
57724
  setMode("detail");
57579
57725
  } catch (e) {
57580
- setStatusMsg(`Error opening task: ${e instanceof Error ? e.message : String(e)}`);
57581
- setTimeout(() => setStatusMsg(""), 4e3);
57726
+ showStatus(`Error opening task: ${e instanceof Error ? e.message : String(e)}`, 4e3);
57582
57727
  }
57583
- }, [kandownDir]);
57728
+ }, [kandownDir, showStatus]);
57584
57729
  const closeContextMenu = (0, import_react37.useCallback)(() => {
57585
57730
  setCtxMenuRow(-1);
57586
57731
  setCtxMenuCursor(0);
@@ -57590,143 +57735,176 @@ function Board({ kandownDir, version }) {
57590
57735
  const taskId = mode === "detail" ? detailTaskId : task?.id;
57591
57736
  if (!taskId) return;
57592
57737
  setMode("browse");
57593
- setStatusMsg(`Launching ${agentId} for ${taskId}\u2026`);
57738
+ showStatus(`Launching ${agentId} for ${taskId}\u2026`, 5e3);
57594
57739
  setTimeout(() => {
57595
57740
  try {
57596
57741
  launchAgent({ taskId, agentId, kandownDir, onBeforeExec: () => exit() });
57597
57742
  reloadBoard();
57598
- setStatusMsg(`${agentId} launched in tmux pane`);
57599
- setTimeout(() => setStatusMsg(""), 3e3);
57743
+ showStatus(`${agentId} launched in tmux pane`, 3e3);
57600
57744
  } catch (err) {
57601
- setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
57602
- setTimeout(() => setStatusMsg(""), 4e3);
57745
+ showStatus(`Error: ${err instanceof Error ? err.message : String(err)}`, 4e3);
57603
57746
  }
57604
57747
  }, 50);
57605
- }, [mode, detailTaskId, getFocusedTask, kandownDir, exit, reloadBoard]);
57748
+ }, [mode, detailTaskId, getFocusedTask, kandownDir, exit, reloadBoard, showStatus]);
57606
57749
  useMouseMode(mode !== "agent-picker");
57607
57750
  const handleMouseClick = (0, import_react37.useCallback)((x, y) => {
57608
57751
  if (!board) return;
57609
- const layout = layoutRef.current;
57610
- let clickedCol = -1;
57611
- for (let c = 0; c < layout.colStarts.length; c++) {
57612
- const start = layout.colStarts[c];
57613
- if (x >= start && x < start + layout.colWidth) {
57614
- clickedCol = c;
57752
+ const clickedCol = columnAtX(x);
57753
+ if (clickedCol < 0) {
57754
+ if (mode === "context-menu") {
57755
+ closeContextMenu();
57756
+ setMode("browse");
57757
+ } else if (mode === "move-target") {
57758
+ setMoveTaskId(null);
57759
+ setMode("browse");
57760
+ }
57761
+ return;
57762
+ }
57763
+ const col = board.columns[clickedCol];
57764
+ if (mode === "move-target") {
57765
+ if (clickedCol === colIndex) {
57766
+ setMoveTaskId(null);
57767
+ setMode("browse");
57768
+ return;
57769
+ }
57770
+ if (moveTaskId) {
57771
+ if (!tryMoveWithGate(moveTaskId, col.name)) {
57772
+ setMoveTaskId(null);
57773
+ setMode("browse");
57774
+ return;
57775
+ }
57776
+ moveTaskToColumn(kandownDir, moveTaskId, col.name);
57777
+ loadBoardInto();
57778
+ showStatus(`Moved ${moveTaskId} \u2192 ${col.name}`);
57779
+ }
57780
+ setMoveTaskId(null);
57781
+ setMode("browse");
57782
+ return;
57783
+ }
57784
+ const maxTasksHeight = Math.max(5, (process.stdout.rows || 24) - TASKS_START_Y - 3 - (mode === "dragging" ? 2 : 0));
57785
+ const isFocused = clickedCol === colIndex;
57786
+ const focusedRow = isFocused ? rowIndex : -1;
57787
+ const contextMenuRowVal = mode === "context-menu" && isFocused ? ctxMenuRow : -1;
57788
+ const scrollIdx = isFocused ? computeScrollIdx(col.tasks, focusedRow, contextMenuRowVal, maxTasksHeight) : 0;
57789
+ const hasTopIndicator = scrollIdx > 0;
57790
+ let currentY = TASKS_START_Y;
57791
+ if (hasTopIndicator) {
57792
+ if (y === currentY) {
57793
+ if (isFocused) {
57794
+ setRowIndex((r) => Math.max(0, r - 1));
57795
+ }
57796
+ return;
57797
+ }
57798
+ currentY += 1;
57799
+ }
57800
+ let endIdx = scrollIdx;
57801
+ let accumulatedHeight = 0;
57802
+ const topIndicatorHeight = hasTopIndicator ? 1 : 0;
57803
+ let clickedTaskIdx = -1;
57804
+ let clickedMenuOffset = -1;
57805
+ while (endIdx < col.tasks.length) {
57806
+ const hasCategory = getTitleCategory(col.tasks[endIdx].title) !== null;
57807
+ const taskHeight = hasCategory ? 3 : 1;
57808
+ const sepHeight = endIdx < col.tasks.length - 1 ? 1 : 0;
57809
+ const hasBottomIndicator = endIdx < col.tasks.length - 1;
57810
+ const bottomIndicatorHeight = hasBottomIndicator ? 1 : 0;
57811
+ const currentMax = maxTasksHeight - topIndicatorHeight - bottomIndicatorHeight;
57812
+ if (accumulatedHeight + taskHeight + sepHeight > currentMax) {
57813
+ if (endIdx === scrollIdx) {
57814
+ endIdx++;
57815
+ }
57615
57816
  break;
57616
57817
  }
57818
+ if (y >= currentY && y < currentY + taskHeight) {
57819
+ clickedTaskIdx = endIdx;
57820
+ break;
57821
+ }
57822
+ currentY += taskHeight;
57823
+ if (contextMenuRowVal === endIdx) {
57824
+ if (y >= currentY && y < currentY + MENU_HEIGHT) {
57825
+ clickedTaskIdx = endIdx;
57826
+ clickedMenuOffset = y - currentY;
57827
+ break;
57828
+ }
57829
+ currentY += MENU_HEIGHT;
57830
+ }
57831
+ if (endIdx < col.tasks.length - 1) {
57832
+ if (y === currentY) {
57833
+ return;
57834
+ }
57835
+ currentY += 1;
57836
+ }
57837
+ accumulatedHeight += taskHeight + sepHeight;
57838
+ endIdx++;
57839
+ }
57840
+ if (endIdx < col.tasks.length) {
57841
+ if (y === currentY) {
57842
+ if (isFocused) {
57843
+ setRowIndex((r) => Math.min(col.tasks.length - 1, r + 1));
57844
+ }
57845
+ return;
57846
+ }
57847
+ currentY += 1;
57617
57848
  }
57618
57849
  if (mode === "browse") {
57619
- if (clickedCol < 0) return;
57620
- const col = board.columns[clickedCol];
57621
- const taskIdx = y - TASKS_START_Y;
57622
- if (taskIdx >= 0 && taskIdx < col.tasks.length) {
57850
+ if (clickedTaskIdx >= 0) {
57623
57851
  setColIndex(clickedCol);
57624
- setRowIndex(taskIdx);
57625
- setCtxMenuRow(taskIdx);
57852
+ setRowIndex(clickedTaskIdx);
57853
+ setCtxMenuRow(clickedTaskIdx);
57626
57854
  setCtxMenuCursor(0);
57627
57855
  setMode("context-menu");
57628
57856
  }
57629
57857
  return;
57630
57858
  }
57631
57859
  if (mode === "context-menu") {
57632
- if (clickedCol < 0) {
57633
- closeContextMenu();
57634
- setMode("browse");
57635
- return;
57636
- }
57637
- const col = board.columns[clickedCol];
57638
57860
  const hasMenu = clickedCol === colIndex && ctxMenuRow >= 0;
57639
57861
  if (hasMenu) {
57640
- const taskIdx = y - TASKS_START_Y;
57641
- if (taskIdx >= 0 && taskIdx < ctxMenuRow) {
57642
- setRowIndex(taskIdx);
57643
- setCtxMenuRow(taskIdx);
57644
- setCtxMenuCursor(0);
57645
- return;
57646
- }
57647
- if (taskIdx === ctxMenuRow) {
57648
- closeContextMenu();
57649
- setMode("browse");
57650
- return;
57651
- }
57652
- const menuOffset = taskIdx - ctxMenuRow - 1;
57653
- if (menuOffset >= 0 && menuOffset < MENU_HEIGHT) {
57654
- if (menuOffset === 0) {
57655
- const task = col.tasks[ctxMenuRow];
57656
- if (task) {
57657
- closeContextMenu();
57658
- openDetail(task.id);
57862
+ if (clickedTaskIdx >= 0) {
57863
+ if (clickedMenuOffset >= 0) {
57864
+ if (clickedMenuOffset === 0) {
57865
+ const task = col.tasks[ctxMenuRow];
57866
+ if (task) {
57867
+ closeContextMenu();
57868
+ openDetail(task.id);
57869
+ }
57870
+ } else {
57871
+ const task = col.tasks[ctxMenuRow];
57872
+ if (task) {
57873
+ setMoveTaskId(task.id);
57874
+ const target = colIndex === 0 ? Math.min(1, board.columns.length - 1) : 0;
57875
+ setMoveTargetCol(target);
57876
+ closeContextMenu();
57877
+ setMode("move-target");
57878
+ }
57659
57879
  }
57880
+ } else if (clickedTaskIdx === ctxMenuRow) {
57881
+ closeContextMenu();
57882
+ setMode("browse");
57660
57883
  } else {
57661
- const task = col.tasks[ctxMenuRow];
57662
- if (task) {
57663
- setMoveTaskId(task.id);
57664
- const target = colIndex === 0 ? Math.min(1, board.columns.length - 1) : 0;
57665
- setMoveTargetCol(target);
57666
- closeContextMenu();
57667
- setMode("move-target");
57668
- }
57884
+ setRowIndex(clickedTaskIdx);
57885
+ closeContextMenu();
57886
+ setCtxMenuRow(clickedTaskIdx);
57887
+ setCtxMenuCursor(0);
57669
57888
  }
57670
- return;
57671
- }
57672
- const belowIdx = taskIdx - MENU_HEIGHT;
57673
- if (belowIdx >= 0 && belowIdx < col.tasks.length) {
57674
- setRowIndex(belowIdx);
57889
+ } else {
57675
57890
  closeContextMenu();
57676
- setCtxMenuRow(belowIdx);
57677
- setCtxMenuCursor(0);
57678
- return;
57891
+ setMode("browse");
57679
57892
  }
57680
57893
  } else {
57681
- const taskIdx = y - TASKS_START_Y;
57682
- if (taskIdx >= 0 && taskIdx < col.tasks.length) {
57894
+ if (clickedTaskIdx >= 0) {
57683
57895
  closeContextMenu();
57684
57896
  setColIndex(clickedCol);
57685
- setRowIndex(taskIdx);
57686
- setCtxMenuRow(taskIdx);
57897
+ setRowIndex(clickedTaskIdx);
57898
+ setCtxMenuRow(clickedTaskIdx);
57687
57899
  setCtxMenuCursor(0);
57688
- return;
57689
- }
57690
- }
57691
- closeContextMenu();
57692
- setMode("browse");
57693
- return;
57694
- }
57695
- if (mode === "move-target") {
57696
- if (clickedCol < 0) {
57697
- setMoveTaskId(null);
57698
- setMode("browse");
57699
- return;
57700
- }
57701
- if (clickedCol === colIndex) {
57702
- setMoveTaskId(null);
57703
- setMode("browse");
57704
- return;
57705
- }
57706
- const col = board.columns[clickedCol];
57707
- const placeholderY = TASKS_START_Y + col.tasks.length;
57708
- if (y === placeholderY) {
57709
- const targetColName = col.name;
57710
- if (moveTaskId) {
57711
- if (!tryMoveWithGate(moveTaskId, targetColName)) {
57712
- setMoveTaskId(null);
57713
- setMode("browse");
57714
- return;
57715
- }
57716
- moveTaskToColumn(kandownDir, moveTaskId, targetColName);
57717
- loadBoardInto();
57718
- setStatusMsg(`Moved ${moveTaskId} \u2192 ${targetColName}`);
57719
- setTimeout(() => setStatusMsg(""), 2e3);
57900
+ } else {
57901
+ closeContextMenu();
57902
+ setMode("browse");
57720
57903
  }
57721
- setMoveTaskId(null);
57722
- setMode("browse");
57723
- return;
57724
57904
  }
57725
- setMoveTaskId(null);
57726
- setMode("browse");
57727
57905
  return;
57728
57906
  }
57729
- }, [board, mode, colIndex, rowIndex, ctxMenuRow, moveTaskId, kandownDir, updateLayout, openDetail, closeContextMenu, loadBoardInto]);
57907
+ }, [board, mode, colIndex, rowIndex, ctxMenuRow, moveTaskId, kandownDir, columnAtX, closeContextMenu, openDetail, loadBoardInto, tryMoveWithGate, taskDrag, showStatus]);
57730
57908
  const handleMouseEvent = (0, import_react37.useCallback)((mouse) => {
57731
57909
  if (!board) return;
57732
57910
  if (mode === "browse") {
@@ -57792,8 +57970,7 @@ function Board({ kandownDir, version }) {
57792
57970
  setColIndex(targetCol);
57793
57971
  const movedRow = loaded?.columns[targetCol]?.tasks.findIndex((task) => task.id === taskDrag.taskId) ?? 0;
57794
57972
  setRowIndex(Math.max(0, movedRow));
57795
- setStatusMsg(`Dragged ${taskDrag.taskId} \u2192 ${targetColName}`);
57796
- setTimeout(() => setStatusMsg(""), 2e3);
57973
+ showStatus(`Dragged ${taskDrag.taskId} \u2192 ${targetColName}`, 2e3);
57797
57974
  }
57798
57975
  }
57799
57976
  setTaskDrag(null);
@@ -57807,7 +57984,7 @@ function Board({ kandownDir, version }) {
57807
57984
  if (mouse.action === "press" && mouse.button === 0) {
57808
57985
  handleMouseClick(mouse.x, mouse.y);
57809
57986
  }
57810
- }, [board, mode, mousePress, taskDrag, taskHitAt, columnAtX, closeContextMenu, handleMouseClick, kandownDir, updateLayout]);
57987
+ }, [board, mode, mousePress, taskDrag, taskHitAt, columnAtX, closeContextMenu, handleMouseClick, kandownDir]);
57811
57988
  use_input_default((input, key) => {
57812
57989
  if (isMouseInput(input)) {
57813
57990
  const mouse = parseMouseInput(input);
@@ -57864,8 +58041,7 @@ function Board({ kandownDir, version }) {
57864
58041
  }
57865
58042
  if (input === "a") {
57866
58043
  if (installedAgents.length === 0) {
57867
- setStatusMsg("No AI agents found in PATH");
57868
- setTimeout(() => setStatusMsg(""), 3e3);
58044
+ showStatus("No AI agents found in PATH", 3e3);
57869
58045
  return;
57870
58046
  }
57871
58047
  const task = getFocusedTask();
@@ -57953,8 +58129,7 @@ function Board({ kandownDir, version }) {
57953
58129
  }
57954
58130
  moveTaskToColumn(kandownDir, moveTaskId, name);
57955
58131
  loadBoardInto();
57956
- setStatusMsg(`Moved ${moveTaskId} \u2192 ${name}`);
57957
- setTimeout(() => setStatusMsg(""), 2e3);
58132
+ showStatus(`Moved ${moveTaskId} \u2192 ${name}`, 2e3);
57958
58133
  }
57959
58134
  setMoveTaskId(null);
57960
58135
  setMode("browse");
@@ -57967,7 +58142,10 @@ function Board({ kandownDir, version }) {
57967
58142
  return;
57968
58143
  }
57969
58144
  if (input === "j" || key.downArrow) {
57970
- setDetailScroll((s) => s + 1);
58145
+ const bodyLines = detailTask ? detailTask.body.split("\n") : [];
58146
+ const maxVisible = (process.stdout.rows || 24) - 10;
58147
+ const maxScroll = Math.max(0, bodyLines.length - maxVisible);
58148
+ setDetailScroll((s) => Math.min(s + 1, maxScroll));
57971
58149
  return;
57972
58150
  }
57973
58151
  if (input === "k" || key.upArrow) {
@@ -57976,8 +58154,7 @@ function Board({ kandownDir, version }) {
57976
58154
  }
57977
58155
  if (input === "a") {
57978
58156
  if (installedAgents.length === 0) {
57979
- setStatusMsg("No AI agents found in PATH");
57980
- setTimeout(() => setStatusMsg(""), 3e3);
58157
+ showStatus("No AI agents found in PATH", 3e3);
57981
58158
  return;
57982
58159
  }
57983
58160
  setMode("agent-picker");
@@ -58014,7 +58191,7 @@ function Board({ kandownDir, version }) {
58014
58191
  if (mode === "context-menu") {
58015
58192
  modeHint = "j/k choose \xB7 Enter confirm \xB7 Esc cancel \xB7 or click";
58016
58193
  } else if (mode === "move-target") {
58017
- modeHint = "\u2190/\u2192 pick column \xB7 Enter confirm \xB7 Esc cancel \xB7 or click \u2193";
58194
+ modeHint = "\u2190/\u2192 pick column \xB7 Enter confirm \xB7 Esc cancel \xB7 or click a column";
58018
58195
  } else if (mode === "dragging") {
58019
58196
  modeHint = "drag over target column \xB7 release to drop \xB7 Esc cancel";
58020
58197
  }
@@ -58060,7 +58237,8 @@ function Board({ kandownDir, version }) {
58060
58237
  contextMenuCursor: ctxMenuCursor,
58061
58238
  showMoveTarget: mode === "move-target" && cIdx !== colIndex || mode === "dragging" && cIdx !== taskDrag?.sourceCol,
58062
58239
  isMoveFocused: (mode === "move-target" || mode === "dragging") && cIdx === moveTargetCol,
58063
- draggedTaskId: taskDrag?.taskId ?? null
58240
+ draggedTaskId: taskDrag?.taskId ?? null,
58241
+ maxTasksHeight: Math.max(5, (process.stdout.rows || 24) - TASKS_START_Y - 3 - (mode === "move-target" || mode === "dragging" ? 2 : 0))
58064
58242
  },
58065
58243
  col.name
58066
58244
  )) }),
@@ -58068,7 +58246,7 @@ function Board({ kandownDir, version }) {
58068
58246
  "Moving ",
58069
58247
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: "cyan", children: moveTaskId }),
58070
58248
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: "gray", children: " \u2014 " }),
58071
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: "yellow", bold: true, children: mode === "dragging" ? "release over a column" : "click \u2193" }),
58249
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: "yellow", bold: true, children: mode === "dragging" ? "release over a column" : "click a column" }),
58072
58250
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Text, { color: "gray", children: mode === "dragging" ? " \xB7 Esc cancel" : " or \u2190/\u2192 + Enter" })
58073
58251
  ] }) }),
58074
58252
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(StatusBar, { message: statusMsg, task: focusedTask, daemonStatus })