tuiboard 0.13.0 → 0.13.1

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.1] - 2026-09-17
11
+
12
+ ### Fixed
13
+ - **Agents list stays in order when sessions move** (#52). A new session
14
+ appearing on top could show twice, leave blank rows, or draw rows in the
15
+ wrong order past the panel border; the lists now keep one stable row per
16
+ position. The cursor also stays on the session you selected while the list
17
+ reorders, instead of silently landing on another one (so Enter, `H` and `c`
18
+ act on the session you picked).
19
+ - **`--view=agents` / `--view=timeline` / `--view=board` start on their own
20
+ zone** (#54), so the cursor and keys work right away instead of acting on
21
+ the hidden planner.
22
+
10
23
  ## [0.13.0] - 2026-09-17
11
24
 
12
25
  tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
@@ -474,6 +487,7 @@ First public release on npm. This entry captures the full feature set at launch.
474
487
 
475
488
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
476
489
 
490
+ [0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
477
491
  [0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
478
492
  [0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
479
493
  [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.1",
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
 
@@ -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
+ }
@@ -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>