tuiboard 0.12.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.
@@ -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,21 +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
- session={session}
104
- cursor={isActive() && i() === agentRow()}
106
+ indicators={props.store.agentIndicators}
107
+ session={session()}
108
+ cursor={isActive() && i === agentRow()}
105
109
  nameMaxChars={48}
106
110
  onClick={() => {
107
111
  props.store.setActiveZone("agents");
108
- props.store.setCursor(0, i());
112
+ props.store.setCursor(0, i);
109
113
  }}
110
114
  />
111
115
  </box>
112
116
  )}
113
- </For>
117
+ </Index>
114
118
  </scrollbox>
115
119
  </Show>
116
120
  </box>
package/src/ui/Modal.tsx CHANGED
@@ -24,6 +24,7 @@ import { AGENDA_WIDTH } from "~/ui/layout";
24
24
  import { formatHm } from "~/store/timeline";
25
25
  import { HARNESS } from "~/store/agents";
26
26
  import { HARNESS_COLOR } from "~/ui/AgentRow";
27
+ import { herdrPlace } from "~/store/herdr";
27
28
  import type { TuiStore } from "~/store/index";
28
29
  import type { PriorityLevel, TimeBlock } from "~/types";
29
30
 
@@ -936,6 +937,27 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
936
937
  <span style={{ fg: T.textDim }}>status </span>
937
938
  <span style={{ fg: T.text }}>{s().status}</span>
938
939
  </text>
940
+ <Show when={s().herdr}>
941
+ {(h: () => NonNullable<ReturnType<typeof s>["herdr"]>) => (
942
+ <>
943
+ <text>
944
+ <span style={{ fg: T.textDim }}>herdr </span>
945
+ <span style={{ fg: T.accent }}>{herdrPlace(h())}</span>
946
+ <span style={{ fg: T.textDim }}>
947
+ {" "}pane {h().paneId} · {h().status} · matched by {h().matchedBy}
948
+ </span>
949
+ </text>
950
+ <Show when={h().matchedBy === "cwd"}>
951
+ <text wrapMode="word">
952
+ <span style={{ fg: T.textDim }}>
953
+ {" "}herdr doesn't report which {h().agent} session this pane is on — run
954
+ `herdr integration install {h().agent}` for an exact match.
955
+ </span>
956
+ </text>
957
+ </Show>
958
+ </>
959
+ )}
960
+ </Show>
939
961
  <text>
940
962
  <span style={{ fg: T.textDim }}>cwd </span>
941
963
  <span style={{ fg: T.text }}>{s().cwd}</span>
@@ -1126,7 +1148,8 @@ const HELP_SECTIONS: HelpSection[] = [
1126
1148
  emoji: "🤖",
1127
1149
  title: "Agents zone",
1128
1150
  rows: [
1129
- ["Enter", "Open (resume) the selected session in a new terminal tab/window"],
1151
+ ["Enter", "Open (resume) the selected session in a new terminal tab/window — or go to it if it's open in herdr"],
1152
+ ["H", "herdr: go to the session, or resume it in a new tab of its project's workspace"],
1130
1153
  ["c", "Copy a 'cd + resume' command for the session"],
1131
1154
  ["o", "Session detail (harness, model, cwd, branch, last prompts, resume cmd)"],
1132
1155
  ["f", "Filter by harness: all → cc Claude Code → cx Codex → oc OpenCode → pi Pi"],
@@ -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,22 +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"
102
+ indicators={props.store.agentIndicators}
96
103
  onClick={() => {
97
104
  props.store.setActiveZone("agents");
98
- props.store.setCursor(0, i());
105
+ props.store.setCursor(0, i);
99
106
  }}
100
107
  />
101
108
  </box>
102
109
  )}
103
- </For>
110
+ </Index>
104
111
  </scrollbox>
105
112
  </Show>
106
113
  </box>