tuiboard 0.13.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.2] - 2026-09-17
11
+
12
+ ### Fixed
13
+ - **A calmer Agents list while agents work** (#58).
14
+ - A failed herdr poll no longer drops every link at once (states briefly
15
+ reverting, rows jumping); herdr only counts as gone after a few misses.
16
+ - A Claude Code PID file read mid-write reuses its last good read instead of
17
+ making the session's state flicker.
18
+ - Live sessions are ordered by their activity at their last state change,
19
+ so a working agent moves once when it starts or finishes, not on every
20
+ write; closed sessions still sort by last activity.
21
+ - Ages tick on one shared clock and read `now` under a minute instead of
22
+ jumping through seconds.
23
+
24
+ ## [0.13.1] - 2026-09-17
25
+
26
+ ### Fixed
27
+ - **Agents list stays in order when sessions move** (#52). A new session
28
+ appearing on top could show twice, leave blank rows, or draw rows in the
29
+ wrong order past the panel border; the lists now keep one stable row per
30
+ position. The cursor also stays on the session you selected while the list
31
+ reorders, instead of silently landing on another one (so Enter, `H` and `c`
32
+ act on the session you picked).
33
+ - **`--view=agents` / `--view=timeline` / `--view=board` start on their own
34
+ zone** (#54), so the cursor and keys work right away instead of acting on
35
+ the hidden planner.
36
+
10
37
  ## [0.13.0] - 2026-09-17
11
38
 
12
39
  tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
@@ -474,6 +501,8 @@ First public release on npm. This entry captures the full feature set at launch.
474
501
 
475
502
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
476
503
 
504
+ [0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
505
+ [0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
477
506
  [0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
478
507
  [0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
479
508
  [0.11.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.11.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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/app.tsx CHANGED
@@ -148,6 +148,11 @@ if (view === "planner") {
148
148
  store.setActiveZone("planner");
149
149
  store.setZoomed(true);
150
150
  }
151
+ // The single-zone views show one zone; keys must act on it. Without this the
152
+ // planner (the default landing zone above) stayed active behind them.
153
+ if (view === "agents" || view === "timeline" || view === "board") {
154
+ store.setActiveZone(view);
155
+ }
151
156
 
152
157
  // ─── App shell ──────────────────────────────────────────────────────────────
153
158
 
@@ -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(() => {
@@ -8,9 +8,10 @@
8
8
  * visible. Clicking a row sets activeZone + agent cursor.
9
9
  */
10
10
 
11
- import { For, Show, createEffect, createMemo } from "solid-js";
11
+ import { Index, Show, createEffect, createMemo } from "solid-js";
12
12
 
13
13
  import { AgentRow } from "~/ui/AgentRow";
14
+ import { useStickyAgentCursor } from "~/ui/agent-cursor";
14
15
  import { T } from "~/ui/glyphs";
15
16
  import { HARNESS } from "~/store/agents";
16
17
  import type { TuiStore } from "~/store/index";
@@ -42,6 +43,7 @@ export function AgentsBar(props: AgentsBarProps) {
42
43
  };
43
44
 
44
45
  let scrollBoxRef: ScrollBoxLike | undefined;
46
+ useStickyAgentCursor(props.store, allShown);
45
47
 
46
48
  // Keep the cursor row visible as j/k moves it (mouse wheel scrolls freely
47
49
  // via the scrollbox itself). setTimeout(0) waits for layout to commit.
@@ -96,22 +98,23 @@ export function AgentsBar(props: AgentsBarProps) {
96
98
  scrollbarOptions: { visible: false },
97
99
  }}
98
100
  >
99
- <For each={allShown()}>
101
+ {/* <Index>: stable rows by position — see AgentsOnly (#52). */}
102
+ <Index each={allShown()}>
100
103
  {(session, i) => (
101
- <box id={agentRowId(i())}>
104
+ <box id={agentRowId(i)}>
102
105
  <AgentRow
103
106
  indicators={props.store.agentIndicators}
104
- session={session}
105
- cursor={isActive() && i() === agentRow()}
107
+ session={session()}
108
+ cursor={isActive() && i === agentRow()}
106
109
  nameMaxChars={48}
107
110
  onClick={() => {
108
111
  props.store.setActiveZone("agents");
109
- props.store.setCursor(0, i());
112
+ props.store.setCursor(0, i);
110
113
  }}
111
114
  />
112
115
  </box>
113
116
  )}
114
- </For>
117
+ </Index>
115
118
  </scrollbox>
116
119
  </Show>
117
120
  </box>
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Keep the Agents cursor on the same session while the list reorders.
3
+ *
4
+ * The cursor is a row index (`ui.row`), and sessions move all the time —
5
+ * sorted by recency, a new one lands on top, a working one climbs. Without
6
+ * this, the highlighted row silently became a different session and Enter /
7
+ * H / c acted on it. Call once inside the component that renders the list.
8
+ */
9
+
10
+ import { createEffect } from "solid-js";
11
+
12
+ import type { AgentSession } from "~/store/agents";
13
+ import type { TuiStore } from "~/store/index";
14
+
15
+ /** Pure decision, for tests: where the cursor should be after a change. */
16
+ export function followSelection(
17
+ prev: { list: readonly AgentSession[] | undefined; row: number; id: string | undefined },
18
+ list: readonly AgentSession[],
19
+ row: number,
20
+ ): number {
21
+ // Only a list change (not a keypress) may move the cursor.
22
+ if (list === prev.list || row !== prev.row || !prev.id) return row;
23
+ if (list[row]?.sessionId === prev.id) return row;
24
+ const idx = list.findIndex((s) => s.sessionId === prev.id);
25
+ return idx >= 0 ? idx : Math.min(row, Math.max(0, list.length - 1));
26
+ }
27
+
28
+ export function useStickyAgentCursor(store: TuiStore, list: () => readonly AgentSession[]): void {
29
+ const prev: { list: readonly AgentSession[] | undefined; row: number; id: string | undefined } = {
30
+ list: undefined,
31
+ row: -1,
32
+ id: undefined,
33
+ };
34
+ createEffect(() => {
35
+ const items = list();
36
+ const row = store.state.ui.row;
37
+ // `ui.row` belongs to whichever zone is active.
38
+ if (store.state.ui.activeZone !== "agents") {
39
+ prev.list = undefined;
40
+ prev.id = undefined;
41
+ return;
42
+ }
43
+ const next = followSelection(prev, items, row);
44
+ prev.list = items;
45
+ prev.row = next;
46
+ prev.id = items[next]?.sessionId;
47
+ if (next !== row) store.setCursor(0, next);
48
+ });
49
+ }
@@ -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
+ }
@@ -5,9 +5,10 @@
5
5
  * scrollChildIntoView (same trick used in BoardView for active columns).
6
6
  */
7
7
 
8
- import { For, Show, createEffect, createMemo } from "solid-js";
8
+ import { Index, Show, createEffect, createMemo } from "solid-js";
9
9
 
10
10
  import { AgentRow } from "~/ui/AgentRow";
11
+ import { useStickyAgentCursor } from "~/ui/agent-cursor";
11
12
  import { T } from "~/ui/glyphs";
12
13
  import { HARNESS } from "~/store/agents";
13
14
  import type { TuiStore } from "~/store/index";
@@ -16,10 +17,9 @@ interface ScrollBoxLike {
16
17
  scrollChildIntoView(id: string): void;
17
18
  }
18
19
 
19
- function rowId(sessionId: string): string {
20
- // Sanitize sessionId for use as an OpenTUI box id. UUIDs are already safe
21
- // (alphanumeric + dashes), but defensive belt-and-braces doesn't hurt.
22
- return `tuiboard-agent-${sessionId.replace(/[^a-zA-Z0-9]/g, "_")}`;
20
+ /** Rows are keyed by position (see the <Index> below), and so are their ids. */
21
+ function rowId(index: number): string {
22
+ return `tuiboard-agent-card-${index}`;
23
23
  }
24
24
 
25
25
  export function AgentsOnly(props: { store: TuiStore }) {
@@ -29,17 +29,17 @@ export function AgentsOnly(props: { store: TuiStore }) {
29
29
  const filter = () => props.store.state.ui.agentsFilter;
30
30
  const filterTag = () => (filter() === "all" ? "" : ` · ${HARNESS[filter() as keyof typeof HARNESS].code}`);
31
31
  let scrollBoxRef: ScrollBoxLike | undefined;
32
+ useStickyAgentCursor(props.store, sessions);
32
33
 
33
34
  // Auto-scroll the list so the active row is visible. setTimeout(0) waits
34
35
  // for OpenTUI to finish layout before requesting scroll.
35
36
  createEffect(() => {
36
37
  const row = agentRow();
37
38
  if (!isActive() || !scrollBoxRef) return;
38
- const target = sessions()[row];
39
- if (!target) return;
39
+ if (row >= sessions().length) return;
40
40
  setTimeout(() => {
41
41
  try {
42
- scrollBoxRef?.scrollChildIntoView(rowId(target.sessionId));
42
+ scrollBoxRef?.scrollChildIntoView(rowId(row));
43
43
  } catch {
44
44
  // Child not mounted yet — harmless.
45
45
  }
@@ -85,23 +85,29 @@ export function AgentsOnly(props: { store: TuiStore }) {
85
85
  scrollbarOptions: { visible: false },
86
86
  }}
87
87
  >
88
- <For each={sessions()}>
88
+ {/*
89
+ <Index>, not <For>: one stable row per position whose data
90
+ updates in place. Sessions are re-created on every refresh and
91
+ reorder by recency, and moving children inside OpenTUI's
92
+ scrollbox left stale/duplicate rows on screen (#52).
93
+ */}
94
+ <Index each={sessions()}>
89
95
  {(session, i) => (
90
- <box id={rowId(session.sessionId)}>
96
+ <box id={rowId(i)}>
91
97
  <AgentRow
92
- session={session}
93
- cursor={isActive() && i() === agentRow()}
98
+ session={session()}
99
+ cursor={isActive() && i === agentRow()}
94
100
  nameMaxChars={120}
95
101
  variant="card"
96
102
  indicators={props.store.agentIndicators}
97
103
  onClick={() => {
98
104
  props.store.setActiveZone("agents");
99
- props.store.setCursor(0, i());
105
+ props.store.setCursor(0, i);
100
106
  }}
101
107
  />
102
108
  </box>
103
109
  )}
104
- </For>
110
+ </Index>
105
111
  </scrollbox>
106
112
  </Show>
107
113
  </box>