tuiboard 0.13.1 → 0.13.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.3] - 2026-09-19
11
+
12
+ ### Fixed
13
+ - **`tuiboard task --board` refuses ambiguous boards** (#63) instead of taking
14
+ the first configured board whose path ends with the argument. Two boards can
15
+ easily end the same way (`Work/Personal.md`, `Home/Personal.md`), and this is
16
+ the headless path used by cron jobs and the bar widget, so the wrong board
17
+ could be mutated silently. It now lists the candidates and writes nothing,
18
+ the way an ambiguous `--match` already did. Thanks to the commenter on
19
+ Reddit who spotted it.
20
+
21
+ ## [0.13.2] - 2026-09-17
22
+
23
+ ### Fixed
24
+ - **A calmer Agents list while agents work** (#58).
25
+ - A failed herdr poll no longer drops every link at once (states briefly
26
+ reverting, rows jumping); herdr only counts as gone after a few misses.
27
+ - A Claude Code PID file read mid-write reuses its last good read instead of
28
+ making the session's state flicker.
29
+ - Live sessions are ordered by their activity at their last state change,
30
+ so a working agent moves once when it starts or finishes, not on every
31
+ write; closed sessions still sort by last activity.
32
+ - Ages tick on one shared clock and read `now` under a minute instead of
33
+ jumping through seconds.
34
+
10
35
  ## [0.13.1] - 2026-09-17
11
36
 
12
37
  ### Fixed
@@ -487,6 +512,8 @@ First public release on npm. This entry captures the full feature set at launch.
487
512
 
488
513
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
489
514
 
515
+ [0.13.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.3
516
+ [0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
490
517
  [0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
491
518
  [0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
492
519
  [0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live view of your coding-agent sessions (Claude Code, Codex, OpenCode, Pi). Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/task.ts CHANGED
@@ -71,18 +71,31 @@ function isoShift(days: number): string {
71
71
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
72
72
  }
73
73
 
74
- /** Resolve a board by configured name, then by path suffix. */
74
+ /**
75
+ * Resolve a board by configured name, then by path suffix — and refuse to
76
+ * guess when more than one matches, the way findTask does for task titles.
77
+ * Two boards can easily end the same way (`Work/Personal.md`,
78
+ * `Home/Personal.md`), and this is the headless path: a cron job or the bar
79
+ * widget mutating the wrong board would go unnoticed.
80
+ */
75
81
  function resolveBoard(needle: string) {
76
82
  const cfg = loadConfig();
77
83
  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) {
84
+ const byName = cfg.boards.filter((b) => (b.name ?? "").toLowerCase() === lower);
85
+ const hits = byName.length > 0
86
+ ? byName
87
+ : cfg.boards.filter((b) => b.path.toLowerCase().endsWith(lower));
88
+ if (hits.length === 0) {
82
89
  const names = cfg.boards.map((b) => b.name ?? b.path).join(", ");
83
90
  throw new Error(`board "${needle}" not found. Configured: ${names || "(none)"}`);
84
91
  }
85
- return hit;
92
+ if (hits.length > 1) {
93
+ const candidates = hits.map((b) => (b.name ? `${b.name} (${b.path})` : b.path)).join(", ");
94
+ throw new Error(
95
+ `board "${needle}" matches ${hits.length} boards: ${candidates}; be more specific`,
96
+ );
97
+ }
98
+ return hits[0]!;
86
99
  }
87
100
 
88
101
  function findColumn(board: Board, name: string): Column {
@@ -207,6 +207,13 @@ export function parseTranscript(content: string): TranscriptParseResult {
207
207
 
208
208
  // ─── Discovery ──────────────────────────────────────────────────────────────
209
209
 
210
+ /**
211
+ * Last successfully parsed record per PID file. Claude Code rewrites these
212
+ * files in place, so a read can land mid-write; reusing the previous record
213
+ * keeps the session's live state from flickering for one scan.
214
+ */
215
+ const lastGoodPid = new Map<string, { sessionId: string; record: LivePidRecord }>();
216
+
210
217
  function discoverLivePids(): Map<string, LivePidRecord> {
211
218
  const out = new Map<string, LivePidRecord>();
212
219
  if (!existsSync(SESSIONS_DIR)) return out;
@@ -216,25 +223,32 @@ function discoverLivePids(): Map<string, LivePidRecord> {
216
223
  } catch {
217
224
  return out;
218
225
  }
226
+ const seen = new Set<string>();
219
227
  for (const f of entries) {
220
228
  if (!f.endsWith(".json")) continue;
221
229
  const path = join(SESSIONS_DIR, f);
230
+ seen.add(path);
222
231
  try {
223
232
  const raw = JSON.parse(readFileSync(path, "utf-8"));
224
233
  const sid = raw.sessionId;
225
234
  if (!sid) continue;
226
235
  const stat = statSync(path);
227
- out.set(sid, {
236
+ const record: LivePidRecord = {
228
237
  mtimeMs: stat.mtimeMs,
229
238
  status: raw.status?.toLowerCase(),
230
239
  pid: raw.pid,
231
240
  version: raw.version,
232
241
  cwd: raw.cwd,
233
- });
242
+ };
243
+ lastGoodPid.set(path, { sessionId: sid, record });
244
+ out.set(sid, record);
234
245
  } catch {
235
- // ignore — malformed PID files happen during writes
246
+ // Mid-write (or vanished): fall back to the last good read of this file.
247
+ const prev = lastGoodPid.get(path);
248
+ if (prev) out.set(prev.sessionId, prev.record);
236
249
  }
237
250
  }
251
+ for (const path of lastGoodPid.keys()) if (!seen.has(path)) lastGoodPid.delete(path);
238
252
  return out;
239
253
  }
240
254
 
@@ -73,6 +73,11 @@ export interface AgentSession {
73
73
  resumeArgv: string[];
74
74
  /** Set when the session is open in a herdr pane. */
75
75
  herdr?: HerdrLink;
76
+ /**
77
+ * Position in the list (newest first). Set by the store: last activity for
78
+ * closed sessions, last state change for live ones — see withSortKeys.
79
+ */
80
+ sortKeyMs?: number;
76
81
  }
77
82
 
78
83
  /** One agent CLI's session source. */
@@ -113,11 +118,11 @@ export function filterSessions(arr: AgentSession[], filter: AgentsFilter): Agent
113
118
  return filter === "all" ? arr : arr.filter((s) => s.provider === filter);
114
119
  }
115
120
 
116
- /** Compact human-readable age. Mirrors av.py `_fmt_age`. */
121
+ /** Compact human-readable age; under a minute reads `now` (no ticking seconds). */
117
122
  export function formatAge(ts: number, now: number): string {
118
123
  if (!ts) return "—";
119
124
  const delta = (now - ts) / 1000;
120
- if (delta < 60) return `${Math.floor(delta)}s`;
125
+ if (delta < 60) return "now";
121
126
  if (delta < 3600) return `${Math.floor(delta / 60)}m`;
122
127
  if (delta < 86_400) return `${Math.floor(delta / 3600)}h`;
123
128
  return `${Math.floor(delta / 86_400)}d`;
@@ -144,15 +149,48 @@ export function isLive(status: AgentStatus): boolean {
144
149
  * cursor indices aligned with the full list.
145
150
  */
146
151
  export function sortSessions(arr: AgentSession[]): AgentSession[] {
152
+ const key = (s: AgentSession) => s.sortKeyMs ?? s.lastActivityMs;
147
153
  return arr.slice().sort((a, b) => {
148
154
  const archived = Number(a.status === "archived") - Number(b.status === "archived");
149
155
  if (archived !== 0) return archived;
150
- const recency = b.lastActivityMs - a.lastActivityMs;
156
+ const recency = key(b) - key(a);
151
157
  if (recency !== 0) return recency;
152
158
  return STATUS_RANK[a.status] - STATUS_RANK[b.status];
153
159
  });
154
160
  }
155
161
 
162
+ export interface StateMemory {
163
+ status: AgentStatus;
164
+ since: number;
165
+ }
166
+
167
+ /**
168
+ * Sort keys that don't churn. A working agent writes its session file all
169
+ * the time; sorting by that made rows swap on every write. Live sessions are
170
+ * keyed by their last activity *as of their last state change* — they move
171
+ * once when they start or finish (both write), not on every write — and
172
+ * closed ones by their last activity. Using activity rather than the wall
173
+ * clock means a "change" that is only new information (herdr answering after
174
+ * the first scan) doesn't reshuffle the list.
175
+ * `memory` carries state across refreshes (keyed by provider + session id).
176
+ */
177
+ export function withSortKeys(
178
+ sessions: AgentSession[],
179
+ memory: Map<string, StateMemory>,
180
+ ): AgentSession[] {
181
+ const seen = new Set<string>();
182
+ const out = sessions.map((s) => {
183
+ const id = `${s.provider}:${s.sessionId}`;
184
+ seen.add(id);
185
+ const prev = memory.get(id);
186
+ const since = prev && prev.status === s.status ? prev.since : s.lastActivityMs;
187
+ memory.set(id, { status: s.status, since });
188
+ return { ...s, sortKeyMs: isLive(s.status) ? since : s.lastActivityMs };
189
+ });
190
+ for (const id of memory.keys()) if (!seen.has(id)) memory.delete(id);
191
+ return out;
192
+ }
193
+
156
194
  // ─── Reactive store ─────────────────────────────────────────────────────────
157
195
 
158
196
  export interface AgentsStore {
@@ -195,9 +233,11 @@ export function createAgentsStore(
195
233
  }
196
234
  }
197
235
 
236
+ const stateMemory = new Map<string, StateMemory>();
198
237
  function publish(): void {
199
238
  const all = [...byAdapter.values()].flat();
200
- setSessions(sortSessions(linkHerdrSessions(all, herdr?.snapshot())));
239
+ const linked = linkHerdrSessions(all, herdr?.snapshot());
240
+ setSessions(sortSessions(withSortKeys(linked, stateMemory)));
201
241
  }
202
242
  herdr?.onChange(publish);
203
243
 
@@ -213,6 +213,11 @@ export interface HerdrSource {
213
213
  }
214
214
 
215
215
  export const HERDR_POLL_MS = 2000;
216
+ /**
217
+ * Consecutive failed polls before herdr counts as gone. A single slow or
218
+ * failed call used to drop every link at once, making states flicker.
219
+ */
220
+ export const HERDR_MISSES_BEFORE_CLEAR = 3;
216
221
 
217
222
  /**
218
223
  * Poll `herdr api snapshot` in the background. Inert when herdr isn't on
@@ -220,7 +225,11 @@ export const HERDR_POLL_MS = 2000;
220
225
  * without noise.
221
226
  */
222
227
  export function createHerdrSource(
223
- { pollMs = HERDR_POLL_MS, bin = Bun.which("herdr") }: { pollMs?: number; bin?: string | null } = {},
228
+ {
229
+ pollMs = HERDR_POLL_MS,
230
+ bin = Bun.which("herdr"),
231
+ missesBeforeClear = HERDR_MISSES_BEFORE_CLEAR,
232
+ }: { pollMs?: number; bin?: string | null; missesBeforeClear?: number } = {},
224
233
  ): HerdrSource {
225
234
  let current: HerdrSnapshot | undefined;
226
235
  let lastRaw = "";
@@ -229,6 +238,7 @@ export function createHerdrSource(
229
238
 
230
239
  let inFlight = false;
231
240
  let disposed = false;
241
+ let misses = 0;
232
242
  const poll = async () => {
233
243
  if (inFlight || disposed) return;
234
244
  inFlight = true;
@@ -242,7 +252,9 @@ export function createHerdrSource(
242
252
  const out = await new Response(proc.stdout).text();
243
253
  clearTimeout(timer);
244
254
  const code = await proc.exited;
245
- const snap = code === 0 ? parseHerdrSnapshot(out) : undefined;
255
+ let snap = code === 0 ? parseHerdrSnapshot(out) : undefined;
256
+ if (snap) misses = 0;
257
+ else if (++misses < missesBeforeClear) snap = current; // ride out a blip
246
258
  // Only the parts we use decide whether anything changed.
247
259
  const key = snap
248
260
  ? JSON.stringify([snap.panes, [...snap.tabs], [...snap.workspaces]])
@@ -253,7 +265,8 @@ export function createHerdrSource(
253
265
  for (const cb of listeners) cb();
254
266
  }
255
267
  } catch {
256
- // herdr vanished mid-call — try again next tick
268
+ // herdr vanished mid-call — counts as a miss, try again next tick
269
+ misses++;
257
270
  } finally {
258
271
  inFlight = false;
259
272
  }
@@ -15,6 +15,7 @@ import { Show, createMemo, createSignal, onMount } from "solid-js";
15
15
  import { homedir } from "node:os";
16
16
 
17
17
  import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
18
+ import { clockNow } from "~/ui/clock";
18
19
  import { T } from "~/ui/glyphs";
19
20
  import { herdrPlace, type IndicatorStyle } from "~/store/herdr";
20
21
  import {
@@ -95,7 +96,7 @@ interface AgentRowProps {
95
96
 
96
97
  export function AgentRow(props: AgentRowProps) {
97
98
  const ageStr = createMemo(() =>
98
- formatAge(props.session.lastActivityMs, Date.now()),
99
+ formatAge(props.session.lastActivityMs, clockNow()),
99
100
  );
100
101
  const nameMax = () => props.nameMaxChars ?? 40;
101
102
  const displayName = createMemo(() => {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A shared, coarse clock for relative times ("5m", "3h"). Rows used to compute
3
+ * their age only when their data changed, so ages jumped irregularly while
4
+ * agents worked; one timer for the whole UI keeps them ticking evenly.
5
+ */
6
+
7
+ import { createSignal } from "solid-js";
8
+
9
+ export const CLOCK_TICK_MS = 30_000;
10
+
11
+ const [now, setNow] = createSignal(Date.now());
12
+ let timer: ReturnType<typeof setInterval> | undefined;
13
+
14
+ /** Current time, updated every CLOCK_TICK_MS. Starts on first use. */
15
+ export function clockNow(): number {
16
+ if (!timer) {
17
+ timer = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
18
+ timer.unref?.();
19
+ }
20
+ return now();
21
+ }